Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2598,6 +2598,29 @@ the now-existing mechanism**, not built yet: the same `notify.SendPlain`/
`SendActionable` primitives this pass added would carry it, once a
concrete "fire on X" event is chosen.

**Attention escalation — `LOCKED` and built (docs/goals/archive/0023-
attention-escalation.md, ADR-0032's Update).** The `document.hasFocus()`-only
presence gate above had a real bug (focused-but-idle read as present,
observed live) — corrected, plus two layers added: **(1)** an
idle-aware presence gate, `internal/adapters/idletime` (`ioreg -c
IOHIDSystem`, zero cgo) + `SettingsService.isAway` (away = unfocused OR
idle ≥ a Settings-configurable threshold, default 300s; an idletime
read error fails toward away, §8's posture); `NotifyPendingApproval`
now takes the frontend's `hasFocus()` reading as a param instead of
gating client-side. **(2)** a floating approval prompt at the same
away verdict — `#/approvalprompt`, ADR-0033's second-window mechanism
reused (deliberately not `HideOnFocusLost`), Approve/Deny for an MCP
write or "Open in Mill" for a guardrail park. **(3)** alert-style
authorization is now actually requested (`notify.Start` previously
never called `RequestNotificationAuthorization` at all); Settings
documents the System Settings → Notifications → Mill → Alerts toggle.
**(4)** a cross-device forward, `composition.SendJSONWebhook` (reuses
the integration-http/decision-outcome transport tail) +
`SettingsService.ForwardPendingApproval`, default-off, POSTs
`{kind, id, description, createdAt}` to a Settings-configured
HTTPRequest, independent of the presence gate — the layer that reaches
the owner with no local Mac to notify on at all.

**Still `OPEN`, real named gaps:** a menu-bar/dock presence toggle (see
above); appearance settings beyond light/dark; a default working
directory/scope (blocked on §6); fullscreen window-state tracking
Expand Down
46 changes: 46 additions & 0 deletions docs/adr/0032-mcp-write-approval-park-and-poll.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,49 @@ this channel; unchanged.
- ADR-0017's status gains a pointer here; SPEC §3.6 (MCP approval
paragraph) and §3.7 (the notifications `OPEN` item, now partly
resolved by this) update in the implementing change.

## Update — the presence gate corrected from focus to idle+focus, plus a floating prompt and a cross-device forward (docs/goals/0023-attention-escalation.md, delivered)

The `document.hasFocus()` gate this ADR's §3 originally specified had
a real, observed bug: a focused-but-unattended Mac (the window sitting
in the foreground while the user was away from the desk entirely)
never notified, since a focused window read as "present" regardless of
how long it had sat idle. Corrected, all four items delivered:

- **Presence moved backend-side.** `internal/adapters/idletime`
(`ioreg -c IOHIDSystem`'s `HIDIdleTime` counter — zero cgo, no TCC
gate, confirmed directly) plus `SettingsService.isAway(focused)`:
away = unfocused OR idle ≥ threshold (default 300s, a Settings
knob). `NotifyPendingApproval` now takes the frontend's own
`document.hasFocus()` reading as a parameter instead of gating
client-side, so App.tsx's per-new-item loop always reports and the
backend makes the one presence decision. An idletime read error
(server mode, or a real desktop failure) fails TOWARD away — §8's
fail-safe posture applied here too.
- **A floating approval prompt**, not just the OS notification: the
same away verdict also shows a small always-on-top window
(`#/approvalprompt`, ADR-0033's second-window mechanism reused
verbatim — Hidden/Frameless/floating/hash-routed — deliberately NOT
`HideOnFocusLost`, since a decision prompt must not vanish just
because focus wandered; Escape is its one explicit dismiss). Shows
the oldest unresolved pending item; Approve/Deny inline for an MCP
write, "Open in Mill" for a guardrail/human-review park (blind
approval from the prompt is never offered, mirroring the
notification's own split above).
- **Alert-style authorization is now actually requested.** Checked
directly against the pinned notifications module's native
implementation: `RequestNotificationAuthorization` has no per-type
parameter to request Alert specifically — it always requests
`UNAuthorizationOptionAlert | Sound | Badge` as one fixed bundle, so
there was nothing to select, only something to call — which
`notify.Start` had never done at all until now (a real gap, not a
design choice). Backgrounded so app startup doesn't block on the
permission dialog.
- **A cross-device forward** for when there's no local Mac to notify
on at all: `composition.SendJSONWebhook` (the exact same transport
tail integration-http/decision-outcome's own webhook already share
— never a second HTTP client) plus `SettingsService.ForwardPendingApproval`,
a fire-and-forget, default-off, Settings-configured POST of
`{kind, id, description, createdAt}` to the owner's own
Configure-authored HTTPRequest (ntfy/Telegram/etc. — §1.1-clean),
independent of the presence gate above.
35 changes: 0 additions & 35 deletions docs/goals/0023-attention-escalation.md

This file was deleted.

9 changes: 6 additions & 3 deletions docs/goals/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,12 @@ this pipeline and on this code)**
fix the audited green-but-wrong class (12 silent persistence
sites, unfailable test fake, uncovered safety-badge source);
frontend half already largely clean
3. [ ] [0023 — Attention escalation](0023-attention-escalation.md) —
floating approval prompt + idle-aware presence + alert-style +
cross-device forward (twice-observed missed-decision pain)
3. [x] [0023 — Attention escalation](archive/0023-attention-escalation.md) —
delivered: floating approval prompt (ADR-0033's mechanism reused,
`#/approvalprompt`), idle-aware presence gate (`internal/adapters/idletime`,
backend-side `isAway`), alert-style authorization request (notify.Start),
cross-device forward (`composition.SendJSONWebhook`,
`ForwardPendingApproval`) — see ADR-0032's Update note

**Ratified 2026-08-10 (owner): three groups, A→B→C. 0001 stays standing
live-review material, interleaved during owner reviews, not a lane.**
Expand Down
76 changes: 76 additions & 0 deletions docs/goals/archive/0023-attention-escalation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 0023 — Attention escalation: decisions reach the human wherever they are

## Goal
A parked decision (MCP write, guardrail ask) reliably reaches the
owner — including away-from-this-Mac — closing the twice-observed miss
("still very much background-ish pattern and not user attention
pattern"). Layered per the ADR-0032 research + owner discussion.

## Plan
1. [x] Floating approval prompt — the incoming-call/askpass pattern:
an always-on-top mini-window Mill draws over whatever app is
focused when a decision parks, Approve/Deny inline. REUSES the
Quick Panel's second-window mechanism (ADR-0033); same
focus-yield-on-dismiss mitigation. Built: `main.go`'s
`approvalprompt` window (Hidden/Frameless/DisableResize/520×200/
WindowCentered/Floating/CanJoinAllSpaces/HideOnEscape, deliberately
NOT HideOnFocusLost), `SettingsService.SetApprovalPromptWindow`/
`showApprovalPrompt`/`DismissApprovalPrompt`
(settingsservice_approvalprompt.go), `app/ApprovalPrompt(App).tsx`
at the `#/approvalprompt` hash route — shows the oldest unresolved
pending item, Approve/Deny for an MCP write, "Open in Mill" for a
guardrail/human-review park (never blind-approve). E2e-covered
(`e2e/approval-prompt.spec.ts`); window-level behavior (floating
level, backend-triggered Show, Escape, focus-yield) stays
manual-only per `.claude/rules/testing.md`.
2. [x] Idle-aware presence gate: replace `document.hasFocus()` (a
focused window on an unattended Mac suppressed the notification by
design — observed live) with system idle time; "present" =
recently-active, not merely focused. Research the cgo-free macOS
idle-seconds option first. Built: `internal/adapters/idletime`
(`ioreg -c IOHIDSystem`'s `HIDIdleTime` counter, zero cgo, no TCC
gate — confirmed directly; unit-tested against a captured real
sample). The presence decision moved backend-side:
`SettingsService.isAway(focused)` — away = unfocused OR
idle≥threshold (default 300s, a Settings knob,
`GetAttentionIdleThreshold`/`SetAttentionIdleThreshold`) — and
`NotifyPendingApproval` now takes the frontend's own
`document.hasFocus()` as a param instead of gating client-side. An
idletime read error (server mode, or a real desktop failure) fails
TOWARD away, per §8's fail-safe posture.
3. [x] Alert-style notification guidance: request .alert
authorization; Settings copy documents the System Settings →
Mill → Alerts toggle (Duo's own documented ask — banners
auto-dismiss in ~5s, structurally background-ish). Also verify
notification delivery under the dev bundle's ad-hoc signature
(ADR-0032's named unknown; owner observations pending). Checked
directly against the pinned notifications module source
(`notifications_darwin.m`): `RequestNotificationAuthorization` has
no per-type parameter — it always requests
`UNAuthorizationOptionAlert | Sound | Badge` as one fixed bundle,
so there's nothing to select beyond calling it, which `notify.Start`
previously never did at all (a real, now-closed gap) — backgrounded
so app startup never blocks on the permission dialog. Settings
copy added naming the System Settings → Notifications → Mill →
Alerts toggle. Notification-delivery-under-dev-signing verification
stays an owner on-machine check, unblocked but not performed here.
4. [x] Cross-device forward: Settings-configured forward of
pending-decision events to the owner's OWN HTTPRequest (ntfy/
Telegram/etc.) — §1.1-clean (user-configured connector), the only
layer that reaches the owner at the work machine. Built:
`composition.SendJSONWebhook` (the exact transport tail
integration-http/decision-outcome's own webhook already share —
never a second HTTP client), `SettingsService.ForwardPendingApproval`
(fire-and-forget, default off, gated on enabled+configured, fires
`{kind, id, description, createdAt}` as the request's whole body,
independent of the presence gate), a Settings section (enable
toggle + the ADR-0009 `EntityRefField` request picker, reused
directly — `views/` importing `configure/` is allowed by
`.dependency-cruiser.cjs`). Go-tested against a local `httptest`
server via the `SetHTTPRequestLookup` seam.

## Acceptance
Owner at another machine (or idle) learns of a parked decision within
seconds via at least one configured layer, and can resolve it from the
floating prompt on return; the focused-but-idle suppression case is
demonstrably fixed.
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,23 @@ import * as guardrail$0 from "../../domain/guardrail/models.js";
import * as $models from "./models.js";

/**
* CreateRule validates and stores a new rule, minting its ID.
* CreateRule validates and stores a new rule, minting its ID. On a
* persist failure the appended rule is rolled back rather than left
* live in memory only -- a rule that silently failed to save must not
* appear to be gating anything (docs/goals/0025 item 2's memory-vs-
* store consistency rule, applied to guardrail rules too since a
* phantom-saved rule here is worse than most: it's the thing deciding
* whether a step needs approval).
*/
export function CreateRule(rule: guardrail$0.Rule): $CancellablePromise<guardrail$0.Rule> {
return $Call.ByID(3694735496, rule);
}

/**
* DeleteRule removes a rule by ID; deleting an absent rule is a no-op,
* matching every other Configure entity's delete semantics.
* matching every other Configure entity's delete semantics. Returns the
* persist error (rather than swallowing it, docs/goals/0025 item 1) and
* restores the deleted rule if the store write fails.
*/
export function DeleteRule(id: string): $CancellablePromise<void> {
return $Call.ByID(1475597571, id);
Expand All @@ -58,7 +66,7 @@ export function TestRules(workflowID: string, nodeID: string): $CancellablePromi

/**
* UpdateRule replaces an existing rule in place, same validation as
* create.
* create; rolls back to the previous rule value if the persist fails.
*/
export function UpdateRule(rule: guardrail$0.Rule): $CancellablePromise<void> {
return $Call.ByID(2711428157, rule);
Expand Down
Loading
Loading