Compare commits

..

126 Commits

Author SHA1 Message Date
felixm 6cb6adf7ef docs(settings): list the ambient env vars in the Settings comment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:02:33 -04:00
felixm b36964a427 fix(keeld): log the effective ambient cadence, not the raw setting
A settings.json predating the ambient fields deserializes cadence to 0;
the sentinel clamps that to its 5m default, so log the clamped value
instead of a misleading cadence=0s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:59:42 -04:00
felixm 6c81a0d4f8 feat(keeld): wire the ambient drift coach sentinel
Automated smoke test results:
- GET /: 200
- POST /ambient/snooze: 204
- GET /settings: includes ambient_mode and ambient_cadence_secs fields
- daemon log: "ambient: mode=notify cadence=0s" (cadence=0 because existing
  settings.json predates these fields; ambient.New defaults to 5 min when
  cadence is non-positive — correct degradation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:57:14 -04:00
felixm aaed1ad265 feat(web): ambient banner, snooze/focus actions, settings controls 2026-06-05 21:51:49 -04:00
felixm fa178a1fd2 feat(web): ambient line in state envelope + /ambient/snooze
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:46:45 -04:00
felixm ef845ce463 feat(settings): ambient_mode and ambient_cadence_secs fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:43:45 -04:00
felixm 4b2cb4242f feat(statusfile): render the ambient drift line when idle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:41:24 -04:00
felixm 7507cc6d28 fix(ambient): re-check active mode after the judge call (coexistence TOCTOU) 2026-06-05 21:39:46 -04:00
felixm c963895599 feat(ambient): evaluate loop, two-stage surfacing, snooze, config 2026-06-05 21:35:56 -04:00
felixm af3a0e2ac8 feat(ambient): sentinel core — evidence ring, Line, deps 2026-06-05 21:33:59 -04:00
felixm b37ee40dd2 feat(ai): AmbientDrift — frame-grounded drift judge 2026-06-05 21:30:37 -04:00
felixm 2690fdd513 refactor(offscreen): assemble frame via shared internal/frame 2026-06-05 21:28:21 -04:00
felixm 703218658b feat(frame): shared ~/owc frame assembler extracted for reuse
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 21:26:21 -04:00
felixm 607ad74def feat(notify): notify-send effector with nop fallback 2026-06-05 21:22:12 -04:00
felixm aac3173874 docs: implementation plan for the ambient drift coach
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:20:11 -04:00
felixm 9d7786c3e6 docs: design the ambient drift coach (always-on sentinel)
The first Control loop / scheduler component (architecture §3): an
always-on sentinel beside the one-mode harness that judges activity
against the ~/owc frame when no session is declared, and surfaces drift
to the status bar, a notify-send toast, and a web-UI banner.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:03:10 -04:00
felixm 682eb603fa fix(evidence): X11 sensor polls so tab/title changes are tracked
The X11 source only re-read the active window on _NET_ACTIVE_WINDOW
changes, so switching a browser tab — which changes the window title
but not the active window — was invisible. All the time on the new tab
was credited to the stale title (e.g. reading a Consume article showed
up as time on the Keel tab).

Mirror the Windows sensor: poll the active window every 750ms and emit
only when the window or its title changes. A shared, display-free
pollLoop carries the change-dedup and is unit-tested for the exact
regression (a title-only change must emit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 20:32:24 -04:00
felixm 6d296bf743 docs: mark off-screen AW memory as shipped in architecture §5 2026-06-05 20:08:20 -04:00
felixm c855731c39 fix(web): preserve aw_url when a settings POST omits it
A UI save was silently blanking the configured AW URL because
handlePostSettings bound the whole Settings from the request and saved
it as-is. Now reads s.settings.AWURL under the existing settingsMu lock
and carries it forward when req.AWURL is empty, so fields not exposed in
the form are never erased.
2026-06-05 20:05:59 -04:00
felixm bd2b8a41fd feat(offscreen): read recent proposals into the brief; prompt follows up
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:56:59 -04:00
felixm 16b44c9e90 feat(offscreen): record proposal_made/action_taken/proposal_dismissed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 19:51:18 -04:00
felixm 5bdb98c1a7 feat(keeld): construct AW memory store, nop fallback when AW is down 2026-06-05 19:45:24 -04:00
felixm 67f91442a4 feat(settings): KEEL_AW_URL with localhost default
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:39:49 -04:00
felixm 3a4f498f86 feat(memory): AW-backed Store with over-fetch kind filtering 2026-06-05 19:38:35 -04:00
felixm 7d4f9ca4a8 feat(memory): Store port with nop and fake adapters 2026-06-05 19:33:46 -04:00
felixm 582af0d68b feat(aw): Insert and Recent over the events endpoint 2026-06-05 19:32:43 -04:00
felixm b422b6deb6 feat(aw): AW REST client with idempotent EnsureBucket 2026-06-05 19:29:41 -04:00
felixm 3386405c7c docs: implementation plan for off-screen AW-backed memory
Nine TDD tasks: internal/aw REST client (EnsureBucket/Insert/Recent),
internal/memory port (Store + awStore over-fetch filter + nop + fake),
KEEL_AW_URL setting, harness.Services.Memory wiring with nop fallback,
off-screen write path (proposal_made/action_taken/proposal_dismissed) and
read path (## Recently proposed + follow-up prompt rule), plus full and
live verification. Derived from the 2026-06-05 design spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:16:47 -04:00
felixm 730ffe33ce docs: design off-screen AW-backed memory (first durable-memory slice)
Brainstormed design for Keel's first cross-run memory: a thin memory.Store
port backed by an internal/aw REST client writing to the keel.events bucket,
with off-screen as the single wired consumer (records proposal_made /
action_taken / proposal_dismissed and reads its recent history back into the
brief for fresher, follow-up-aware proposals). Brain-only, best-effort,
degrades to nop when AW is down. Realizes keel-architecture.md §5/§7 at the
smallest honest vertical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 19:08:41 -04:00
felixm 7d69a1f320 Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.

- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
  EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
  under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
  ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
  with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
  ignored, stale antidriftd binary removed.

Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:00:49 -04:00
felixm 258de2c14b docs: make Keel the repo front door + add agent context
- README: lead with Keel (the human-harness), frame AntiDrift as the focus
  mode, keep the milestone history. Fix the "rename deferred" wording (the
  dir/repo is already keel; only the code identity stays antidrift) and drop
  the dangling 2026-05-31 spec reference (that spec was intentionally removed).
- keel-architecture.md: update the rename status in the header and §8 — the
  directory is keel; the Go module / antidriftd binary / ANTIDRIFT_* env /
  ~/.antidrift -> ~/.keel migration land with the controller refactor.
- add AGENTS.md (agent orientation) + CLAUDE.md symlink so claude / codex /
  Hermes auto-load Keel context when launched in this repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 13:20:51 -04:00
felixm f9e580ff7e docs: name the cockpit Keel and add its architecture
Move the higher-goals cockpit architecture into this repo: AntiDrift is
Keel's seed, with its focus harness becoming one mode of a general
collect -> brain -> act loop. Records the locked decisions: the product
is the harness around a swappable brain (claude/codex/Hermes); storage is
ActivityWatch; two surfaces (web UI + status bar); gated -> autonomous
effectors. Code/repo rename to keel deferred until the controller-refactor
decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:57:14 -04:00
felixm 13633ffabf Fix drift sync: per-window verdicts, forgiving class match, event-driven status bar
Three independent defects made the focus state feel flaky and let the OS
status bar disagree with the web UI:

- Status file lagged the web by up to a minute: it rendered only on a 60s
  ticker with no hook into state changes. notify() now fans out to multiple
  listeners (AddOnChange) and the writer has a coalesced Wake() so drift
  reaches the bar as promptly as the browser.

- A brief off-task visit could latch drift for the whole session. Sibling
  windows of one app (a browser's reading tab vs its chat tab) share a window
  class, but the judge verdict was cached by class alone, so one tab's verdict
  poisoned the rest and never re-judged. Cache is now keyed by class + scrubbed
  title (judgedWindows) so siblings are judged independently.

- Allowed-class matching was exact equality, so a short token ("brave") never
  matched the real WM_CLASS ("Brave-browser") and every window was routed to
  the LLM. Matching is now substring-based, and planning surfaces the live
  window class with a click-to-add chip so users pick a token that matches.

Also fix the planning checkbox alignment: the global full-width input rule was
stretching the "Enforce focus" checkbox; scope it out for checkboxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:25:30 -04:00
felixm 9012d5ddc6 Purge leftover Rust implementation
Remove the legacy/ Rust tree (Cargo manifests, .rs sources, desktop
entry), drop Rust-specific .gitignore blocks and README note, and strip
now-dangling "ported from Rust" comments from the Go sources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 09:06:14 -04:00
felixm 91a3fb29c2 Add Windows 11 support behind evidence.Source and enforce.Guard ports
Polling active-window sensor and window-minimize guard as //go:build windows
adapters over a shared internal/winapi Win32 binding. Pure logic (class
normalization, emit-on-change tracking) is TDD-tested on Linux; syscall code is
cross-compile verified. No consumer changes.
2026-06-02 12:41:25 -04:00
felixm 9546999acb Clarify null-HWND doc note in ForegroundWindow 2026-06-02 12:39:32 -04:00
felixm d45a01eca5 Verify Windows cross-compile and Linux regression for Windows support
- GOOS=windows GOARCH=amd64 go build ./...  (whole module links)
- GOOS=darwin  GOARCH=amd64 go build ./...  (!linux && !windows fallback intact)
- go build ./... && go test ./...           (Linux host green, incl. new winapi/evidence tests)
- go vet ./... on linux and windows         (clean)
- cmd/antidriftd cross-compiles to a real PE32+ x86-64 .exe
2026-06-02 12:37:24 -04:00
felixm b6c7933f16 Align Windows guard ctx param and doc with X11 guard 2026-06-02 12:36:12 -04:00
felixm f37e295166 Add Windows window-minimize guard 2026-06-02 12:34:14 -04:00
felixm d149736946 Share unavailable() so the Windows sensor logs unavailable transitions 2026-06-02 12:33:23 -04:00
felixm 0daf8c2849 Add Windows polling active-window sensor 2026-06-02 12:30:16 -04:00
felixm 0f47d2dbf0 Document title/path truncation behavior in Win32 binding 2026-06-02 12:29:07 -04:00
felixm 0ac1511aac Add Win32 binding for foreground window and minimize 2026-06-02 12:24:55 -04:00
felixm 7d62af0750 Document foregroundTracker unavailable-state contract
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:23:54 -04:00
felixm e1194be3c2 Add emit-on-change tracker for the Windows polling sensor 2026-06-02 12:21:58 -04:00
felixm f79c149039 Clarify .exe suffix handling in class normalization 2026-06-02 12:21:12 -04:00
felixm 4ea8aed04b Add pure process-path to class normalization for Windows 2026-06-02 12:18:59 -04:00
felixm 885b0f9224 Plan Windows 11 support implementation
Six TDD/cross-compile tasks: pure class normalization and emit-on-change
tracker (unit-tested on Linux), Win32 binding, the two windows-tagged port
adapters, build-tag narrowing, and a whole-module cross-compile gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:08:01 -04:00
felixm e0ea1eca83 Spec Windows 11 support via windows-tagged port adapters
Full-parity design: evidence.Source (polling) and enforce.Guard
(ShowWindow) behind the existing X11/no-op port boundaries, pure Go,
verified compile-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:35:39 -04:00
felixm 14ec2b6ea8 Remove shipped specs and plans; history and code are the record
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:00:52 -04:00
felixm b447432df0 Track faithful-reflection implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:56:48 -04:00
felixm ca5e76baf5 Test faithful on/off-task crediting through RecordWindow
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:05:07 -04:00
felixm e5c32563d0 Render on/off-task split in the reflection block 2026-06-01 21:02:23 -04:00
felixm 7eb2b5dc58 Split session time into on/off/unclassified buckets
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:00:52 -04:00
felixm 6b99804132 Add faithful-reflection on/off-task split design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:49:19 -04:00
felixm 38e1a175b6 Harden settings UI: robust parentDir, escape browse paths, surface load error
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:29:42 -04:00
felixm ce201c57bb Add settings gear, overlay, and file-browser modal to the UI
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:26:24 -04:00
felixm 326990d69a Serialize settings applies; log fallback save error
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:24:17 -04:00
felixm 324aa3ebce Drive daemon config from settings file via injected applier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:21:07 -04:00
felixm 86c85629c0 Remove /knowledge/path; folded into /settings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:18:44 -04:00
felixm acf336c846 Strengthen /fs/browse test: assert dir and entry paths
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:16:36 -04:00
felixm a108964457 Add GET /fs/browse directory-listing endpoint
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:15:00 -04:00
felixm 1a925b3a05 Clarify settings apply/save comment; strengthen settings tests
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:13:33 -04:00
felixm 2a196bcdb2 Add GET/POST /settings handlers with injected applier
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:10:47 -04:00
felixm e3351b3d70 Add settings package: Settings file load/save/seed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:07:42 -04:00
felixm ae5cec3dce Add settings page implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:53:27 -04:00
felixm 3fdbfd307b Add settings page + settings file design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:47:42 -04:00
felixm 4f6b26a220 Trust no proxies on the local server
antidriftd binds localhost only and sits behind no proxy, so trusting all
proxies (gin's default) is both wrong and a startup warning. Set an empty
trusted-proxy list to disable forwarded-IP header trust and silence it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:24:19 -04:00
felixm 71b8c80c69 Scrub spinner glyphs from window-title bucket keys
Bucket keys are {Class, ScrubTitle(Title)}, so each animated progress frame a
terminal app cycles into its title (Claude Code's braille and dingbat-star
glyphs) became a distinct bucket — fragmenting one task into dozens of rows and
inflating the switch count, since applyEvent counts a switch per key change.

ScrubTitle now drops any non-ASCII symbol or control rune and collapses leftover
whitespace. This is category-based rather than range-based, so future spinner
sets are covered too, while letters and digits of every script survive and ASCII
symbols (e.g. "C++") are left intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:23:49 -04:00
felixm 1c81eb1b50 Refresh the active evidence band live and drop its duplicate switch count
The active view's fast-path repainted only the status band, so the "now"
window, per-window buckets, and their times froze at the moment the active
screen first rendered — only the status-band switch count and the countdown
kept moving. Give the evidence band a stable id and repaint it every tick via
updateActiveEvidence, mirroring updateActiveDrift.

The evidence band's own "context switches" readout duplicated the live count
in the status band, so remove it (and its now-dead .switches CSS rule).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:23:49 -04:00
felixm d95aed0111 Add AntiDrift favicon 2026-06-01 19:09:37 -04:00
felixm 895ad342c7 Migrate the reflection fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:12:10 -04:00
felixm 59283be733 Migrate the coach fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:08:48 -04:00
felixm fbbff6966d Migrate the knowledge fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:06:09 -04:00
felixm db0d11ea2e Add runFetchAsync helper and migrate the tasks fetch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 18:05:08 -04:00
felixm d3f0526355 Split async AI roles into roles.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:57:26 -04:00
felixm 0d3294b30d Split drift/nudge/enforcement into drift.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:50:53 -04:00
felixm d068e2f79c Split evidence-stats accounting into stats.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:15 -04:00
felixm a0f144968b Split session views into views.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:42:53 -04:00
felixm f13f33cf74 Add knowledge stale-discard characterization test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:39:40 -04:00
felixm 465938906e Add M9 tame-session refactor implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:31:57 -04:00
felixm 79b3c0be95 Add M9 tame-session refactor design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:24:36 -04:00
felixm 5adab985a7 Add the enforce toggle and drift-band minimize note
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:49:31 -04:00
felixm 4cc1edfc60 Wire the enforce guard into the daemon and assert it on the wire
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:46:59 -04:00
felixm 47f1167247 Minimize the off-task window on confirmed drift at the block level
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:43:30 -04:00
felixm 41c0a5fbbc Thread per-commitment enforcement level through to the snapshot
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:41:09 -04:00
felixm 7fdcae5d14 Add enforce.Guard port with X11 and no-op adapters
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:35:54 -04:00
felixm d0e6893659 Add M8 Tier A window-minimize enforcement implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:22:59 -04:00
felixm 76cdd4c857 Add M8 Tier A window-minimize enforcement design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:11:56 -04:00
felixm e3cfe8c70c Assert the reviewer fires exactly once per session
Closes the one coverage gap from the final review: the spec's
'one async call per session' efficiency claim is now directly
verified via the reviewer call count, not just implied. Also drops a
stale CarryForward-preview mention from the Review projection in the
design spec to match the shipped (cleaner) behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:47:27 -04:00
felixm 95e14e6340 Show the recap on Review and the carry-forward on Planning
The Review screen renders the reviewer's one-line recap (quiet pending
line, then the recap); the Planning screen renders last session's
carry-forward as a 'Last time: …' one-liner, mirroring the knowledge
indicator. Updates the README Status section for M7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:42:21 -04:00
felixm ff100b2472 Wire the reviewer and assert reflection on the wire
main injects the AI service as the reviewer alongside the other roles.
A web test drives a session to Review and asserts the recap rides the
state payload, then to the next Planning and asserts the carry-forward
does too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:38:13 -04:00
felixm a4f0c00cac Reflect on entering Review and ground the next coach
The controller fetches a reflection asynchronously on enterReview
(generation-guarded, non-blocking, graceful) and caches a one-line
recap plus a latest-wins carry-forward. The recap projects onto Review,
the carry-forward onto the next Planning, and both ride the snapshot.
RequestCoach composes the carry-forward into the coach's existing
free-form grounding string, so ai.Coach is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:36:50 -04:00
felixm 25f56b3c71 Add RecentSessions reader over the audit chain
Exposes the last n session summaries (oldest-first) for the reviewer to
read as recent-history context. Missing/empty chain or n<=0 yields nil.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:26:57 -04:00
felixm e4e34b092c Add reviewer role to the ai port
A fourth leaf role: Review(finished, history) returns a two-line
Reflection (recap + carry_forward) over the same CLI backend as the
coach. Primitive-only, with a JSON prompt and a tolerant parser that
rejects a blank recap but allows an empty carry_forward.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:24:04 -04:00
felixm 82fd7d35a5 Add M7 reflection implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 09:20:55 -04:00
felixm f417faab9a Add M7 reflection design spec 2026-06-01 09:11:00 -04:00
felixm 5342da2755 Show a profile-grounding indicator on the planning screen
The planning view renders a quiet line for the standing-profile state
(loading/grounded/absent/unreadable) with a "change" affordance to
repoint the file via POST /knowledge/path. The profile text never
reaches the browser — only status, path, and size. Also updates the
README Status section to describe the M6 knowledge port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:08:55 -04:00
felixm b1b807590c Wire the knowledge file adapter and a runtime path selector
main constructs the FileSource (default via ANTIDRIFT_KNOWLEDGE_FILE)
and injects it. Adds POST /knowledge/path to repoint the profile file at
runtime, and web tests asserting the planning payload carries the
knowledge object (status + path, never the text) and that path selection
reloads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:06:10 -04:00
felixm 8bd37ed46d Load the knowledge profile on planning and ground the coach
The controller loads the profile asynchronously on entering planning,
mirroring the tasks fetch: generation-guarded goroutine, status enum
(idle/pending/ready/absent/error), projected into State only while
planning and only when a source is set. The loaded text is cached and
passed to the coach as grounding; SetKnowledgePath repoints the file at
runtime. nil source degrades to no view and an ungrounded coach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:04:13 -04:00
felixm 0f1790c8d5 Thread optional grounding into the coach prompt
The ai.Coach interface gains a grounding parameter carrying standing
context about the user (the knowledge port). buildPrompt injects an
"About the user" block only when grounding is non-empty, so an empty
grounding produces a byte-for-byte unchanged prompt. Drift judge and
nudge are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:01:03 -04:00
felixm 09061f8e30 Add knowledge port and single-file adapter
The knowledge.Source port answers "who am I; what are my priorities?".
The FileSource adapter reads one profile file (path selectable per call),
treats a missing/blank file as absent (not an error), and caps the text.
Leaf package mirroring tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:59:48 -04:00
felixm 9e03add01b Add M6 knowledge-port design spec and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:58:55 -04:00
felixm 9452e54867 Add M5 tasks-port implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:11:59 -04:00
felixm 8b5121c69d Show today's tasks as seed chips on the planning screen
The planning view renders Marvin's today list as clickable chips;
clicking one fills the intent field, feeding the existing coach flow.
idle/error/empty render nothing, pending shows a quiet loading line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:09:38 -04:00
felixm e832a2da85 Wire Marvin tasks adapter into the daemon
main constructs the Marvin adapter (command overridable via
ANTIDRIFT_MARVIN_CMD) and injects it. Adds a web test asserting the
planning state payload carries today's tasks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:07:20 -04:00
felixm c4eb870808 Test that a stale tasks fetch result is discarded
Exercises the generation/left-planning guard in startTasksFetchLocked
via the fakeProvider gate: a fetch in flight when its generation is
superseded must not clobber the current tasks state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:06:04 -04:00
felixm 8b7bec1173 Fetch today's tasks asynchronously on entering planning
The controller fetches the Marvin task list when entering planning,
mirroring the planning coach: generation-guarded goroutine, status enum
(idle/pending/ready/error), projected into State only while planning and
only when a provider is set. nil provider degrades to no tasks view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:02:28 -04:00
felixm 569264be11 Add tasks port and Amazing Marvin adapter
The tasks.Provider port answers "what should I be doing?". The Marvin
adapter shells out to ampy's `am --json` and parses today's open tasks,
dropping done/empty-title entries. Leaf package mirroring ai.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:59:00 -04:00
felixm 267eda59e6 Add M5 tasks-port design spec
Design for the tasks.Provider port: an Amazing Marvin adapter shelling
out to `am --json`, today's tasks seeding the planning intent field.
Read-only, no writeback, graceful degradation — mirrors the ai port.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:50:15 -04:00
felixm 6b2a96620c M4: drop stale plan-task reference from CSS comment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:16:11 -04:00
felixm 51cb09c520 M4: presentational review recap
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:13:04 -04:00
felixm f5da12362a M4: cockpit HUD with state-driven accent
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:10:03 -04:00
felixm 76816ae188 M4: split web UI assets into app.css and app.js
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:05:34 -04:00
felixm 93e779348c M4: add look-good implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 21:03:34 -04:00
felixm c807a72623 Spec M4: cockpit-style web UI design pass
State-driven accent HUD, stacked bands, CSS/JS split out of the inline
HTML, polished presentational review recap. No behavior changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:57:32 -04:00
felixm 097678e839 Mirror runtime status to ~/.antidrift_status
A window-manager status bar can now read a single-line status file: a
glyph plus minutes-remaining (● 24m / ⚠ DRIFT 24m / ● 24m ·? / ◔ planning
/ ✓ review), empty when idle. Rewritten on a one-minute tick, only when
the line changes, and removed on shutdown. Degrades to no file if $HOME
is unresolvable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:49:48 -04:00
felixm 19654c2b57 Align M3.5 design doc with the nudgeEpoch guard
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:52:01 -04:00
felixm 622a1cd401 Tie nudge validity to the on-task stretch
A soft nudge advisory belongs to one continuous on-task stretch in an allowed
app. Guard it with an on-task-stretch epoch (advanced on session reset and on
any non-on-task-match observation) instead of a session-only counter, and clear
the advisory on leaving the allowed app, so a stale 'Heads up' never resurfaces
after a drift episode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:20:22 -04:00
felixm 9dd0bb934e Wire the semantic nudge into the daemon and document M3.5
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:11:42 -04:00
felixm 20a102b4a4 Add dismiss-only Heads up tier for semantic nudges
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:10:03 -04:00
felixm 97577c003f Assert semantic nudge reaches the state payload
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:08:51 -04:00
felixm b43ba5179d Orchestrate semantic nudge on the on-task path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 18:07:31 -04:00
felixm b96319d847 Add ai.Nudger role for semantic drift within allowed apps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:57:20 -04:00
felixm 4e5e1f3881 Plan M3.5 semantic nudge implementation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:52:31 -04:00
felixm 5818d401c3 Design M3.5 semantic nudge within allowed apps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:44:30 -04:00
129 changed files with 16870 additions and 20830 deletions
+1 -11
View File
@@ -1,16 +1,6 @@
# Generated by Cargo will have compiled files and executables
debug/
target/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Local brainstorming companion artifacts
.superpowers/
# Go
/antidriftd
/keeld
*.test
+64
View File
@@ -0,0 +1,64 @@
# Keel — Agent Context
You are working in **Keel**, Felix's human-harness. Read this, then treat
`docs/keel-architecture.md` as the source of truth for anything architectural.
## What this is
The inversion of an agent harness: instead of a harness watching a model work, a
model (the brain) helps observe and steer a *human*. Felix is the observed
subject; the brain is the operator. The thing we build is **the harness** — the
framework that collects Felix's real state, hands it to a swappable brain, and
acts on the reply.
- **Brain** — swappable, rented compute: `claude` / `codex` / **Hermes** (real,
on another machine). Not the point; interchangeable behind one interface.
- **Storage** — **ActivityWatch** buckets (`keel.state`, `keel.events`). No new
DB / event store. Buckets hold *pointers*, not copies.
- **Frame** — `~/owc` (values, goals, `resources/bug-*.md` life-bugs). Reference,
never duplicate.
- **Surfaces** — a phone-friendly web UI + the WM status bar (`~/.keel_status`).
- **Effectors** — gated writes (Marvin tasks, capture, AntiDrift enforce; later
Beeminder datapoints, `~/owc` notes). Start propose-and-confirm; trend toward
autonomy. The gate is per-effector config — Felix's agency-vs-cage dial.
## What runs today
**AntiDrift**, Keel's first *mode*: a Go focus daemon (ports-and-adapters) —
`evidence.Source` (perception) · `tasks.Provider` (Marvin) · `knowledge.Source`
(the `~/owc`-derived profile) · `ai.Backend` (the brain) · `enforce.Guard`
(window-minimize). The cockpit work is **loosening its session controller** into a
general *collect → brain → act* loop where a focus-session is one mode among many.
```bash
go run ./cmd/keeld # local web UI at http://localhost:7777
go test ./...
```
## Naming / rename status
The rename is **complete**. Directory, git repo, Go module, binary, runtime, and
env are all `keel`: module `keel`, binary `keeld`, runtime `~/.keel/`,
`KEEL_*` env. If you have a live ledger under the old `~/.antidrift/` directory,
move it manually to `~/.keel/` — there is no automated migration tool.
## How to work here
- **Reuse before building.** Read the code before claiming something is missing.
AntiDrift already is the harness skeleton; widen it, don't rebuild.
- **Reference, don't duplicate.** `~/owc` and each tool's store stay source of
truth; Keel never becomes a hidden second source.
- **Ship visible proof.** Every session ends with a real artifact — reframing and
planning are not progress. Felix's named failure mode is tooling-as-avoidance;
resist it.
- **Concise coaching.** The status line is one signal / one action / one question.
Not "nice summaries" — that was explicitly rejected.
- **Local-first; mind secrets.** The operator runs as the full Unix user with no
sandbox; window titles and on-disk keys can leak. Filter at the assembler.
## Pointers
- `docs/keel-architecture.md` — authoritative architecture (components, data flow,
AW-as-storage, the open questions in §8).
- `docs/superpowers/` — AntiDrift focus-mode feature specs/plans (historical).
- `README.md` — project front door.
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+74 -11
View File
@@ -1,19 +1,45 @@
# AntiDrift
# Keel
A personal focus operating system: treat each work session as an explicit
commitment (next action, success condition, timebox), and make drift visible.
A **human-harness** that helps Felix hold course toward his higher goals: it
collects his real state from the tools he already uses, hands it to a swappable
brain (`claude` / `codex` / Hermes), and acts on the reply through gated
effectors — surfaced on a web UI and the WM status bar. The brain, the storage
(ActivityWatch), and the data sources are reused; the harness is what we build.
This is the Go reimagining. The original Rust implementation is preserved under
`legacy/` for reference. See `docs/superpowers/specs/` for the design.
> **Architecture:** [`docs/keel-architecture.md`](docs/keel-architecture.md) is the
> source of truth. **Agents:** read [`AGENTS.md`](AGENTS.md) first.
## Modes
Keel's controller is now a generic **harness** that hosts one *mode* at a time,
and the daemon ships two:
- **Focus (AntiDrift)** — Keel's first mode: a personal focus operating system
that treats each work session as an explicit commitment (next action, success
condition, timebox) and makes drift visible. The milestone history below is
this mode's record.
- **Off-screen** — an away-from-the-desk mode: it reads today's Marvin tasks plus
the `~/owc` goals and life-domain `bug-*.md` notes, asks the brain for one
worthwhile off-screen action, and on confirm files it as a Marvin task.
When no mode is active the web UI shows a **launcher** to start either one.
Further modes (body, review, capture) are aspirational. The milestone history
below records the focus mode's development.
> **Naming:** the rename is complete — directory, repo, Go module, binary,
> runtime, and env are all `keel` (`keeld` binary, `~/.keel/` runtime,
> `KEEL_*` env). If you have a live ledger under the old `~/.antidrift/`
> directory, move it manually to `~/.keel/`.
## Run
```bash
go run ./cmd/antidriftd
go run ./cmd/keeld
```
The daemon serves a local web UI at http://localhost:7777 and opens your
browser. State is persisted to `~/.antidrift/state.json`.
browser. Per-mode state is persisted under `~/.keel/modes/<kind>/` (e.g.
`~/.keel/modes/focus/state.json`).
## Test
@@ -23,6 +49,43 @@ go test ./...
## Status
**M8 (Tier A) — Enforcement (window-minimize).** Drift finally costs something.
A planning-screen "Enforce focus" toggle arms the new `enforce.Guard` port: when
the drift judge confirms the active window is off-task, the daemon minimizes that
window (native X11, no `xdotool`). It is unprivileged, per-session (the chosen
enforcement level rides the snapshot), and degrades to today's advisory behavior
when off, unwired, or on a platform without the X11 adapter.
M7 (reflection): when a session ends, a fourth AI role — the reviewer —
reflects on it, read against your recent sessions, and produces two short
lines: a recap shown on the Review screen, and a carry-forward takeaway that
grounds the coach the next time you plan. It runs once asynchronously on
entering Review, never blocks the End button, and degrades gracefully — with
no backend (or a slow/failed call) Review and Planning behave exactly as
before. The carry-forward is snapshot-persisted (latest-wins) and composes
into the coach's grounding; the reflection lines are short and cross the wire
by design, while the knowledge profile still does not.
M6 (knowledge port): the planning coach now sharpens intents against who you
actually are. A single profile file (`~/.keel/knowledge.md`, overridable
via `KEEL_KNOWLEDGE_FILE`) holds your standing context — priorities,
values, what counts as good work — and the daemon loads it asynchronously on
entering planning, mirroring the tasks fetch. The cached text grounds the AI
coach prompt only; the drift judge and nudge are untouched, and the profile text
never crosses the wire. A subtle planning-screen indicator shows whether the
profile loaded and from where, with a "change" affordance to repoint at another
file. It degrades gracefully — a missing, blank, or unreadable file just leaves
the coach ungrounded, and planning still works.
M3.5 (semantic nudge): the drift interceptor catches the *wrong app*, but not
the *wrong work inside a right app*. M3.5 adds a third, ambient AI role that —
only while you are on-task in an allowed app — periodically reads your recent
window titles and, if the trajectory has wandered from the commitment, shows a
soft, dismissible "Heads up" line (no interrupt, no buttons to fight). It is
debounced to roughly one check every five minutes, reuses the same CLI backend
as the coach and drift judge, and degrades gracefully — without it, everything
else still works.
M3 (drift interceptor): while a commitment is Active, the daemon watches the
focused window. A cheap local match against the session's allowed window classes
is authoritative for on-task; only unmatched windows are sent to the LLM drift
@@ -33,12 +96,12 @@ The drift judge degrades gracefully — without it, local matching still runs.
M2 (AI planning coach): in the Planning view, a rough intent is "sharpened"
into a structured commitment (next action, success condition, timebox) by an
LLM CLI backend (claude or codex, selectable via `ANTIDRIFT_AI_BACKEND`). The
LLM CLI backend (claude or codex, selectable via `KEEL_AI_BACKEND`). The
coach runs asynchronously and degrades gracefully — manual planning always
works.
M1 (evidence & audit): active-window tracking, two-tier evidence store
(disposable per-session raw log + permanent hash-chained session summaries),
and live SSE updates. Live drift judgment and the ambient nudge arrive in later
milestones (see the roadmap in
`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`).
and live SSE updates. Live drift judgment and the ambient nudge arrived in later
milestones (M3 and M3.5 above; the original roadmap spec has since been removed —
code and git history are the record).
-78
View File
@@ -1,78 +0,0 @@
// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI
// and owns the commitment state machine.
package main
import (
"context"
"log"
"os"
"os/exec"
"runtime"
"time"
"antidrift/internal/ai"
"antidrift/internal/evidence"
"antidrift/internal/session"
"antidrift/internal/store"
"antidrift/internal/web"
)
const addr = "localhost:7777"
func main() {
path, err := store.DefaultPath()
if err != nil {
log.Fatalf("resolve snapshot path: %v", err)
}
ctrl, err := session.New(path)
if err != nil {
log.Fatalf("load session: %v", err)
}
srv := web.NewServer(ctrl)
srv.Init() // re-arm or expire a restored Active session
// Wire the AI planning coach. Backend selectable via ANTIDRIFT_AI_BACKEND
// (claude default, or codex). Misconfiguration degrades to no coach rather
// than failing startup — manual planning still works.
if backend, err := ai.NewBackend(os.Getenv("ANTIDRIFT_AI_BACKEND")); err != nil {
log.Printf("ai disabled: %v", err)
} else {
svc := ai.NewService(backend)
ctrl.SetCoach(svc)
ctrl.SetDriftJudge(svc)
log.Printf("ai: %s backend (coach + drift judge)", backend.Name())
}
src := evidence.NewSource()
go src.Watch(context.Background(), ctrl.RecordWindow)
go openBrowser("http://" + addr)
log.Printf("antidriftd listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+250
View File
@@ -0,0 +1,250 @@
// Command keeld is the Keel daemon: it serves a local web UI and drives the
// harness — the general collect→brain→act loop that hosts one mode at a time
// (focus or offscreen).
package main
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"keel/internal/ai"
"keel/internal/ambient"
"keel/internal/aw"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/offscreen"
"keel/internal/notify"
"keel/internal/settings"
"keel/internal/statusfile"
"keel/internal/tasks"
"keel/internal/web"
)
const addr = "localhost:7777"
func main() {
home, err := os.UserHomeDir()
if err != nil {
log.Fatalf("resolve home dir: %v", err)
}
root := filepath.Join(home, ".keel")
// Resolve and load settings, seeding from the legacy KEEL_* env vars on
// first run. After first run the file is the sole source of truth; env is
// ignored. A missing file is the first-run signal.
settingsPath, err := settings.DefaultPath()
if err != nil {
log.Fatalf("resolve settings path: %v", err)
}
cfg, err := settings.Load(settingsPath)
if err != nil {
cfg = settings.SeedFromEnv()
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not write %s (continuing): %v", settingsPath, err)
} else {
log.Printf("settings: seeded %s from environment", settingsPath)
}
}
// buildServices turns a Settings value into a fresh harness.Services. It
// validates the AI backend FIRST and returns an error before constructing any
// ports, so an invalid backend never half-rewires the harness. The same code
// path serves startup and live POST /settings edits.
buildServices := func(s settings.Settings) (harness.Services, error) {
backend, err := ai.NewBackend(s.AIBackend)
if err != nil {
return harness.Services{}, fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err)
}
svc := ai.NewService(backend)
// Defend against a hand-blanked aw_url: SeedFromEnv defaults this only on
// first run, but Load returns whatever the JSON file holds on later starts.
awURL := s.AWURL
if awURL == "" {
awURL = "http://localhost:5600"
}
var mem memory.Store = memory.NewNop()
awc := aw.New(awURL)
if err := awc.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
log.Printf("memory: AW unavailable at %s (%v); running without memory", awURL, err)
} else {
mem = memory.NewAWStore(awc, "keel.events")
log.Printf("memory: keel.events ready at %s", awURL)
}
return harness.Services{
AI: svc,
Tasks: tasks.NewMarvin(s.MarvinCmd),
Knowledge: knowledge.NewFileSource(s.KnowledgePath),
Enforce: enforce.NewGuard(),
Memory: mem,
Clock: time.Now,
}, nil
}
// Build the base services at startup. A bad persisted backend should not be
// fatal: fall back to claude (always valid), persist the correction, and retry.
base, err := buildServices(cfg)
if err != nil {
log.Printf("settings: %v; falling back to claude backend", err)
cfg.AIBackend = "claude"
if err := settings.Save(settingsPath, cfg); err != nil {
log.Printf("settings: could not persist claude fallback to %s: %v", settingsPath, err)
}
base, err = buildServices(cfg)
if err != nil {
log.Fatalf("settings: build failed even with claude: %v", err)
}
}
log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath)
log.Printf("enforce: window-minimize guard")
// withDir namespaces a factory's persistence under ~/.keel/modes/<kind>; the
// mode builds its own file paths under Services.Dir.
withDir := func(dir string, f harness.Factory) harness.Factory {
return func(s harness.Services) mode.Mode {
s.Dir = dir
return f(s)
}
}
focusDir := filepath.Join(root, "modes", "focus")
offscreenDir := filepath.Join(root, "modes", "offscreen")
h := harness.New(base, map[string]harness.Factory{
"focus": withDir(focusDir, focus.Factory),
"offscreen": withDir(offscreenDir, offscreen.Factory),
})
// Re-adopt a live focus session restored from disk, if one was in flight.
// Use h.Services() (Notify wired) rather than base (Notify nil) so the
// restored mode's async completions reach the harness's onChange listeners.
focusSvc := h.Services()
focusSvc.Dir = focusDir
if m, live, err := focus.Restore(focusSvc); err != nil {
log.Printf("focus: restore failed (continuing idle): %v", err)
} else if live {
if err := h.Adopt(m); err != nil {
log.Printf("focus: adopt restored session failed: %v", err)
} else {
log.Printf("focus: restored a live session")
}
}
// Ambient drift coach: an always-on sentinel beside the harness. It reuses the
// base ports (AI, knowledge, tasks, memory, clock) and stays silent whenever a
// mode is active. Backend/port changes need a daemon restart to reach it; live
// settings only re-dial its cadence and mode (mirrors "active mode keeps its
// services").
ambMode := cfg.AmbientMode
if ambMode == "" {
ambMode = settings.AmbientNotify
}
// Compute the effective cadence so the log reflects what the sentinel will
// actually use: New clamps a non-positive value (e.g. a settings.json that
// predates these fields) to a default, mirrored here for the log line.
ambCadence := time.Duration(cfg.AmbientCadenceSecs) * time.Second
if ambCadence <= 0 {
ambCadence = 5 * time.Minute
}
sentinel := ambient.New(ambient.Deps{
AI: base.AI,
Knowledge: base.Knowledge,
Tasks: base.Tasks,
Notifier: notify.NewNotifier(),
Memory: base.Memory,
Clock: time.Now,
ActiveMode: func() string { return h.State().ActiveMode },
}, ambCadence, ambMode)
log.Printf("ambient: mode=%s cadence=%s", ambMode, ambCadence)
srv := web.NewServer(h)
srv.Init() // re-arm or expire a restored deadline-bearing session
srv.SetAmbient(sentinel.Line, sentinel.Snooze)
sentinel.AddOnChange(srv.Broadcast)
// applyFn re-wires the running daemon from a new Settings value: it rebuilds a
// fresh harness.Services and installs it via SetServices. Per the harness
// contract this affects the NEXT mode start; an already-active mode keeps the
// services it was built with. It validates the backend before mutating, so
// POST /settings can reject an invalid backend atomically.
applyFn := func(s settings.Settings) error {
if s.AmbientMode != "" && !settings.ValidAmbientMode(s.AmbientMode) {
return fmt.Errorf("%w: %q", settings.ErrInvalidAmbientMode, s.AmbientMode)
}
next, err := buildServices(s)
if err != nil {
return err
}
h.SetServices(next)
mode := s.AmbientMode
if mode == "" {
mode = settings.AmbientNotify
}
sentinel.SetConfig(time.Duration(s.AmbientCadenceSecs)*time.Second, mode)
return nil
}
srv.SetSettings(settingsPath, cfg, applyFn)
// Mirror runtime status to ~/.keel_status for a window-manager bar.
// A misconfigured home dir degrades to no status file, not a failed start.
if statusPath, err := statusfile.DefaultPath(); err != nil {
log.Printf("status file disabled: %v", err)
} else {
writer := statusfile.NewWriter(statusPath, h.State, sentinel.Line)
h.AddOnChange(writer.Wake)
sentinel.AddOnChange(writer.Wake)
go writer.Run(context.Background())
log.Printf("status: writing %s", statusPath)
}
// Feed the window sensor stream into BOTH the harness (for the active mode)
// and the ambient sentinel (always-on). On a headless box the source is a
// no-op, so both degrade gracefully without new X11 requirements.
src := evidence.NewSource()
go src.Watch(context.Background(), func(w evidence.WindowSnapshot) {
h.RecordWindow(w)
sentinel.OnWindow(w)
})
go sentinel.Run(context.Background())
go openBrowser("http://" + addr)
log.Printf("keeld listening on http://%s", addr)
if err := srv.Router().Run(addr); err != nil {
log.Fatalf("server: %v", err)
}
}
// openBrowser best-effort launches the default browser after a short delay so
// the server is listening first. Failures are logged, not fatal.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond)
var cmd string
var args []string
switch runtime.GOOS {
case "linux":
cmd, args = "xdg-open", []string{url}
case "darwin":
cmd, args = "open", []string{url}
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url}
default:
log.Printf("open browser manually: %s", url)
return
}
if err := exec.Command(cmd, args...).Start(); err != nil {
log.Printf("could not open browser (%v); visit %s", err, url)
}
}
+305
View File
@@ -0,0 +1,305 @@
---
title: Keel — Architecture
name: keel
category: architecture
created: 2026-06-04
updated: 2026-06-04
tags:
- keel
- human-harness
- architecture
- antidrift
- activitywatch
status: authoritative
---
# Keel — Architecture
> **Name & home.** *Keel* is the system: a human-harness that helps Felix hold
> course toward his higher goals. The name answers AntiDrift's metaphor — *drift*
> is what a vessel does with nothing to hold it; the *keel* resists drift and
> keeps you on an even keel (steady mind). **This repo is `keel`** — AntiDrift's
> focus harness becomes Keel's first *mode*. The rename is **complete**: directory,
> git repo, Go module (`keel`), binary (`keeld`), runtime (`~/.keel/`), and env
> (`KEEL_*`) are all `keel`. The old `~/.antidrift/` ledger needs a one-time manual
> move to `~/.keel/` for existing installs.
> Moved from `~/dev/higher/` on 2026-06-04 — that directory was the scaffold where
> the decision to extend AntiDrift was made, and now retires.
---
## 1. What we're building (one sentence)
**A harness** — a framework that pulls Felix's real state out of his existing
tools, hands it to a swappable brain, and turns the reply into actions and two
surfaces. The brain, the storage, and the data sources are all **reused**; the
harness is the only thing we build. The target is one system (Keel — the single
screen), grown by **loosening AntiDrift's controller**, not by starting over.
The inversion holds: this is a *human-harness*. In an agent harness a model is
the observed worker; here Felix is the observed subject and the model is the
operator.
---
## 2. Core decisions (locked 2026-06-04)
1. **The product is the harness.** The brain (`claude` / `codex` / **Hermes**) is
rented, interchangeable compute reached over a CLI or the network — *not* the
point. The value is the framework that feeds information *in* and acts on what
comes *back*. Hermes is real (an agent harness on another machine); it is one
brain option, not a dependency.
2. **Reuse AntiDrift's engine; loosen its controller.** AntiDrift already is this
harness for one plane. Its controller is welded to *commitment = next_action +
success_condition + timebox*. We loosen that into a general **collect → brain →
act** loop where a focus-session is just **one mode**. The harness now hosts a
second, **off-screen** mode; further modes (body, review, capture) are
aspirational. The core architecture is salvageable — we widen scope, we don't
rebuild.
- Worked example (the shipped non-focus mode): from the phone web UI, *"what
worthwhile off-screen thing should I do now?"* → the harness reads today's
Marvin tasks + the `~/owc` goals and life-domain `bug-*.md`, the brain
proposes one off-screen action, the UI asks Felix for feedback, and on
approval the Marvin effector files the task.
3. **Storage = ActivityWatch.** AW is already a general append-only event store
with a REST API, and it is currently the single richest *unused* asset
(~1 yr / ~287k events). Keel gets its **own AW bucket(s)** for memory. **No new
database, no new event store.** AW is therefore both a *sensor* (screen-time
ground truth) and the *store* (Keel's memory).
4. **Two surfaces, both — no fork.**
- **Web UI** — configure, interact, discuss; phone-friendly. The place Felix
*talks to* Keel.
- **WM status bar** — reflect current status (AntiDrift already writes
`~/.keel_status`). The reminder sentence lives here; it is one *status
element*, **not** a deliverable. (No "nice summaries" as a product.)
5. **Life-information root = `~/owc`** (owncloud markdown), formerly called
"clawd". Values, goals, the 22 `resources/bug-*.md`, weekly reviews,
self-management frameworks, capture stream. **Reference, never duplicate.**
6. **Effectors: start gated, trend toward autonomy.** Day one: read + *propose,
Felix confirms in the web UI*. Target: the brain does a lot on its own —
**update Beeminder datapoints, update Marvin to-dos, update `~/owc` notes.**
The gate (propose-and-confirm vs. just-do-it) is a **config surface per
effector**, not a vibe — this is the agency-vs-cage line and it stays Felix's
to set.
7. **Still in force:** don't build a new event store; AntiDrift is the substrate;
reference source-of-truth; local-first; every session ends with visible proof;
the status line stays small.
---
## 3. The harness (the components we build)
```
SOURCES (read) KEEL (the framework = the product) BRAIN (swappable)
───────────── ────────────────────────────────────── ──────────────────
AW :5600 ───────┐ ┌──────────────────────────────────────┐ claude (local CLI)
AntiDrift ──────┤ feed │ ① COLLECTORS → ③ ASSEMBLER → [BRAIN] │ send ──▶ codex (local CLI)
HQ hq.db ───────┤ ───────▶ │ ▲ │ │ Hermes (remote API)
Consume :8000 ──┤ │ │ ▼ │ ◀─ reply
daily.db :2200 ─┤ │ ② MEMORY = AW buckets ④ EFFECTORS │ (just compute;
Beeminder ──────┤ │ (reuse AW; no new DB) │ │ interchangeable)
Marvin ammcp ───┘ └────────────────────────────────┼─────┘
~/owc (frame) ──── grounds the brief ────────────────────────┤
⑤ renders to ──┴──────────┐
▼ ▼
WEB UI WM STATUS BAR
(configure/interact/discuss) (current status)
```
Each component has one job, a defined interface, and can be built/tested alone:
- **① Collectors** — one small read-only adapter per source. Contract:
`read(window) -> Signal[]`. Today's real interfaces: AW `POST :5600/api/0/query/`;
AntiDrift `tail ~/.keel/audit.jsonl` (+ live SSE `:7777/events`); HQ
read-only `hq.db` (+ `:8765`); Consume `GET :8000/api/...`; daily read-only
`daily.db` (+ `:2200`); Beeminder `beeline`; Marvin `ammcp` MCP / `am --json`.
Depends only on the source being up; degrades to a dropped signal if not.
- **② Memory** — AW buckets owned by Keel. Keel writes its own derived events here
(mode_started, proposal_made, action_taken, spiral_flagged, …) so it *remembers
across runs* and can see trends a single tool can't. Rebuildable, queryable via
AW's REST API. Depends on AW only. (See §7.)
- **③ Context assembler** — builds the brief handed to the brain = relevant
collector signals + memory (recent + trend) + the `~/owc` frame (the value/goal
at stake, the relevant `bug-*.md`). Budgets it. Depends on ① ② and `~/owc`.
Privacy boundary lives here: titles that can leak secrets are filtered before
they reach the brain.
- **Brain adapter** — pluggable. Local: shell `claude --print` / `codex exec`
(exactly what AntiDrift's `ai.Backend` already does). Remote: call Hermes over
the network. Interface: `decide(brief) -> {status, proposals[]}`. Keel treats it
as a black box; swapping it changes nothing else.
- **④ Effectors** — one gated adapter per write target. Today's safe writes:
Marvin task create/complete (`am` / `ammcp`, **preserve the `fieldUpdates`
CRDT** on write), capture append (one file), AntiDrift start-session / enforce.
Roadmap writes: Beeminder datapoint (`beeline`), `~/owc` note edits. Each
effector reads its gate from config (propose-and-confirm | auto).
- **⑤ Surfaces** — render Keel's state. Web UI (rich, phone-friendly, the
interaction/approval surface) and the WM status bar (one live line). Both read
the same Keel state; neither owns data.
- **Control loop / scheduler** — drives the cycle: when to collect, when to think,
when to surface, when to act. Cheap triggers (a switch-count spike, a Beeminder
losedate approaching, a manual web-UI request) decide when to spend a brain
call, instead of polling the LLM blindly. This is AntiDrift's state machine,
generalized past the single session.
- **Policy / gates** — per-effector authority + the value frame. Felix's dial
from "read-only" to "act freely". Configured in the web UI.
---
## 4. AntiDrift is the seed, not a peer
Keel is AntiDrift's port set, widened. The parts already exist:
| Keel part | AntiDrift today | Keel (generalized) |
|---|---|---|
| Collectors | `evidence.Source` (X11), `tasks.Provider` (Marvin) | + AW, HQ, Consume, daily, Beeminder, `~/owc` |
| Frame | `knowledge.Source` → one file | `~/owc`: values, goals, 22 `bug-*.md`, frameworks |
| Brain | `ai.Backend` → local `claude`/`codex` | + Hermes (remote); pluggable interface |
| Memory | ephemeral (dies in `state.json`) | **AW buckets** (durable, queryable) |
| Effectors | `enforce.Guard` (window-minimize) | + Marvin, capture, Beeminder, `~/owc` notes |
| Surfaces | web UI `:7777` + `~/.keel_status` | richer web UI (phone) + status bar |
| Control loop | session state machine (locked/planning/active/review) | **modes** (focus is one); a session is one mode |
**Loosening the controller** concretely means: generalize `Commitment` from a
work-session into a *mode invocation* (a mode has its own collectors, frame slice,
brain prompt, and allowed effectors); the focus-session remains the
`next_action/success_condition/timebox` mode; the off-screen mode reads the
life-domain bugs + goals and proposes one off-screen action; a future "capture"
mode would just route a thought. The runtime state machine becomes the loop in §3.
---
## 5. One loop, concrete
Off-screen mode, from the phone, end to end — **this is what ships today**:
1. **Collect** — today's Marvin tasks (`am --json`) + the `~/owc/goals-2026.md`
goals + the life-domain `~/owc/resources/bug-*.md` notes — plus recent
proposals and their outcomes recalled from the `keel.events` AW bucket —
assembled into a single budgeted brief.
2. **Brain** — the chosen brain (`ai.Proposer`) returns one worthwhile off-screen
action plus a short rationale.
3. **Surface** — the phone-first web UI renders a proposal card and asks Felix to
confirm or dismiss.
4. **Act** — on confirm, the Marvin `Create` effector files the action as a task
(CRDT-preserving `am add`); the mode is one-shot and the harness returns idle.
No new store touched. The brain was rented for one call. Everything else was Keel.
**Shipped:** memory. Off-screen now writes its decisions to the `keel.events` AW
bucket (`proposal_made` / `action_taken` / `proposal_dismissed`, correlated by a
random `proposal_id`) and reads its recent history back into the brief — so a
proposal can avoid repeating itself and follow up on what was dismissed or left
undone. Storage sits behind a thin `memory.Store` port (AW-backed, or a nop when
AW is down), the seam every later mode reuses; see §7. The `keel.state` bucket and
the trend/spiral detection that reads this data remain future increments.
---
## 6. Sources & targets (the real ecosystem)
| Tool | Read | Write | Role | Source of truth |
|---|---|---|---|---|
| ActivityWatch | `POST :5600/api/0/query/`, sqlite | new buckets via REST | sensor **+ store** | `peewee-sqlite.v2.db` |
| AntiDrift | `audit.jsonl`, SSE `:7777` | `POST :7777` cmds, enforce | focus mode + enforcement | `~/.keel/` |
| HQ | `hq.db` r/o, `:8765` | `dashboard.write_artifact` | agent-execution evidence | `hq.db` (~23k events) |
| Consume | `GET :8000/api` | (later) | intake/reflection signal | item frontmatter |
| daily | `daily.db` r/o, `:2200` | — | mood/habit checkout | `daily.db` |
| Beeminder | `beeline` | `beeline` datapoints | committed-evidence rollups | Beeminder API |
| Marvin | `ammcp` / `am --json` | `am` / `ammcp` (keep CRDT) | intent / to-dos / bugs | CouchDB |
| `~/owc` | markdown globs, `.zk` | note edits (later) | values/goals/bugs frame | `~/owc` files |
| Brain | — | — | rented compute | claude/codex/Hermes |
Known frictions Keel inherits (from the survey): beesync has **no scheduler**
(Beeminder data lags; `[daily]` bridge commented out); the life-bug concept is
**forked** (22 rich `~/owc` markdown bugs vs. 5 thin Marvin tasks); **capture is
broken** (`blurt` → clipboard, dead `inbox.md`, two rival `stream.md`); AW's year
of data is read by nothing today.
---
## 7. Memory in ActivityWatch (the storage design)
AW is a typed, append-only, timestamped event store with buckets + a REST/query
API — i.e. it is already the event store the §0 correction said not to rebuild.
Keel gets dedicated buckets (e.g. `keel.state`, `keel.events`) and writes
**derived** events: `mode_started`, `proposal_made`, `feedback_given`,
`action_taken`, `spiral_flagged`, `coach_line`. Properties:
- **Reference, don't duplicate** — these hold pointers (a Marvin id, a `bug-*.md`
path, an audit `session_id`), not copies of other tools' truth.
- **Rebuildable** — derivable from the underlying ledgers; AW is a cache/index of
Keel's own decisions plus a join surface over the sources.
- **Queryable over time** — this is what unlocks trend/spiral detection ("caffeine
bug + mood dip + rising switches, same shape as three weeks ago") that no single
tool can do, because each tool only answers "what now".
Open sub-decision: whether work-laptop AW data is peer-synced in (cross-device
behavioral story) or left out initially.
---
## 8. Open questions (not yet decided)
- **Effector gate policy** — exact default authority per write target on day one,
and how Felix raises/lowers it from the web UI.
- **Capture unification** — which single file is canonical (resolve
`~/owc/stream.md` vs `~/wrk/pkb/stream.md` vs dead inboxes) and rewiring `blurt`
to append there.
- **Cross-device AW** — sync work-laptop AW or not (see §7).
- **Secret hygiene** — an increasingly autonomous operator runs as the full Unix
user; plaintext keys exist on disk; the privacy boundary in ③ must be real.
### Resolved
- **Code rename** — complete as of Phase 0 of the controller refactor. Go module
is `keel`, binary is `keeld`, runtime is `~/.keel/`, env prefix is `KEEL_*`.
Existing installs with a live ledger under `~/.antidrift/` need a one-time
manual move to `~/.keel/`.
- **Controller refactor shape** — resolved: grown in place, not branched. The
focus-only `session.Controller` is now a generic `harness.Harness` that hosts
one `mode.Mode` at a time (the `collect → brain → act` loop of §3); focus is the
first mode and off-screen the second. Per-mode persistence lives under
`~/.keel/modes/<kind>/`.
---
## 9. What this supersedes (from `~/dev/higher/`)
- The whole `~/dev/higher/` planning directory: it was the scaffold for deciding
to extend AntiDrift. This doc is now the authoritative home; that dir retires to
a pointer.
- "clawd" → **`~/owc`** everywhere.
- The plan's premise that the operator "already knows Felix via `~/.hermes`" and
the survey finding "Hermes doesn't exist" are both resolved: **Hermes is real
but remote, and the brain is pluggable** — grounding comes from the `~/owc`
frame regardless of which brain runs.
- The plan's §6 recommendation ("a thin *reader* layer is the real architecture")
is **superseded**: the reader is merely the **collectors layer** of Keel. The
target is the full system (one screen, two surfaces, durable memory,
gated→autonomous effectors), grown from AntiDrift.
- "Don't build an event store" is **sharpened**: storage = **ActivityWatch
buckets**.
---
## 10. Smallest real slice (a vertical, not a summary)
To stay honest to "ship visible proof" without boiling the ocean, the first slice
is **off-screen mode, end to end** (§5) on the existing AntiDrift web surface —
collectors (`~/owc` goals + life-domain bugs + Marvin), the brain (`ai.Proposer`),
the phone-first web UI proposal card, and the Marvin `Create` effector behind a
confirm gate. It exercises every Keel component once except memory, on the
smallest mode, and produces a real filed task — interaction and action, not a
coaching sentence. **This slice now ships;** durable AW memory (§7) is the next
increment that closes the loop.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,548 @@
# Windows 11 Support Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make AntiDrift function end-to-end on Windows 11 by adding `//go:build windows` adapters behind the existing `evidence.Source` and `enforce.Guard` ports — active-window sensing (polling) and window-minimize enforcement — with no consumer code changes.
**Architecture:** Two new windows-tagged adapters call a small shared Win32 binding (`internal/winapi`). The syscall-free logic (process-path → class normalization, emit-on-change tracking) is extracted into untagged files so it is unit-tested on Linux via TDD. The syscall-bound code is verified by cross-compilation only (no Windows machine is available). The current `//go:build !linux` no-op fallbacks are narrowed to `//go:build !linux && !windows` so Windows links the real adapters while macOS/other stay no-ops.
**Tech Stack:** Go 1.26, `golang.org/x/sys/windows` (already an indirect dep; promoted to direct), pure Go (no cgo). Win32 calls: `GetForegroundWindow`, `GetWindowThreadProcessId`, `OpenProcess`/`QueryFullProcessImageName`/`CloseHandle` (all typed in `x/sys/windows`), plus `GetWindowTextW` and `ShowWindow` via `windows.NewLazySystemDLL("user32.dll")`.
**Reference spec:** `docs/superpowers/specs/2026-06-02-windows-support-design.md`
---
## File structure
- Create `internal/winapi/class.go` — untagged — `ClassFromImagePath` (pure: image path → class string). Builds on all platforms; this is the only file that makes package `winapi` compile on Linux.
- Create `internal/winapi/class_test.go` — untagged — table test for `ClassFromImagePath` (runs on Linux).
- Create `internal/winapi/winapi.go``//go:build windows``ForegroundWindow()` and `MinimizeForeground()` over the Win32 calls.
- Create `internal/evidence/foreground_tracker.go` — untagged — `foregroundTracker.changed` (pure emit-on-change predicate). Runs on Linux.
- Create `internal/evidence/foreground_tracker_test.go` — untagged — tests for the tracker.
- Create `internal/evidence/windows.go``//go:build windows` — polling `Source` (`NewSource`, `windowsSource.Watch`).
- Create `internal/enforce/windows.go``//go:build windows``Guard` (`NewGuard`, `windowsGuard.MinimizeActive`).
- Modify `internal/evidence/source_other.go:1` — build tag `//go:build !linux``//go:build !linux && !windows`.
- Modify `internal/enforce/guard_other.go:1` — build tag `//go:build !linux``//go:build !linux && !windows`.
- Modify `go.mod``go mod tidy` promotes `golang.org/x/sys` from indirect to direct.
No changes to `session`, `web`, `domain`, or `cmd` — they already consume the ports and degrade on `Health`.
---
## Task 1: Pure class-name normalization (TDD on Linux)
`ClassFromImagePath` turns a Windows process image path into the on-task class
identity: base file name, trailing `.exe` removed (case-insensitive),
lowercased. It must be correct when the test runs on Linux, so it parses
separators itself rather than using OS-dependent `path/filepath`.
**Files:**
- Create: `internal/winapi/class.go`
- Test: `internal/winapi/class_test.go`
- [ ] **Step 1: Write the failing test**
Create `internal/winapi/class_test.go`:
```go
package winapi
import "testing"
func TestClassFromImagePath(t *testing.T) {
cases := []struct{ in, want string }{
{`C:\Program Files\Microsoft VS Code\Code.exe`, "code"},
{`C:\Windows\explorer.exe`, "explorer"},
{`chrome.exe`, "chrome"},
{`C:\x\FOO.EXE`, "foo"},
{`C:\x\My.App.exe`, "my.app"},
{`C:/forward/slash/Code.exe`, "code"},
{`firefox`, "firefox"},
{``, ""},
}
for _, c := range cases {
if got := ClassFromImagePath(c.in); got != c.want {
t.Errorf("ClassFromImagePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/winapi/`
Expected: FAIL — build error, `undefined: ClassFromImagePath`.
- [ ] **Step 3: Write minimal implementation**
Create `internal/winapi/class.go`:
```go
// Package winapi is the Windows Win32 binding layer for AntiDrift's OS ports.
// The syscall-bound code lives in windows-tagged files; this untagged file
// holds the pure path logic so it builds and is tested on every platform.
package winapi
import "strings"
// ClassFromImagePath derives the on-task class identity from a process image
// path: the base file name with any trailing ".exe" removed (case-insensitive),
// lowercased. It is the Windows analog of an X11 WM_CLASS. It parses both `\`
// and `/` separators so it is correct regardless of the host OS running the
// test. Returns "" for an empty path.
func ClassFromImagePath(p string) string {
if i := strings.LastIndexAny(p, `\/`); i >= 0 {
p = p[i+1:]
}
if len(p) >= 4 && strings.EqualFold(p[len(p)-4:], ".exe") {
p = p[:len(p)-4]
}
return strings.ToLower(p)
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/winapi/`
Expected: PASS (`ok antidrift/internal/winapi`).
- [ ] **Step 5: Commit**
```bash
git add internal/winapi/class.go internal/winapi/class_test.go
git commit -m "Add pure process-path to class normalization for Windows"
```
---
## Task 2: Emit-on-change tracker (TDD on Linux)
The X11 source is event-driven; the Windows source polls. `foregroundTracker`
reproduces "emit only on change" for the poll loop: it remembers the last
observation and reports whether the new one differs. It tracks availability too,
so a steady "no foreground window" run does not re-emit every tick.
**Files:**
- Create: `internal/evidence/foreground_tracker.go`
- Test: `internal/evidence/foreground_tracker_test.go`
- [ ] **Step 1: Write the failing test**
Create `internal/evidence/foreground_tracker_test.go`:
```go
package evidence
import "testing"
func TestForegroundTrackerChanged(t *testing.T) {
var tr foregroundTracker
if !tr.changed(true, 100, "A") {
t.Fatal("first observation should always report changed")
}
if tr.changed(true, 100, "A") {
t.Error("identical observation should not report changed")
}
if !tr.changed(true, 100, "B") {
t.Error("title change on same hwnd should report changed")
}
if !tr.changed(true, 200, "B") {
t.Error("hwnd change should report changed")
}
if !tr.changed(false, 0, "") {
t.Error("transition to unavailable should report changed")
}
if tr.changed(false, 0, "") {
t.Error("repeated unavailable should not report changed")
}
if !tr.changed(true, 200, "B") {
t.Error("transition back to available should report changed")
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `go test ./internal/evidence/ -run TestForegroundTracker`
Expected: FAIL — build error, `undefined: foregroundTracker`.
- [ ] **Step 3: Write minimal implementation**
Create `internal/evidence/foreground_tracker.go`:
```go
package evidence
// foregroundTracker remembers the last observed foreground window so the
// Windows polling Source emits only on change. hwnd is held as uintptr so this
// file stays platform-neutral (it must build and test on Linux).
type foregroundTracker struct {
primed bool
available bool
hwnd uintptr
title string
}
// changed reports whether (available, hwnd, title) differs from the last
// observation and records the new values. The first call always returns true.
func (t *foregroundTracker) changed(available bool, hwnd uintptr, title string) bool {
if t.primed && available == t.available && hwnd == t.hwnd && title == t.title {
return false
}
t.primed, t.available, t.hwnd, t.title = true, available, hwnd, title
return true
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `go test ./internal/evidence/ -run TestForegroundTracker`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add internal/evidence/foreground_tracker.go internal/evidence/foreground_tracker_test.go
git commit -m "Add emit-on-change tracker for the Windows polling sensor"
```
---
## Task 3: Win32 binding layer (cross-compile verified)
The syscall surface. Cannot be unit-tested without Windows; verified by
cross-compilation. All referenced `x/sys/windows` symbols were confirmed present
via `GOOS=windows go doc` while writing the spec.
**Files:**
- Create: `internal/winapi/winapi.go`
- Modify: `go.mod` (via `go mod tidy`)
- [ ] **Step 1: Write the implementation**
Create `internal/winapi/winapi.go`:
```go
//go:build windows
package winapi
import (
"unsafe"
"golang.org/x/sys/windows"
)
var (
user32 = windows.NewLazySystemDLL("user32.dll")
procGetWindowTextW = user32.NewProc("GetWindowTextW")
procShowWindow = user32.NewProc("ShowWindow")
)
// ForegroundWindow returns the current foreground window's handle (as a uintptr
// so platform-neutral callers need not import windows), its title, and its
// on-task class (process exe base name; see ClassFromImagePath). ok is false
// when there is no foreground window (e.g. secure desktop / lock screen).
func ForegroundWindow() (hwnd uintptr, title, class string, ok bool) {
h := windows.GetForegroundWindow()
if h == 0 {
return 0, "", "", false
}
return uintptr(h), windowTitle(h), windowClass(h), true
}
func windowTitle(h windows.HWND) string {
const max = 512
buf := make([]uint16, max)
n, _, _ := procGetWindowTextW.Call(
uintptr(h),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(max),
)
return windows.UTF16ToString(buf[:n])
}
func windowClass(h windows.HWND) string {
var pid uint32
if _, err := windows.GetWindowThreadProcessId(h, &pid); err != nil || pid == 0 {
return ""
}
proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid)
if err != nil {
return ""
}
defer windows.CloseHandle(proc)
buf := make([]uint16, windows.MAX_PATH)
size := uint32(len(buf))
if err := windows.QueryFullProcessImageName(proc, 0, &buf[0], &size); err != nil {
return ""
}
return ClassFromImagePath(windows.UTF16ToString(buf[:size]))
}
// MinimizeForeground minimizes the current foreground window. It returns nil
// when nothing is focused. ShowWindow's BOOL return reports prior visibility,
// not success, so there is nothing to error-check; minimize is best-effort by
// the Guard contract.
func MinimizeForeground() error {
h := windows.GetForegroundWindow()
if h == 0 {
return nil
}
procShowWindow.Call(uintptr(h), uintptr(windows.SW_MINIMIZE))
return nil
}
```
- [ ] **Step 2: Promote the dependency and verify the package cross-compiles**
Run:
```bash
go mod tidy
GOOS=windows GOARCH=amd64 go build ./internal/winapi/
```
Expected: both succeed with no output. `go.mod` now lists `golang.org/x/sys` in the direct (non-`// indirect`) require block.
- [ ] **Step 3: Verify the package still builds on Linux (pure file only)**
Run: `go test ./internal/winapi/`
Expected: PASS — on Linux the package is just `class.go` + its test; `winapi.go` is excluded by its build tag.
- [ ] **Step 4: Commit**
```bash
git add internal/winapi/winapi.go go.mod go.sum
git commit -m "Add Win32 binding for foreground window and minimize"
```
---
## Task 4: Windows evidence.Source adapter (cross-compile verified)
The polling sensor. Emits one snapshot immediately, then on every 750ms tick
emits only when the foreground window or title changed (via `foregroundTracker`
from Task 2 and `winapi.ForegroundWindow` from Task 3). Also narrows the
non-Linux fallback so it no longer claims Windows.
**Files:**
- Create: `internal/evidence/windows.go`
- Modify: `internal/evidence/source_other.go:1`
- [ ] **Step 1: Narrow the fallback build tag**
In `internal/evidence/source_other.go`, change the first line:
```go
//go:build !linux && !windows
```
(from `//go:build !linux`). Leave the rest of the file unchanged.
- [ ] **Step 2: Write the Windows source**
Create `internal/evidence/windows.go`:
```go
//go:build windows
package evidence
import (
"context"
"time"
"antidrift/internal/winapi"
)
// pollInterval is how often the Windows sensor samples the foreground window.
// ~1s latency on a window switch is immaterial for a focus tracker, and polling
// avoids the message-loop/callback machinery a SetWinEventHook source needs.
const pollInterval = 750 * time.Millisecond
// NewSource returns the Windows active-window sensor (polling).
func NewSource() Source { return windowsSource{} }
type windowsSource struct{}
// Watch emits the current window immediately, then samples every pollInterval,
// emitting only when the foreground window or its title changes. A read with no
// foreground window yields an Unavailable snapshot (once, until it recovers). It
// runs until ctx is cancelled. It never panics the daemon.
func (windowsSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
var tr foregroundTracker
poll := func() {
hwnd, title, class, ok := winapi.ForegroundWindow()
if !tr.changed(ok, hwnd, title) {
return
}
if !ok {
onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no foreground window"}})
return
}
onChange(WindowSnapshot{Title: title, Class: class, Health: EvidenceHealth{Available: true}})
}
poll() // immediate current window
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
poll()
}
}
}
```
- [ ] **Step 3: Verify Windows build and Linux tests**
Run:
```bash
GOOS=windows GOARCH=amd64 go build ./internal/evidence/
go test ./internal/evidence/
```
Expected: the Windows build succeeds with no output; the Linux test run passes (tracker test included, X11/other files unaffected).
- [ ] **Step 4: Verify the fallback world is intact (macOS still no-op)**
Run: `GOOS=darwin GOARCH=amd64 go build ./internal/evidence/`
Expected: succeeds — `source_other.go` (`!linux && !windows`) still provides `NewSource` on darwin.
- [ ] **Step 5: Commit**
```bash
git add internal/evidence/windows.go internal/evidence/source_other.go
git commit -m "Add Windows polling active-window sensor"
```
---
## Task 5: Windows enforce.Guard adapter (cross-compile verified)
The minimize guard. Thin wrapper over `winapi.MinimizeForeground`. Narrows the
non-Linux fallback the same way as Task 4.
**Files:**
- Create: `internal/enforce/windows.go`
- Modify: `internal/enforce/guard_other.go:1`
- [ ] **Step 1: Narrow the fallback build tag**
In `internal/enforce/guard_other.go`, change the first line:
```go
//go:build !linux && !windows
```
(from `//go:build !linux`). Leave the rest of the file unchanged.
- [ ] **Step 2: Write the Windows guard**
Create `internal/enforce/windows.go`:
```go
//go:build windows
package enforce
import (
"context"
"antidrift/internal/winapi"
)
// NewGuard returns the Windows window-minimize guard.
func NewGuard() Guard { return windowsGuard{} }
type windowsGuard struct{}
// MinimizeActive minimizes the current foreground window. It is best-effort:
// with nothing focused it does nothing and returns nil. Stateless and per-call,
// mirroring the short-lived X11 connection model.
func (windowsGuard) MinimizeActive(context.Context) error {
return winapi.MinimizeForeground()
}
```
- [ ] **Step 3: Verify Windows build and the fallback world**
Run:
```bash
GOOS=windows GOARCH=amd64 go build ./internal/enforce/
GOOS=darwin GOARCH=amd64 go build ./internal/enforce/
go build ./internal/enforce/
```
Expected: all three succeed with no output (Windows uses `windows.go`; darwin and Linux-host build paths still resolve `NewGuard`).
- [ ] **Step 4: Commit**
```bash
git add internal/enforce/windows.go internal/enforce/guard_other.go
git commit -m "Add Windows window-minimize guard"
```
---
## Task 6: Full cross-compilation gate and regression check
The primary automated guarantee for the syscall-bound code: the whole module
cross-compiles for Windows, the fallback world is intact for macOS, and the
Linux host build and full test suite are still green.
**Files:** none (verification only).
- [ ] **Step 1: Whole-module Windows cross-compile**
Run: `GOOS=windows GOARCH=amd64 go build ./...`
Expected: succeeds with no output. (This is the headline check: `cmd/antidriftd`, `evidence`, `enforce`, `winapi`, and every consumer link for Windows.)
- [ ] **Step 2: Whole-module macOS cross-compile (fallback intact)**
Run: `GOOS=darwin GOARCH=amd64 go build ./...`
Expected: succeeds with no output — proves the `!linux && !windows` tag edits did not orphan `NewSource`/`NewGuard` on other platforms.
- [ ] **Step 3: Linux host build and full test suite**
Run:
```bash
go build ./...
go test ./...
```
Expected: build succeeds; all tests pass, including the new `winapi` and `evidence` pure-logic tests, with the existing Linux X11 integration tests unaffected.
- [ ] **Step 4: Confirm the Windows binary actually links (optional sanity)**
Run: `GOOS=windows GOARCH=amd64 go build -o /tmp/antidriftd.exe ./cmd/antidriftd && ls -l /tmp/antidriftd.exe`
Expected: a `.exe` is produced.
- [ ] **Step 5: Commit (if `go mod tidy` left any tidy changes)**
```bash
git add -A
git commit -m "Verify Windows cross-compile and Linux regression for Windows support" --allow-empty
```
---
## Deferred manual verification (when a Windows 11 machine is available)
Not part of this plan's automated gates — recorded for whoever runs it on real
hardware later (from the spec):
- Run `antidriftd`; confirm the browser opens and the live view shows the
current window title and class.
- Switch windows and change a browser tab; confirm snapshots update and health
reads available.
- Start a commitment with "Enforce focus" armed; focus an off-task window;
confirm the drift judge fires and the window minimizes.
---
## Self-review notes
- **Spec coverage:** both ports (Task 4 sensor, Task 5 guard); `winapi` binding (Task 3); build-tag narrowing (Tasks 45); `go mod tidy` promotion (Task 3); pure-logic TDD for class + change-detection (Tasks 12); cross-compile + darwin-fallback + Linux-regression gates (Task 6); deferred manual steps recorded. No fabricated syscall mocks. The `Class` = exe-base-name decision is implemented in Task 1.
- **Type consistency:** `ClassFromImagePath` (Tasks 1, 3); `foregroundTracker.changed(available, hwnd, title)` with `hwnd uintptr` (Tasks 2, 4); `winapi.ForegroundWindow() (uintptr, string, string, bool)` and `winapi.MinimizeForeground() error` (Tasks 3, 4, 5). Module path `antidrift` confirmed against `go.mod`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,674 +0,0 @@
# Commitment OS Design
Date: 2026-05-25
## Purpose
AntiDrift should evolve from a session timer and window-rating tool into a commitment operating system: a personal agency layer that makes computer use explicit, constrained, observable, and resistant to casual tampering.
The system addresses distraction as unconscious context switching, not only as access to bad websites. The guiding rule is:
```text
No unchosen transitions.
```
The computer should not silently enter unconstrained use. It should be in one of these explicit modes:
- active commitment;
- deliberate transition;
- constrained planning;
- review;
- locked state;
- delayed administrative override.
Tamper resistance is a friction gradient against the user's impulsive self. It is not a perfect security boundary. The goal is to defeat impulsive and casual bypasses, delay deliberate bypasses, and make drift visible.
## Architecture
The system has five layers that share a common commitment model.
1. Orientation: projects, responsibilities, values, tasks, next actions, energy constraints.
2. Commitment: a concrete next action, success condition, allowed context, timebox, and transition policy.
3. Live work mode: desktop, browser, terminal, files, ActivityWatch, window tracking, and future evidence sources.
4. Containment: a privileged guardian that enforces the selected commitment across the live environment.
5. Reflection: session outcomes, drift events, transition reasons, rule adjustments, and planner updates.
The commitment model sits between planning and enforcement. Planner integration can later create commitments from tasks. Enforcement operates on policy snapshots derived from commitments without understanding planner internals.
## Threat Model And Scope
The adversary is the same person in a less reflective state. The system should be honest about what local software can and cannot enforce.
| Tier | Bypass effort | Examples | Realistic goal |
| --- | --- | --- | --- |
| 0: impulse | less than 1 second | Alt-tab, open browser, type URL, leave desk without choosing transition | Defeat with blocking, prompts, overlays, and explicit transitions |
| 1: casual | seconds | Kill process, stop user service, close app, quit ActivityWatch | Defeat with watchdogs, privilege separation, and restart behavior |
| 2: determined | minutes | Use sudo, edit config, disable systemd units, reboot, manipulate clock | Delay and audit through delayed admin path and break-glass flow |
| 3: premeditated | unconstrained | Boot live USB, GRUB recovery, second device, phone, hardware removal | Out of scope for local software |
In-scope bypasses:
- opening disallowed domains or apps;
- quitting AntiDrift or ActivityWatch;
- stopping user-level services;
- entering unconstrained planning;
- changing rules without delayed admin access;
- crash-looping the user agent;
- malformed policy snapshots;
- clock manipulation attempts visible to the running system;
- unmonitored desk absence after future presence sensing exists.
Out-of-scope or explicitly limited:
- second phones, tablets, or other computers;
- booting from external media or recovery shell;
- physical tampering;
- fully premeditated bypasses with unlimited time;
- coercive monitoring of another person.
Known edge cases to address or scope per implementation stage:
- switching TTYs or desktop sessions;
- Wayland compositor limitations;
- VMs and nested display servers;
- SSH into the same machine;
- browser profiles and extensions;
- Flatpak/Snap application identity;
- multi-user machines;
- multiple monitors, virtual desktops, and tiling window managers;
- direct modification of ActivityWatch data.
Stage 1 does not need to solve every edge case, but it must not pretend they are solved.
## Linux Target Environment
The first target is the user's Linux workstation.
Stage 1 should support the current X11-style active-window model already used by AntiDrift through `xdotool`, plus ActivityWatch evidence when available. If the session is Wayland and active window metadata cannot be collected reliably, the system must surface that as a degraded evidence mode rather than silently reporting false confidence.
Stage 2 should choose concrete Linux enforcement mechanisms per target environment:
- systemd system service for the privileged guardian;
- systemd user service or normal user process for the agent;
- nftables or DNS-level controls for domain blocking;
- window-class interruption where active window details are available;
- ActivityWatch watchdog where ActivityWatch is installed and expected.
Wayland support should be compositor-specific. GNOME Wayland, KDE Wayland, Sway, and Hyprland may need separate adapters. Unknown or unsupported environments should fail into a conservative mode: planning/review may work, but enforcement claims are limited.
## Domain Model
The first-pass `Commitment` concept should be split into three related records.
### Commitment
User-facing intent and planning artifact.
```text
Commitment
id
created_at
source: manual | planner | recurring | recovery | template
project_id: optional initially, planner-backed later
template_id: optional
next_action: concrete executable action
success_condition: what counts as done or meaningfully advanced
timebox
transition_policy_id
state: draft | active | paused | completed | abandoned | violated
```
Only one commitment may be active at a time. Task switches go through `Transition` and either resume the current commitment, abandon it, or create/select a new one.
### PolicySnapshot
Versioned enforcement contract consumed by the guardian.
```text
PolicySnapshot
id
commitment_id
schema_version
created_at
runtime_state: locked | planning | active | transition | review | admin_override
enforcement_level: observe | warn | block | locked
allowed_context
required_monitors
violation_actions
expires_at
generated_by_agent_version
```
The guardian consumes `PolicySnapshot`, not planner internals.
### SessionRecord And EventLog
Append-only history of what happened.
```text
SessionRecord
id
commitment_id
started_at
ended_at
outcome: completed | partial | abandoned | violated
review_rating
review_notes
EventLog
sequence
timestamp
event_type
commitment_id
runtime_state
payload
previous_hash
hash
```
The event log is the source for review, audit, and later planner writeback.
## Allowed Context Semantics
Allowed context should be explicit and conservative. Stage 1 may only observe or warn, but the matching rules should be defined early so Stage 2 can enforce the same contract.
```yaml
allowed_context:
window_classes:
match: exact_lowercase
values:
- code
- alacritty
window_titles:
match: substring
values:
- antidrift
domains:
match: exact_or_subdomain
values:
- github.com
- docs.python.org
repos:
match: canonical_path_prefix
values:
- ~/dev/antidrift
commands:
match: executable_basename
values:
- cargo
- git
- rg
```
Rules:
- Domains include subdomains unless explicitly marked exact-only.
- Full URLs should not be stored by default; store domain and coarse path only when needed.
- Window classes are preferred over titles because titles can contain private data and change frequently.
- Unknown windows are handled by `enforcement_level`: record in `observe`, prompt in `warn`, interrupt in `block`, and force `Locked` on repeated or severe violations in `locked`.
- Terminals are hard to classify. Stage 1 may treat terminal windows as allowed based on window class and record command evidence later only when a shell integration exists.
- Child process handling is deferred until command/process enforcement is designed. Stage 2 should not claim process-level policy without cgroups, namespaces, AppArmor, seccomp, or equivalent mechanisms.
- Commitments may use per-resource actions. Example: block social domains, warn on unfamiliar apps, observe unknown terminal commands.
Commitment templates and project defaults should reduce manual allowlist construction:
- `deep coding`;
- `writing`;
- `admin`;
- `research`;
- `rest`;
- project-specific default repos, apps, and documentation domains.
Time-limited context expansion should be allowed when work legitimately discovers a new need. Expansion must be explicit, audited, and bounded, such as "allow `docs.rs` for 20 minutes for this commitment."
## Runtime State Machine
Runtime state controls what the machine is allowed to do now.
```text
Locked
No valid active commitment. Distracting/default computer use is blocked.
Planning
Constrained UI for inspecting tasks/projects and creating or choosing a commitment.
Active
A commitment is running. Allowed context is available; disallowed context is blocked or interrupted.
Transition
Intentional state change: bodily break, reset, task switch, or session end.
Requires reason, expected duration, and return target.
Review
Session ended. The user rates relevance, marks outcome, and records drift/avoidance patterns.
Admin Override
Privileged change path. Requires delayed admin access and leaves audit evidence.
```
Legal transitions:
| From | To | Guard | Side effects |
| --- | --- | --- | --- |
| Locked | Planning | Planning UI requested | Apply planning policy, start planning timer |
| Planning | Active | Valid commitment and policy snapshot accepted by guardian | Create session record, apply active policy |
| Planning | Locked | Cancel, timeout, invalid policy, or idle | Apply locked policy |
| Active | Transition | Reason, expected duration, and return target provided | Record transition start, apply transition policy |
| Active | Review | Commitment completed, abandoned, or timebox expired | Stop active policy, collect evidence summary |
| Active | Locked | Severe violation, policy failure, or monitor failure | Record violation, apply locked policy |
| Transition | Active | Return before timeout to same commitment | Record return, reapply active policy |
| Transition | Planning | Task switch requested | Mark current commitment paused/abandoned as chosen, apply planning policy |
| Transition | Review | End session requested | Collect review data |
| Transition | Locked | Timeout exceeded or transition policy failure | Record violation, apply locked policy |
| Review | Planning | Continue working | Store review, apply planning policy |
| Review | Locked | End work period | Store review, apply locked policy |
| Any | Admin Override | Delayed admin path completed | Record override, apply requested privileged change |
| Admin Override | Previous/new state | Override ends | Record result, apply selected policy |
Planning must never become unconstrained browsing. It has its own policy:
- only the planning UI, local task data, and approved references are available;
- planning is time-limited;
- evidence collection remains active;
- opening arbitrary browser tabs from Planning is a violation unless explicitly allowed.
Suspend/hibernate and reboot should resume into `Locked` unless a valid policy can be restored and verified. A violated commitment may be resumed only through `Review` or `Planning`; resumption should create a visible event.
## Commitment Lifecycle
Commitment state is distinct from runtime state.
| From | To | Guard | Notes |
| --- | --- | --- | --- |
| draft | active | Runtime enters `Active` with accepted policy | Only one active commitment at a time |
| active | paused | Runtime enters `Transition` with return target | Paused does not imply unconstrained use |
| paused | active | User returns before transition timeout | Same commitment resumes |
| active | completed | Success condition met or user completes in review | Requires review |
| active | abandoned | User chooses task switch/end without completion | Requires reason |
| active | violated | Severe or unresolved violation | May force runtime `Locked` |
| paused | abandoned | Transition becomes task switch/end | Requires reason |
| violated | active | Explicit recovery flow | Audited; not automatic |
| violated | abandoned | Review confirms abandonment | Stored in history |
## Guardian IPC Contract
The user agent and guardian communicate through a narrow local API.
Recommended transport: root-owned Unix domain socket with a small JSON-lines protocol. D-Bus can be reconsidered later if desktop integration requires it, but the first design should avoid unnecessary framework surface.
Authentication and integrity:
- The socket path is owned by root and writable only by the expected user/group.
- The guardian checks peer credentials with `SO_PEERCRED`.
- Messages include `schema_version`, monotonic sequence, and request id.
- The guardian rejects malformed, unknown-version, stale, or unauthenticated messages.
- Policy changes are logged before and after application.
Minimal API:
```text
ApplyPolicy(PolicySnapshot) -> Result
ReportEvent(Event) -> Ack
GetStatus() -> SystemState
RequestOverride(OverrideRequest) -> OverrideResult
```
Failure behavior:
- If the agent disconnects while `Active`, the guardian keeps the last valid policy for a short grace period, then moves to `Locked`.
- If the agent sends malformed policy, the guardian rejects it and keeps the previous valid policy; repeated malformed policy escalates to `Locked`.
- If IPC is unavailable, the agent should show degraded state and the system should not claim enforcement.
- If guardian and agent schema versions are incompatible, the system should enter `Locked` with an actionable error and break-glass instructions.
## Enforcement Mechanism Matrix
Stage 2 enforcement should be explicit about feasibility.
| Resource | Stage 1 | Stage 2 candidate | Notes |
| --- | --- | --- | --- |
| Domain | observe from ActivityWatch/browser data if available | nftables or DNS-level blocking | Prefer domains over full URLs for privacy |
| App/window | active-window tracking, minimize or overlay | window-class interruption, minimize, SIGSTOP only where safe | Depends on X11/Wayland adapter |
| Agent health | local process check | systemd restart and guardian watchdog | Casual process killing should fail |
| ActivityWatch health | observe availability | guardian restart and escalation after repeated failure | Direct DB tampering only audited if detectable |
| Config changes | local file checks | root-owned config, delayed admin required | Config writes must be audited |
| Commands/processes | mostly out of scope | future shell integration, cgroups, AppArmor, seccomp, namespaces | Do not overclaim early |
| Files/repos | local path metadata | future filesystem policy only if needed | Path allowlists are mostly planning/evidence initially |
| Presence | out of scope | future opt-in sensor events | Requires separate ethics/design review |
Stage 1 should include Tier 0 friction even without privileged blocking:
- full-screen or high-priority overlay on violation where feasible;
- dismissal requires entering a reason;
- explicit transition prompts;
- local event logging tied to the active commitment.
## Delayed Admin And Break-Glass Recovery
The delayed admin mechanism is load-bearing and must be concrete before Stage 2 hardening.
Initial assumption:
- daily work happens in a non-admin user account;
- admin access exists in a separate admin account or equivalent path;
- admin access already has a delay, currently about 10 minutes, and can be lengthened;
- privileged enforcement changes require that delayed path.
Actions requiring delayed admin:
- disabling guardian service;
- changing locked policy files;
- changing blocklists or allowlists outside explicit time-limited context expansion;
- uninstalling enforcement;
- disabling watchdog behavior;
- emergency break-glass.
Override requests should be logged at request time and completion time:
```text
OverrideRequest
id
requested_at
requester_user
reason
requested_action
earliest_allowed_at
completed_at
result
```
Pre-scheduled instant overrides weaken the system. The guardian should reject override tokens or sentinel files created before the current request window unless they are part of the audited delayed-admin protocol.
Break-glass recovery must be independent of the normal agent UI. Example: a privileged command or root-owned sentinel file disables enforcement for 10 minutes after delayed admin access. It must:
- be simple enough to trust under failure;
- be audited;
- have automatic expiry;
- restore normal policy after expiry unless explicitly uninstalled;
- not depend on the user agent being healthy.
## Event Log, Tamper Evidence, And Retention
Events should be append-only and bounded.
Recommended initial format: JSON lines with hash chaining.
```text
event_hash = hash(schema_version, sequence, timestamp, event_type, payload, previous_hash)
```
The log should include:
- policy applications;
- state transitions;
- violations;
- process/monitor failures;
- override requests and completions;
- review outcomes.
Retention and storage:
- rotate logs by size and time;
- enforce max disk usage;
- define behavior when disk is full;
- support export and deletion through delayed admin if enforcement logs are involved;
- include schema version and migration path.
If logging fails:
- non-critical review notes may be skipped with visible warning;
- enforcement/audit events should fail conservative, usually `Locked`, unless doing so would create an unsafe machine lockout;
- break-glass must still work even when normal logging is impaired, with best-effort emergency log.
## Privacy, Ethics, And Data Retention
This is voluntary self-use software. It must not become bossware, parental control software, or coercive monitoring.
Principles:
- local-first storage by default;
- no network transmission unless explicitly configured;
- minimum necessary observation;
- prefer domain over full URL;
- prefer window class over title where possible;
- treat window titles as sensitive because they can contain private messages, documents, or secrets;
- encrypt logs/history at rest if feasible, especially once planner and presence data exist;
- provide retention, deletion, and export tools;
- make data collection visible to the user;
- include first-class `rest` and `exploration` commitments so legitimate openness and recovery are not treated as failure.
The system should increase agency, not create surveillance anxiety. Presence sensing, camera use, posture detection, and phone pickup detection require a separate opt-in design and ethics review after the core system is useful.
## Failure Handling
Failures should have explicit fail-open or fail-closed behavior.
| Failure | Default behavior |
| --- | --- |
| User agent crash | Guardian restarts agent; if repeated, enter `Locked` |
| User agent crash loop | Enter `Locked`, show recovery instructions |
| Guardian crash | systemd restarts guardian; until restored, user agent shows enforcement degraded |
| Guardian crash loop | Fail conservative where possible, but keep break-glass available |
| ActivityWatch stopped | Guardian restarts it; repeated failure escalates to `Locked` if ActivityWatch is required |
| ActivityWatch unavailable | Run degraded if policy does not require it; otherwise refuse `Active` |
| ActivityWatch data corrupted | Mark evidence degraded, preserve raw error, continue only if policy allows |
| Policy apply failure | Refuse `Active`; stay `Planning` or move `Locked` |
| Malformed policy | Reject, keep previous valid policy, escalate after repeated failures |
| Disk full/log write failure | Warn; fail conservative for enforcement events; keep break-glass available |
| System reboot | Start in `Locked` until state is restored and verified |
| Suspend/hibernate | On resume, revalidate timebox and policy; otherwise enter `Locked` |
| Clock manipulation | Prefer monotonic timers for timeboxes; record wall-clock anomalies |
| OS updates | Require delayed admin or maintenance commitment |
| Emergency interruption | Use transition or break-glass depending on severity |
| Schema migration failure | Enter `Locked` with recovery instructions |
The primary invariant is:
```text
Without an active valid commitment, the machine cannot silently enter unconstrained use.
```
## Locked, Planning, Transition, And Review UX
Locked state should show a small, reliable UI:
- current status;
- reason for lock;
- option to enter constrained Planning;
- option to review last session if pending;
- break-glass instructions that require delayed admin.
Planning should be useful but constrained:
- local project/task list;
- commitment templates;
- recent commitments;
- allowed local notes or references;
- time limit;
- no arbitrary browsing unless a planning policy explicitly allows it.
Transition should force consciousness into state changes:
- reason;
- expected duration;
- return target;
- optional note;
- timeout behavior.
Review should be difficult to skip:
- mark outcome;
- rate relevance;
- record drift or transition failures;
- update commitment/task state;
- generate next suggested commitment where useful.
## Staged Implementation
### Stage 1a: Commitment Kernel
- Commitment schema.
- PolicySnapshot schema without privileged enforcement.
- Runtime and commitment state machines.
- Append-only local event log.
- Unit tests for legal and illegal transitions.
### Stage 1b: Session UI And Evidence
- Session UI using commitment fields.
- Review flow.
- Activity/window evidence linked to `commitment_id`.
- Degraded evidence reporting when ActivityWatch or window metadata is unavailable.
### Stage 1c: Tier 0 Friction
- Full-screen or high-priority overlay on violation where feasible.
- Dismissal requires deliberate reason entry.
- Explicit transition prompts.
- Unknown-window/domain behavior based on enforcement level.
### Stage 2a: Guardian Skeleton
- systemd system service for guardian.
- systemd user service or launcher for agent.
- Watchdog restart behavior for user agent and ActivityWatch.
- Minimal status reporting.
### Stage 2b: IPC And Policy Delivery
- Root-owned Unix domain socket.
- JSON-lines protocol.
- `ApplyPolicy`, `ReportEvent`, `GetStatus`, `RequestOverride`.
- Schema versioning and malformed-message handling.
### Stage 2c: Domain Blocking
- nftables or DNS-level policy application.
- Domain matching semantics.
- Policy rollback on failure.
### Stage 2d: App/Window Interruption
- X11 adapter first if current environment is X11.
- Compositor-specific Wayland adapters only after verification.
- Minimize, overlay, or interrupt based on policy.
### Stage 2e: Tamper-Evident Logs And Retention
- Hash-chained events.
- Rotation and max disk usage.
- Export/delete path.
- Log failure handling.
### Stage 2f: Delayed Admin And Config Lockdown
- Root-owned policy/config.
- Delayed override protocol.
- Break-glass command.
- Uninstall/rollback tooling.
### Stage 3a: Project And Task Model
- Projects.
- Tasks.
- Next actions.
- Recurring responsibilities.
### Stage 3b: Commitment Templates From Tasks
- Project defaults.
- Work-mode templates.
- Task selection during Planning state.
- Time-limited context expansion.
### Stage 3c: Outcome Writeback
- Session outcomes written back to tasks.
- Review suggestions based on drift history.
- Next commitment suggestions.
## Future Direction: Embodied And Presence Sensing
Presence sensing is not part of the core roadmap. It may be considered later only if:
- the core system has proven useful;
- the user explicitly opts in;
- data minimization is defined;
- psychological safety is reviewed;
- retention and deletion behavior are explicit.
Potential future events:
- `desk_absence_started`;
- `desk_absence_resolved`;
- `posture_warning`;
- `phone_pickup_detected`.
These should be evidence events, not core dependencies.
## Uninstall And Rollback
The system must have a clean removal path.
Uninstall should:
- disable guardian service;
- disable user agent service;
- remove nftables or DNS rules;
- remove root-owned policy/config files after delayed admin confirmation;
- export or delete logs;
- restore normal admin behavior if it was modified for AntiDrift;
- verify that no blocking policy remains active.
Rollback should support returning from a failed upgrade to the previous known-good policy and guardian version.
## Testing Strategy
Testing must include bypass resistance, not only happy paths.
Unit tests:
- commitment validation;
- legal and illegal runtime transitions;
- legal and illegal commitment transitions;
- PolicySnapshot generation;
- allowed context matching;
- event hash chaining.
Property tests:
- no unconstrained use without valid active commitment;
- illegal transitions do not mutate state;
- expired policy snapshots are rejected.
Integration tests:
- user agent writes state;
- guardian reads policy;
- simulated window/ActivityWatch events produce expected violations;
- malformed policy is rejected;
- agent disconnect triggers grace period then `Locked`;
- timebox expiry moves to `Review` or `Locked` as configured.
Adversarial VM/manual tests:
- kill agent;
- stop ActivityWatch;
- attempt blocked domain;
- malformed policy;
- guardian restart;
- guardian crash loop;
- timebox expiry;
- config change attempt;
- reboot during active commitment;
- suspend/resume during active commitment;
- disk full or log write failure.
Soak and upgrade tests:
- 24-48 hour run with normal use;
- log rotation;
- schema migration;
- downgrade/rollback;
- IPC fuzz tests.
The first implementation plan should target Stage 1a through Stage 1c while preserving the process boundary and data contracts needed for Stage 2 and planner integration.
@@ -1,266 +0,0 @@
# AntiDrift Go Reimagining — Design
Date: 2026-05-31
## Purpose
AntiDrift is being reimagined from its current Rust implementation (~7,500
lines) into Go, to become the user's focus operating system on the computer:
fast, good-looking, and deeply integrated with AI from the start.
This is **not a faithful 1:1 port**. The existing domain model and the
`commitment-os-design.md` spec remain the north star. The Rust code is a
reference, not a thing to replicate line-for-line. The move to Go is an
opportunity to shed incidental complexity — most notably the token-heavy
event-log replay/revalidation design — while preserving what is genuinely
valuable.
### Why Go, why now
- **Token efficiency for AI-assisted development.** The current pain is not
runtime cost; it is that any AI edit to the core must load
`session.rs` (3,475 lines, ~80% replay-validation logic and its tests).
Go's smaller idioms plus a redesigned, smaller core directly reduce the
context any change requires.
- **Predictable LLM codegen.** Go's rigid syntax produces functional code on
the first pass with fewer correction loops.
- **Runtime fit.** Concurrency and local HTTP serving are first-class, which
suits a long-running focus daemon that also talks to AI.
## What Carries Over vs. What Changes
**Preserved (high value, ports cleanly):**
- The domain model: `Commitment`, `PolicySnapshot`, `RuntimeState`,
`CommitmentState`, `AllowedContext`, `EnforcementLevel`.
- The pure runtime/commitment state machines (`state_machine.rs`) — a near 1:1
port.
- The `commitment-os-design.md` spec as the conceptual foundation, including
"no unchosen transitions" and the staged threat model.
- Hash-chained tamper evidence — but relocated to the audit log only.
**Reimagined:**
- **Persistence.** Replace replay-everything-and-revalidate-on-startup with an
in-memory state-of-truth, a persisted **snapshot**, and an append-only audit
log. This removes roughly 3,000 lines (the bulk of `session.rs`).
- **UI.** Replace the ratatui TUI with a **local web app** (Gin backend +
browser). This is the surface that must "look good."
- **AI.** AI is a first-class participant from the start, not a later add-on.
**Deferred for v1:**
- The AI **reviewer** role (session-end reflection). The three live roles ship
first; the reviewer returns as **M7 — Reflection**, which closes the loop.
- Privileged enforcement (guardian, IPC, nftables, delayed admin) — same Stage 2
boundary as the original spec. This is the path toward the **entry gate**
(see "Destination" in the Roadmap) and is deliberately the last milestone.
## Process Model
A single Go binary, `antidriftd`, runs as a **local daemon** and owns all
state. The **browser** is its face.
```
┌─────────────────────────────────────────┐
│ antidriftd (one Go process) │
│ │
│ web (Gin) ──HTTP + SSE──▶ browser UI │
│ │ │
│ session ── statemachine ── domain │
│ │ │
│ store (snapshot + audit log) │
│ evidence (xdotool/X11) │
│ ai (CLI backend, async workers) │
└─────────────────────────────────────────┘
```
- The daemon holds live state **in memory** as the single source of truth.
- It persists a **snapshot** on every state change (crash/restart recovery).
- It appends every significant event to an **append-only audit log** (the
tamper-evident, hash-chained trail — for audit and later review, not for
state reconstruction).
- The browser is stateless: it renders what the daemon pushes over Server-Sent
Events (SSE) and POSTs user actions back. No business logic in the browser.
### Why snapshot instead of replay
The original Rust design reconstructs all state by replaying the entire event
log on startup and re-validating every transition, with a dedicated test per
illegal sequence. That is correct and tamper-aware, but it is the single
largest source of code and token weight. A snapshot of current state plus an
append-only audit trail gives the same recoverability and keeps tamper evidence
on the log, at a fraction of the code. State-machine *correctness* is still
enforced — by the pure transition functions at the point of transition, tested
directly — just not re-litigated on every startup.
## Architecture: Ports Around a Decision Core
AntiDrift is a **focus brain**: a decision core surrounded by pluggable
interfaces (ports) to the outside world. This is a ports-and-adapters
(hexagonal) architecture, and it is the organizing principle the whole system
grows along. New capability is almost always "a new port + adapter," not a
change to the core.
The core is layered, and the layering is load-bearing:
- **Skeleton — deterministic, no I/O** (`domain` + `statemachine`). The rails.
Owns what moves are *legal*. The original spec's safety property, "no
unchosen transitions," lives here: the system can only ever be in a legal
state, reached by a legal move.
- **Nervous system — the orchestrator** (`session.Controller`). The single hub.
Holds the in-memory state-of-truth, routes signals between ports and the
skeleton, persists snapshots, appends to the audit log, and broadcasts.
Everything connects through here.
- **Cortex — the advisor** (the LLM, via the `ai` port). Powerful *judgment* at
the decision points the state machine exposes — sharpen this commitment, is
this window drift, nudge me. It informs and proposes; **it can never force an
illegal transition.** The LLM is the most powerful adapter, not the kernel.
"The brain" is all three together. Critically, the state machine — not the LLM
— owns transitions; the LLM acts only within the rails the skeleton enforces.
### Ports
Each port is a small Go interface with one real adapter (and a fake for tests).
| Port | Interface | "Answers" | Adapter(s) | Milestone |
| ---- | --------- | --------- | ---------- | --------- |
| Activity | `evidence.Source` | What am I doing right now? | X11 / xgbutil (was xdotool) | M1 |
| Advisor | `ai.Assistant` (`Coach`/`JudgeDrift`/`Nudge`) | What's the smart call here? | `claude`/`codex` CLI | M2M3 |
| Tasks | `tasks.Provider` | What *should* I be doing? | Amazing Marvin (existing `ampy` + marvin MCP) | deferred (M5) |
| Knowledge | `knowledge.Source` | Who am I; what are my priorities? | PKM / files | deferred (M6) |
| Enforcement / Gate | `enforce.Guard` | Make drift cost something — ultimately, gate the machine on a declared intention | window-minimize (legacy `minimize_other`) → nftables/guardian → entry gate | deferred (M8) |
| UI | `web` | Show me; take my input | Gin + browser over SSE | M0, ongoing |
Persistence (`store`) is infrastructure shared by the orchestrator, not a port.
The `tasks`, `knowledge`, and `enforce` ports are **named now but built later**
— defining them keeps the architecture coherent without expanding near-term
scope. We resist designing their interfaces in detail until the milestone that
builds them, to avoid speculative abstraction (YAGNI). M1 ships the first real
port end-to-end (`evidence.Source` + X11 adapter + fake), establishing the
pattern every later port copies.
## Package Layout
| Package | Ports from | Size | Purpose |
| -------------- | ------------------------ | ------ | ------- |
| `domain` | `domain.rs` | small | Commitment, PolicySnapshot, runtime/commitment states, AllowedContext, EnforcementLevel, validation |
| `statemachine` | `state_machine.rs` | small | Pure transition functions (1:1 port) |
| `session` | reimagined `session.rs` | medium | In-memory controller; drives transitions, snapshots, audit appends; no replay validation |
| `store` | `event_log.rs` | small | Snapshot file (current state) + append-only hash-chained audit JSONL |
| `evidence` | `window/*` + `context.rs`| small | Active-window snapshot (xdotool/X11), evidence health, allowed-context matching |
| `ai` | new | small | `Coach` / `JudgeDrift` / `Nudge` behind one interface; CLI backend |
| `web` | new (replaces TUI) | medium | Gin routes, SSE stream, static browser UI |
| `tasks` | new (deferred, M5) | small | `Provider` port over current to-do items; Amazing Marvin adapter |
| `knowledge` | new (deferred, M6) | small | `Source` port over personal priorities / about-me context |
| `enforce` | `window/*` (minimize) | small | `Guard` port; make drift cost something (window-minimize → nftables/guardian) |
Design constraint: every package stays small and single-purpose so an AI edit
loads one focused file, not a monolith. This is the concrete mechanism for the
token-efficiency goal.
## AI Integration
AI is reached through one narrow interface with a single CLI backend to start:
```go
type Assistant interface {
// Planning: turn a vague intent into a concrete commitment.
Coach(ctx context.Context, intent string) (domain.Commitment, error)
// Live: is the current window on-task for this commitment?
JudgeDrift(ctx context.Context, c domain.Commitment, w evidence.WindowSnapshot) (Verdict, error)
// Ambient: periodic check-in based on recent activity.
Nudge(ctx context.Context, c domain.Commitment, recent []evidence.WindowSnapshot) (string, error)
}
```
- **Backend (v1):** shell out to `claude`/`codex` with a strict prompt that
demands JSON output. Reuses existing CLI auth; no API key plumbing.
- **Latency containment** (the CLI is slow, ~seconds, and AI is in the live hot
path): all AI calls run in **background goroutines**; the UI never blocks.
Drift judgments are **debounced** (no faster than ~10s) and **cached per
(commitment, window-class)** so the same window is not re-judged. The UI
shows a pending state and updates via SSE when a verdict lands.
- **Swap path:** the interface boundary lets an Anthropic API backend (faster,
structured, prompt-cached) drop in later without touching callers. Not built
in v1.
The three live roles ship first: planning **coach**, live **drift
interceptor**, ambient **nudge**. The reviewer is deferred. The advisor sits at
the **cortex** layer of the decision core (see "Architecture: Ports Around a
Decision Core"): it proposes and judges at the decision points the state
machine exposes, but never owns a transition.
## Roadmap
Each milestone is independently shippable and gets its own spec → plan → build
cycle. M0M4 build the core plus the first two ports (activity, advisor) and the
UI; M5M8 add the remaining ports and close the loop, each a small interface +
adapter following the pattern M1 establishes.
**Destination — the gate-first loop.** The end state is an OS-level focus loop:
the **Guard** checks for a declared intention *before* the machine is usable,
the user commits, works under monitoring, and every cycle ends in
**reflection** before returning to the gate (gate → plan → work → reflect →
gate). The earlier milestones build "track and advise"; the later ones turn
that into "you don't drift in the first place." We get there incrementally — the
Guard's privileged entry-gate behavior is deliberately the last and heaviest
step (M8), and everything before it is valuable standalone. The runtime state
machine already mirrors this loop: `locked` is the gate, `planning`/`active` are
declare-and-work, `review` is reflection.
- **M0 — Walking skeleton.** Daemon + Gin + minimal browser UI; port `domain` +
`statemachine`; snapshot persistence; manual commitment → timebox → end.
Proves the full stack end-to-end. No AI, no window tracking.
- **M1 — Evidence & audit.** X11 (xgbutil) active-window tracking via the
`evidence.Source` port, evidence health, per-window time stats, append-only
hash-chained audit log, live SSE updates. Establishes the port pattern.
- **M2 — AI planning coach.** `ai` port + CLI backend; "sharpen this
commitment" in the Planning view.
- **M3 — Drift interceptor + ambient nudge.** Allowed-context matching + live
AI drift judgment (debounced/cached) + violation friction UI.
- **M4 — Look good.** A real design pass on the web UI.
- **M5 — Tasks port.** `tasks.Provider` over current to-do items; Amazing Marvin
adapter. Pull the day's commitments from real tasks.
- **M6 — Knowledge port.** `knowledge.Source` over personal priorities and
about-me context, feeding the advisor richer grounding.
- **M7 — Reflection.** The deferred AI **reviewer** role, promoted into the main
loop: a session-end reflection that reads the audit trail and per-window stats
(built in M1) to summarize what happened and feed the next planning cycle.
Closes the "Focus OS Reflection" step and makes the loop self-reinforcing.
- **M8 — Enforcement & gate.** `enforce.Guard`: make drift cost something,
starting with window-minimize (porting legacy `minimize_other`) and the
nftables/DNS path, building toward the **entry gate** — the Guard checking for
a declared intention before the machine is usable. The privileged guardian
process, root-owned IPC, and break-glass keep the original Stage 2 threat
boundary and are the final, heaviest step.
The first sub-project to brainstorm and spec in detail is **M0**.
## Repo Strategy
- New Go module at the repository root.
- Move the existing Rust into a `legacy/` directory (or a `rust` branch) so it
remains available as reference while the Go code becomes the front door.
## Out of Scope (v1)
"v1" here means the first shippable arc, **M0M4** (core + activity/advisor
ports + UI). The items below are deferred past it; some are now named ports with
their own later milestones (see Roadmap), others remain fully out of scope.
- **Tasks, knowledge, and enforcement ports** — named in the architecture and
slotted as M5M8, but not built in v1. The `enforce.Guard` port starts with
window-minimize and builds toward the entry gate; its **privileged** adapters
(guardian process, root-owned Unix socket IPC, nftables/DNS domain blocking,
delayed admin, break-glass) remain out of scope until M8 and keep the original
Stage 2 threat boundary.
- AI reviewer / session-end reflection — now scheduled as **M7 — Reflection**.
- Wayland compositor adapters beyond the existing degraded reporting.
- Planner/project model and outcome writeback (beyond the M5 tasks port).
- Presence sensing.
These remain governed by `commitment-os-design.md` and may return as later
milestones.
@@ -1,209 +0,0 @@
# M0 — Walking Skeleton Design
Date: 2026-05-31
Parent design: `2026-05-31-go-focus-os-design.md`
## Purpose
M0 proves the entire Go stack end-to-end with the smallest possible surface: a
single Go daemon (`antidriftd`) that serves a local web UI, drives one manual
commitment through the ported state machine, and persists a snapshot that
survives restart.
M0 explicitly excludes AI, active-window tracking, and the hash-chained audit
log (those arrive in M1+). What M0 establishes is the real architecture — the
daemon process model, the ported domain/state-machine, snapshot persistence,
and the SSE sync channel — so later milestones add features to a working spine
rather than scaffolding.
## Scope
In scope:
- Port `domain` (Commitment, runtime/commitment states, EnforcementLevel,
minimal PolicySnapshot, validation).
- Port `statemachine` (pure runtime/commitment transition functions).
- `session.Controller`: in-memory state of truth behind a mutex; snapshot on
every change.
- `store`: snapshot load/save as JSON.
- `web`: Gin server with the M0 HTTP surface and an SSE stream.
- A single-page vanilla-JS browser UI covering the M0 state flow.
- Unit tests for domain, statemachine, session; httptest tests for web.
Out of scope (deferred):
- AI of any kind (M2+).
- Active-window tracking and evidence health (M1).
- Hash-chained append-only audit log (M1).
- Allowed-context matching and violation friction (M3).
- Transition (break) flow, admin override, planner — full machine edges beyond
the M0 subset.
- Visual design polish (M4).
## State Flow
M0 exercises a subset of the full runtime state machine:
```
Locked ──Plan──▶ Planning ──Start──▶ Active ──Complete / timebox expiry──▶ Review ──End──▶ Locked
```
All transitions go through the ported pure transition functions, so behavior is
correct from day one even though fewer edges are exercised. Mapping to the
ported actions:
- Locked → Planning: `EnterPlanning`
- Planning → Active: `Activate { policy_accepted: true }` (commitment also
`Activate`s draft → active)
- Active → Review: `CompleteForReview` (triggered by user "Complete" or by
server-side timebox expiry)
- Review → Locked: `EndWorkPeriod`
## Components
### `domain`
Direct port of the valuable Rust types:
- `Commitment` (id, createdAt, source, next action, success condition, timebox
seconds, state) with `NewManual(...)` validation: non-empty next action,
non-empty success condition, non-zero timebox.
- `RuntimeState` (Locked, Planning, Active, Transition, Review, AdminOverride)
and `CommitmentState` (Draft, Active, Paused, Completed, Abandoned, Violated)
— full enums, even though M0 uses a subset, so later milestones need no
changes here.
- `EnforcementLevel` (Observe, Warn, Block, Locked).
- `PolicySnapshot`: minimal — enough to represent the accepted policy that gates
`Activate`. Full enforcement fields can stay zero/empty in M0.
- IDs use UUIDv7 (or equivalent monotonic unique id); JSON tags use snake_case
to match the existing on-disk vocabulary.
### `statemachine`
Port of `state_machine.rs`:
- `TransitionRuntime(current RuntimeState, action RuntimeAction) (RuntimeState, error)`
- `TransitionCommitment(current CommitmentState, action CommitmentAction) (CommitmentState, error)`
- Illegal transitions return a typed error identifying current state + action.
Pure functions, no I/O.
### `session.Controller`
- Holds `runtimeState` and `activeCommitment` in memory, guarded by a
`sync.Mutex`, as the single source of truth.
- Methods: `EnterPlanning()`, `StartManualCommitment(nextAction, successCond
string, timebox time.Duration)`, `Complete()`, `End()`.
- Each method applies the relevant transition(s), updates in-memory state, and
persists a snapshot via `store`. On any transition error, state is left
unchanged and the error is returned.
- Exposes a read method returning a snapshot-shaped view for broadcasting.
### `store`
- Snapshot is the current state: runtime state + active commitment (+ deadline).
- `Load(path) (Snapshot, error)` — missing file yields a default `Locked`
snapshot, not an error.
- `Save(path, Snapshot) error` — atomic write (temp file + rename) to
`~/.antidrift/state.json`, creating the directory if needed.
- No hash chaining in M0; that belongs to the M1 audit log.
### `web`
- Gin server bound to `localhost:7777`.
- Holds the `session.Controller` and an SSE broadcaster (set of subscriber
channels).
- Each mutating handler: acquire controller mutex → apply transition → persist
snapshot → broadcast new state to all SSE subscribers → return.
## Daemon Behavior
On start, `antidriftd`:
1. Loads the snapshot (or starts `Locked`).
2. If the loaded state is `Active` with a future deadline, re-arms the expiry
timer; if the deadline has already passed, transitions to `Review`.
3. Starts Gin on `localhost:7777`.
4. Attempts to open the default browser at that URL (best-effort; logs and
continues if it fails).
Timebox expiry is **server-authoritative**: on entering `Active`, the daemon
arms a `time.AfterFunc` at the deadline that fires `Active → Review` and
broadcasts. The browser countdown is cosmetic and derived from the deadline
timestamp in the state payload.
## HTTP Surface
| Method | Route | Body | Effect |
| ------ | ------------- | ------------------------------------------------- | ------ |
| GET | `/` | — | Serves the single-page UI |
| GET | `/events` | — | SSE stream; emits the current state immediately, then on every change |
| POST | `/planning` | — | Locked → Planning |
| POST | `/commitment` | `{next_action, success_condition, timebox_secs}` | Planning → Active (validates; 400 on invalid) |
| POST | `/complete` | — | Active → Review |
| POST | `/end` | — | Review → Locked |
State payload (SSE `data:` and POST responses) is JSON:
```json
{
"runtime_state": "active",
"commitment": {
"next_action": "Port the domain package",
"success_condition": "domain tests pass",
"timebox_secs": 1500,
"deadline_unix_secs": 1748725200
}
}
```
`commitment` is null when there is no active commitment. Invalid transitions
requested via HTTP return 409 (illegal transition) or 400 (invalid input); state
is unchanged.
## Browser UI
Single HTML page served from `/`, using vanilla JavaScript with no build step
(no bundler, no framework) to keep it dependency-free and token-light. It:
- Subscribes to `/events` via `EventSource`.
- Renders exactly one view based on `runtime_state`:
- **Locked** — status + a "Plan" button (POST `/planning`).
- **Planning** — a form with next action, success condition, and minutes;
"Start" is disabled until all three are valid (POST `/commitment`).
- **Active** — next action, success condition, a live countdown derived from
`deadline_unix_secs`, and a "Complete" button (POST `/complete`).
- **Review** — a short summary of the just-ended commitment and an "End"
button (POST `/end`).
- Is a pure renderer of pushed state; it holds no authoritative state of its
own. Styling is clean and legible but not the focus — the real visual pass is
M4.
## Testing
- `domain`: validation rejects empty next action / success condition / zero
timebox; JSON round-trips with snake_case tags.
- `statemachine`: legal transitions for the M0 subset succeed; representative
illegal transitions (e.g. Locked → Active) return typed errors. Port the
intent of the existing Rust tests.
- `session`: planning → start → complete → end happy path drives the expected
states; snapshot save/load round-trips and restores the controller.
- `web`: `httptest` checks that POST `/planning` then POST `/commitment` moves
the controller to Active, that `/commitment` with invalid input returns 400,
and that `/events` emits a state payload.
## Done When
`go run ./cmd/antidriftd` opens a browser; the user creates a commitment,
watches the timebox count down, completes it (or lets it expire into Review),
and ends the session back to Locked. Killing and restarting the daemon restores
the persisted state from `~/.antidrift/state.json`. `go test ./...` passes.
## Repo Setup (first task of M0)
- Initialize the Go module at the repository root.
- Move the existing Rust sources into `legacy/` so they remain available as
reference (Cargo.toml, src/, etc.), keeping the shared `docs/` at the root.
- Code layout: `cmd/antidriftd/` (main), `internal/domain`,
`internal/statemachine`, `internal/session`, `internal/store`,
`internal/web` (with the static UI under `internal/web/static`).
@@ -1,381 +0,0 @@
# M1 — Evidence & Audit Design
Date: 2026-05-31
Parent design: `2026-05-31-go-focus-os-design.md`
## Purpose
M1 gives the daemon eyes and a memory. It adds a continuous active-window
sensor, a two-tier evidence store, and live updates of what you are doing — then
seals each finished session into a tamper-evident, hash-chained audit trail.
M1 is **observe & record only**. It makes no judgment about whether the current
window is on-task; that is the advisor's job in M3. M1 is honest
instrumentation: the trustworthy foundation that later milestones read from.
In the architecture of the parent design, M1 builds the first real **port** end
to end — the activity port (`evidence.Source`) with its X11 adapter and a fake
for tests — establishing the interface + adapter + fake pattern every later port
copies.
## Scope
In scope:
- `evidence` package: an X11 (xgbutil) active-window sensor behind a `Source`
interface, pushing a `WindowSnapshot` on every focus change; evidence health;
the legacy title-scrubbing regex as a tested `ScrubTitle` function.
- `store` extensions: a per-session raw focus-event log (`sessions/<id>.jsonl`,
appended live, pruned after a retention window) and a permanent hash-chained
audit log (`audit.jsonl`, one linked `SessionSummary` per completed session).
- `session.Controller` extensions: in-memory per-session evidence stats, an
injectable clock, focus accumulation while `Active`, crash-recovery replay,
and writing the hashed summary at session end.
- `web` extensions: the SSE payload carries an `evidence` object; the Active and
Review views surface current window, per-bucket time, context-switch count,
and an evidence-health indicator. A broadcast callback registered on the
controller so every state change (focus, expiry, user action) fans out over
one path.
- Unit tests for `evidence` (scrub + interface), `store` (audit chain +
evidence log), `session` (accumulation via fake source + injectable clock),
and `web` (evidence payload over SSE).
Out of scope (deferred):
- Any AI / drift judgment / on-task vs off-task classification (M2M3).
- Allowed-context matching and violation friction (M3).
- Enforcement of any kind, including window-minimize (M7).
- Wayland active-window support beyond degraded `Unavailable` reporting.
- Visual design polish (M4) — M1 surfaces data legibly, nothing more.
## Architecture Fit
Per the parent design's "ports around a decision core":
- **Skeleton** (`domain`, `statemachine`) is unchanged by M1.
- **Nervous system** (`session.Controller`) gains evidence ownership: it
receives sensor events, decides relevance by current runtime state,
accumulates stats, persists raw events, and writes the audit summary. It
remains the single hub.
- **Activity port** (`evidence.Source`) is new — a dumb sensor that makes no
decisions. `session` decides what each event means.
The sensor never judges; the orchestrator never talks to X11 directly. This is
the port boundary M1 exists to establish.
## The Two-Tier Evidence Model
Window focus can change dozens of times a minute. Keeping every focus event in
the permanent hash chain forever would bloat it. Discarding detail loses the
ability to compute things like context-switch frequency. M1 resolves this with
two tiers:
1. **Raw, per-session, disposable.** Every focus change is appended live to
`sessions/<session-id>.jsonl` as it happens (crash-durable). This is the
firehose: full titles, millisecond timestamps. It is the recovery source for
in-memory stats and the basis for end-of-session analytics. It is **pruned
after 30 days**.
2. **Summarized, permanent, tamper-evident.** At session end the raw stream is
rolled up into one `SessionSummary` (per-bucket totals, switch count,
duration, outcome) which is hash-chained into `audit.jsonl` and **kept
forever**. The chain is coarse — one linked entry per session — so tamper
evidence is cheap and the log stays small.
## Components
### `evidence` (new package)
A dumb X11 sensor. One long-lived xgbutil connection is opened at daemon start
and kept open for the whole process. It subscribes to `PropertyNotify` on the
root window's `_NET_ACTIVE_WINDOW` and emits a `WindowSnapshot` on every active
window change (and once immediately with the current window). It makes no
relevance decisions.
```go
type EvidenceHealth struct {
Available bool
Reason string // empty when Available; populated when not
}
type WindowSnapshot struct {
Title string // full _NET_WM_NAME (raw; used in live view + raw log)
Class string // WM_CLASS
Health EvidenceHealth
}
type Source interface {
// Watch runs until ctx is cancelled, invoking onChange on every active
// window change, and once immediately with the current window.
Watch(ctx context.Context, onChange func(WindowSnapshot))
}
```
- Real implementation `x11Source` uses xgbutil:
`ewmh.ActiveWindowGet``ewmh.WmNameGet` (title) + `icccm.WmClassGet` (class).
- On X failure, no active window, or unset `DISPLAY`:
`Health{Available: false, Reason: "..."}` with empty title/class. This mirrors
the legacy degraded reporting and covers Wayland implicitly.
- `ScrubTitle(string) string` is the legacy digit/percent regex
(`-?\d+([:.]\d+)+%?`) ported as its own tested function. Used to compute
bucket keys; the raw log keeps the unscrubbed title.
### `store` (extended)
Two new files beside `snapshot.go`. `store` is infrastructure, not a port.
`store/audit.go` — the permanent hash-chained log at `~/.antidrift/audit.jsonl`:
```go
type BucketTotal struct {
Class string `json:"class"`
Title string `json:"title"` // scrubbed
Seconds int64 `json:"seconds"`
}
type SessionSummary struct {
Seq int `json:"seq"`
PrevHash string `json:"prev_hash"`
SessionID string `json:"session_id"`
NextAction string `json:"next_action"`
SuccessCond string `json:"success_condition"`
Outcome string `json:"outcome"` // "completed" | "expired"
StartedUnix int64 `json:"started_unix"`
EndedUnix int64 `json:"ended_unix"`
SwitchCount int `json:"switch_count"`
Buckets []BucketTotal `json:"buckets"` // sorted desc by seconds
Hash string `json:"hash"`
}
func AppendSession(path string, s SessionSummary) error
func VerifyChain(path string) error
```
- `AppendSession` reads the last line's `Hash` as this entry's `PrevHash`
(genesis = 64 hex zeros), assigns `Seq = lastSeq + 1`, computes
`Hash = SHA-256(prevHash || canonicalJSON(fields-except-Hash))`, and appends
one JSON line atomically (append + fsync). Canonical serialization uses the
struct with `Hash` zeroed and stable field order (encoding/json is stable for
structs).
- `VerifyChain` re-walks every line: recompute each hash, confirm each
`PrevHash` equals the prior `Hash`, confirm `Seq` is contiguous. Returns an
error naming the first broken line; nil if intact or empty.
`store/evidence_log.go` — the per-session raw stream at
`~/.antidrift/sessions/<session-id>.jsonl`:
```go
type FocusEvent struct {
AtUnixMillis int64 `json:"at_unix_millis"`
Class string `json:"class"`
Title string `json:"title"` // full, unscrubbed
Available bool `json:"available"`
Reason string `json:"reason,omitempty"`
}
func AppendFocus(dir, sessionID string, e FocusEvent) error
func ReplaySession(dir, sessionID string) ([]FocusEvent, error)
func PruneOlderThan(dir string, age time.Duration, now time.Time) error
```
- `AppendFocus` opens the session file `O_APPEND|O_CREATE`, writes one JSON
line, closes. Creates `sessions/` if needed.
- `ReplaySession` reads all events in order (used to rebuild stats after a
crash).
- `PruneOlderThan` deletes session files whose modification time is older than
`now - age`. `now` is injected for testability.
### `session.Controller` (extended)
Gains:
- `clock func() time.Time` — injectable; defaults to `time.Now`. Used for all
accumulation and timestamps so tests are deterministic.
- `stats *EvidenceStats` — in-memory, current session only (nil when not in a
live session).
- `onChange func()` — a notification callback the web layer registers; invoked
after any state change so the browser is pushed fresh state.
- A reference to the store paths (audit file, sessions dir).
```go
type bucketKey struct{ Class, Title string } // Title is scrubbed
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
```
`RecordWindow(snap evidence.WindowSnapshot)`:
1. Lock. Always update `c.latestWindow = snap` (for the live indicator outside
Active).
2. If `runtimeState != Active` or `stats == nil`: fire `onChange`; return.
(Tracked for display, not accounted.)
3. `now := c.clock()`. If `hasLast`: add `now - lastFocusAt` to
`Buckets[lastKey]`. (If the prior snapshot was unavailable, `lastKey` is the
reserved `(evidence unavailable)` key.)
4. Append a `FocusEvent` to the session log.
5. Compute the new key: unavailable snapshot → reserved key
`{Class: "", Title: "(evidence unavailable)"}`; else
`{snap.Class, ScrubTitle(snap.Title)}`. If `hasLast && newKey != lastKey`,
increment `SwitchCount`.
6. Set `lastKey = newKey`, `lastFocusAt = now`, `hasLast = true`,
`Current = snap`. Fire `onChange`.
Lifecycle hooks (all under the existing mutex):
- **`StartManualCommitment`** (→ Active): mint `session_id` (UUIDv7), create
`stats` with `StartedUnix = clock()`, seed `Current`/`lastKey`/`lastFocusAt`
from `latestWindow` (so the first segment counts from start), `hasLast = true`.
Persist snapshot (now carrying `session_id`).
- **`Complete`** / **timebox expiry** (→ Review): flush final segment
(`clock() - lastFocusAt` into `lastKey`), freeze `stats` (stop accounting).
Consistent with M0's "freeze active time during review."
- **`End`** (→ Locked): build `SessionSummary` from frozen `stats`
(`Outcome` = "completed" if user-completed, "expired" if timebox fired —
tracked via a field set at the Active→Review transition), `AppendSession` to
the audit chain, then clear `stats`.
Read view: `State()` is extended to include an evidence projection (current
window, buckets sorted desc, switch count, health) for the SSE payload.
### Crash Recovery
The snapshot (`state.json`) gains `session_id` and `outcome_pending` fields.
On startup, after loading the snapshot:
- Run `store.PruneOlderThan(sessionsDir, 30*24*time.Hour, time.Now())`.
- If runtime state is `Active` with a `session_id`: `ReplaySession` the raw log
and rebuild `EvidenceStats` exactly (sum segments between consecutive events;
the final open segment resumes from the last event's timestamp). The raw log
is the recovery source of truth for stats; the snapshot only points at it.
- The M0 deadline re-arm / expire-to-Review behavior is unchanged and runs
after stats are rebuilt.
### `web` (extended)
- At startup the `Server` registers `ctrl.SetOnChange(func(){ broadcast() })`.
Every state change — focus update, timebox expiry, user action — flows out
through this single path. This also moves M0's expiry broadcast onto the same
callback rather than an inline broadcast.
- The SSE/POST payload gains an `evidence` object:
```json
{
"runtime_state": "active",
"commitment": { "next_action": "...", "success_condition": "...",
"timebox_secs": 1500, "deadline_unix_secs": 1748725200 },
"evidence": {
"available": true,
"reason": "",
"current": { "class": "code", "title": "m1 design - antidrift" },
"switch_count": 7,
"buckets": [
{ "class": "code", "title": "antidrift", "seconds": 540 },
{ "class": "firefox", "title": "docs", "seconds": 120 }
]
}
}
```
`evidence` is null when there is no live session (Locked/Planning). In Review it
carries the frozen stats.
### Browser UI (data, not polish)
The page stays a pure renderer of pushed state. Additions:
- **Active view:** current window line (`class · title`); a per-bucket time
breakdown list (sorted desc, `mm:ss`); the context-switch count; an
evidence-health pill — green "tracking" when available, amber
"evidence unavailable: <reason>" when not.
- **Review view:** the session summary about to be hashed — total active time,
switch count, top buckets.
- Visual treatment is minimal and legible; the real design pass is M4.
## Data Flow
```
X server ──PropertyNotify──▶ evidence.x11Source.Watch (goroutine)
│ onChange(WindowSnapshot)
session.Controller.RecordWindow
│ (accumulate if Active) │ append raw event
▼ ▼
EvidenceStats (memory) sessions/<id>.jsonl
│ onChange()
web broadcast ──SSE──▶ browser
...
End: SessionSummary ──hash-chain──▶ audit.jsonl (permanent)
```
## Error Handling
- **Evidence unavailable** (X error, no active window, Wayland): snapshot has
`Health.Available = false`; time during the gap accrues to the reserved
`(evidence unavailable)` bucket so totals always equal wall-clock duration.
The watch goroutine logs and continues; it never crashes the daemon.
- **Session log append failure:** logged; accounting continues in memory (the
raw log is best-effort detail, not the state of truth). Crash recovery for
that session would be incomplete, which is acceptable for disposable detail.
- **Audit append failure at End:** logged and surfaced; the transition to Locked
still completes (state integrity over audit completeness). A retry-on-next-
start is out of scope.
- **Corrupt audit chain:** `VerifyChain` reports the first broken line; M1 only
exposes verification, it does not auto-repair.
## Testing
- `evidence`: `ScrubTitle` table tests porting the legacy regex cases
(percentages, ratios, leading sign, plain titles untouched). The `x11Source`
sits behind `Source`; a `//go:build` integration smoke test queries the live
server and is skipped when `DISPLAY` is unset.
- `store/audit`: append N summaries then `VerifyChain` passes; genesis
`PrevHash` is 64 zeros; `Seq` is contiguous; a tamper test mutates a middle
line's field and asserts `VerifyChain` names that line.
- `store/evidence_log`: `AppendFocus` then `ReplaySession` round-trips event
order/values; `PruneOlderThan` deletes only files older than the cutoff
(modification time controlled).
- `session`: a `fakeSource` plus injectable `clock` drive scripted focus
sequences with controlled timestamps. Assert: bucket totals and switch count;
events outside Active are not accounted; unavailable snapshots accrue to the
reserved bucket; crash-replay rebuilds identical stats; `End` writes a
summary whose buckets/switch-count match and which extends the chain.
- `web`: httptest confirms `/events` emits an evidence-bearing payload and that
a simulated focus change pushes an updated payload.
## Done When
`go run ./cmd/antidriftd`: start a commitment, switch between a few windows, and
watch the Active view update live with the current window, per-app time, and
switch count. Let it complete (or expire) into Review and see the session
summary. End it; `audit.jsonl` gains one hash-chained line and `VerifyChain`
passes. Kill the daemon mid-session and restart: the per-window stats are
rebuilt from the session log and tracking resumes. Session files older than 30
days are gone on startup. `go test ./...` passes.
## File Structure
- Create: `internal/evidence/evidence.go` (types, `Source`, `ScrubTitle`)
- Create: `internal/evidence/x11.go` (`x11Source`, xgbutil; build-tagged)
- Create: `internal/evidence/evidence_test.go`
- Create: `internal/store/audit.go`, `internal/store/evidence_log.go`
- Create: `internal/store/audit_test.go`, `internal/store/evidence_log_test.go`
- Modify: `internal/store/store.go` (snapshot gains `session_id`,
`outcome_pending`)
- Modify: `internal/session/session.go` (clock, stats, RecordWindow, lifecycle
hooks, onChange, recovery), `internal/session/session_test.go`
- Modify: `internal/web/web.go` (SetOnChange wiring, evidence payload),
`internal/web/web_test.go`, `internal/web/static/index.html`
- Modify: `cmd/antidriftd/main.go` (construct `evidence.Source`, start `Watch`,
wire to controller)
- Modify: `go.mod` (add `github.com/jezek/xgb`, `github.com/jezek/xgbutil`)
@@ -1,447 +0,0 @@
# M2 — AI Planning Coach — Design
Date: 2026-05-31
## Purpose
M2 adds the first AI capability to AntiDrift: a **planning coach**. In the
Planning view, the user types one rough intent ("work on the quarterly report"),
presses **Sharpen**, and an AI coach proposes a structured commitment —
`next_action`, `success_condition`, and a `timebox` — that pre-fills the existing
three Planning inputs for the user to edit and accept.
This establishes the `ai` port (the **cortex** layer of the decision core) and
the CLI backend, the pattern every later AI role (drift interceptor, nudge,
reflection) will reuse. The coach **proposes**; the user still drives the
existing `/commitment` transition. The LLM never owns a state transition.
AI is **strictly additive**: if the coach is unavailable, slow, or returns
garbage, the three manual Planning inputs remain fully usable. This mirrors the
evidence-health degradation pattern established in M1.
## Scope
**In scope (M2):**
- A new `ai` package with a pluggable CLI **backend** abstraction and **two real
adapters from day one: `claude` and `codex`**.
- A backend-agnostic **`Coach`** capability that turns a free-text intent into a
validated `Proposal`.
- Async, SSE-driven delivery: the coach runs in a background goroutine; the UI
shows a pending state and updates when the proposal lands.
- Graceful degradation on every failure path (missing CLI, timeout, malformed
output, no backend wired).
- Planning-view UI: an intent box + Sharpen button that pre-fills the existing
inputs from the proposal.
**Out of scope (deferred):**
- The `JudgeDrift` and `Nudge` roles — they join the `ai` interface in **M3**.
M2 builds only `Coach` (YAGNI).
- An Anthropic API backend — the interface boundary allows it later without
touching callers; not built now.
- Any change to the commitment/runtime state machine. The coach produces a
draft; activation still goes through the existing `StartManualCommitment`
path.
- Persisting the proposal. It is ephemeral pre-commitment advice (see
"Ephemeral state").
## Architecture
M2 follows the established ports-and-adapters shape. The `ai` package is the new
**Advisor** port; `claude` and `codex` are its adapters; `session.Controller`
(the nervous system) orchestrates the async call and broadcasts; the browser
renders. The coach sits at the **cortex** layer: it proposes at a decision point
the state machine exposes (planning), but never forces a transition.
### The `ai` package — two layers
The pluggability requirement is met by separating *what we ask* from *how we
reach a CLI*.
**Layer 1 — `Backend` (the pluggable adapter).**
```go
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
// and arguments they run.
type Backend interface {
// Run sends prompt to the CLI and returns its raw stdout.
Run(ctx context.Context, prompt string) (string, error)
// Name identifies the backend (e.g. "claude", "codex").
Name() string
}
```
Two real adapters. The exact invocations below were verified empirically on
this machine (claude 2.1.154, codex-cli 0.135.0); both authenticate via the
existing CLI login — **no API keys**.
- **`claudeBackend`** runs:
```
claude --print --tools "" --no-session-persistence --output-format text
```
The prompt is delivered on **stdin** (avoids argv limits and shell-escaping;
also dodges a quirk where an empty `--tools ""` positional can be mistaken for
the prompt). The model's answer is exactly **stdout** (trailing newline
trimmed). `--tools ""` disables all tools so it just answers;
`--no-session-persistence` avoids writing resumable session files. Do **not**
use `--bare` (it forces `ANTHROPIC_API_KEY` and ignores the machine's login).
- **`codexBackend`** runs:
```
codex exec --skip-git-repo-check --ignore-user-config --ignore-rules \
-s read-only -a never --ephemeral -o <tmpfile> -
```
The prompt is delivered on **stdin** (the trailing `-` tells codex to read it
from stdin). codex's **stdout is not clean** (it includes session preamble),
so the adapter writes the final answer to a per-call **temp file** via `-o`,
then reads and returns that file's contents. The adapter creates the temp file
(`os.CreateTemp`) and removes it on return. The flags matter:
`--ignore-user-config --ignore-rules -s read-only` stop codex from executing
shell commands driven by local config (observed: it otherwise runs tool calls
even for a trivial prompt, adding latency); `-a never` disables approval
prompts for headless use; `--ephemeral` skips persisting session files;
`--skip-git-repo-check` lets it run anywhere.
Both use `os/exec` with the `ctx` passed to `exec.CommandContext` so a timeout
cancels the child process. Each adapter stores its command name and base args in
struct fields so argument construction is unit-testable without spawning a
process. The codex adapter's temp-file handling lives inside its `Run` so the
`Backend` interface stays uniform (`Run(ctx, prompt) (string, error)`).
A selector constructs the configured backend:
```go
// NewBackend returns the named backend, or an error for an unknown name.
// name "" defaults to "claude".
func NewBackend(name string) (Backend, error)
```
**Layer 2 — `Coach` (backend-agnostic capability).**
```go
// Proposal is the coach's structured suggestion for a commitment. It is NOT a
// domain.Commitment: the AI does not mint IDs, timestamps, or state.
type Proposal struct {
NextAction string
SuccessCondition string
TimeboxSecs int64
}
// Coach turns a free-text intent into a validated Proposal.
type Coach interface {
Coach(ctx context.Context, intent string) (Proposal, error)
}
```
`Service` implements `Coach` over any `Backend`:
```go
type Service struct {
backend Backend
}
func NewService(b Backend) *Service
```
`Coach` builds a strict prompt, calls `backend.Run`, extracts and parses the
JSON, and validates it. The `ai` package imports nothing from the rest of the
app (it returns its own `Proposal`, not `domain.Commitment`), so it stays a leaf
package with no import cycles.
### Prompt and JSON contract
The prompt instructs the model to act as a focus coach and to **return only
JSON** of the form:
```json
{
"next_action": "Draft the executive summary section",
"success_condition": "Summary section has 3 paragraphs covering revenue, risks, outlook",
"timebox_minutes": 25
}
```
Parsing is tolerant of a chatty CLI:
- `extractJSON(s string) (string, error)` scans for the first balanced `{...}`
object in the output and returns it. This survives leading/trailing prose or
code fences.
- `parseProposal(jsonStr string) (Proposal, error)` unmarshals into an internal
struct with `next_action`, `success_condition`, `timebox_minutes`, then:
- trims whitespace; errors if `next_action` or `success_condition` is empty;
- errors if `timebox_minutes <= 0`;
- converts minutes to `TimeboxSecs` (`minutes * 60`).
All parse/validation failures return a non-nil error; the caller degrades
gracefully (see below). Sentinel errors: `ErrEmptyResponse`, `ErrNoJSON`,
`ErrInvalidProposal`.
Both CLIs also offer native structured-output flags (claude
`--output-format json --json-schema`; codex `--output-schema <file>`) that would
guarantee shape. We deliberately do **not** use them in M2: they diverge the two
adapters (different flags, envelope vs file) and would push schema concerns into
the `Backend` layer. Prompt-instructed JSON + tolerant `extractJSON` keeps the
`Backend` interface uniform and the parsing in one place. Native schemas remain a
clean future robustness upgrade behind the same `Coach` boundary.
### `session.Controller` — async coach orchestration
A new method drives the coach using the **exact concurrency pattern** already in
`RecordWindow`: mutate state under the mutex, then call `notify()` with the
mutex released (`session.go:139-146`).
```go
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach)
// RequestCoach starts an async coach call for the given intent. It is a no-op
// error path (not a hard failure) unless the runtime state is wrong.
func (c *Controller) RequestCoach(intent string) error
```
Behavior of `RequestCoach`:
1. Lock. If `runtimeState != RuntimePlanning`, unlock and return
`ErrNotPlanning` (a real client error — coaching only makes sense in
planning).
2. If `coach == nil`: set coach state to `status=error`,
`err="coach unavailable"`, unlock, `notify()`, return `nil` (graceful — not
an HTTP error).
3. Otherwise: increment `coachGen`, capture `gen := coachGen`, set
`status=pending`, clear prior proposal/error, capture the `coach` reference,
unlock, `notify()` (broadcasts the pending state).
4. Launch a goroutine:
- `ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)`
(`coachTimeout = 60 * time.Second` — codex in particular runs tens of
seconds even for trivial prompts; 60s gives a real coaching prompt
headroom); `defer cancel()`.
- Call `coach.Coach(ctx, intent)`.
- Lock. **If `gen != c.coachGen` or `runtimeState != RuntimePlanning`,
unlock and return** (stale result — a newer request superseded this one, or
the user left planning). Discard silently.
- On error: `status=error`, `err=<sanitized message>`, `proposal=nil`.
- On success: `status=ready`, `proposal=<the Proposal>`, `err=""`.
- Unlock, `notify()`.
The intent string is **not** stored on the controller; it is captured by the
goroutine closure only.
#### Ephemeral state
The coach state lives on the controller as plain fields and is **never written
to the snapshot**:
```go
// on Controller:
coach ai.Coach
coachStatus string // "idle" | "pending" | "ready" | "error"
coachProposal *ai.Proposal
coachErr string
coachGen int
```
`persistLocked()` is **not** modified — `store.Snapshot` gains no coach fields.
Rationale: a proposal is pre-commitment advice; if the daemon restarts during
planning, there is nothing to recover, and the user simply re-sharpens.
Coach state is reset to `idle` (proposal nil, err "") in two places:
- `EnterPlanning` — entering planning starts with a clean coach.
- `StartManualCommitment` and the `enterReview`/`End` paths implicitly leave
planning; coach state is reset to `idle` there so a stale `ready` proposal is
not projected outside planning. (Concretely: reset in `EnterPlanning` and on
any successful leave-planning transition.)
#### State projection
`State` gains a coach projection, populated **only while in planning**:
```go
type ProposalView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
}
type CoachView struct {
Status string `json:"status"` // idle | pending | ready | error
Proposal *ProposalView `json:"proposal,omitempty"`
Error string `json:"error,omitempty"`
}
// added to State:
// Coach *CoachView `json:"coach,omitempty"`
```
In `stateLocked()`: if `runtimeState == RuntimePlanning`, attach a `CoachView`
with the current status (default `idle`), the proposal if `ready`, and the error
if `error`. Outside planning, `Coach` is `nil` and omitted.
### `web` layer
One new route:
```go
r.POST("/coach", s.handleCoach)
```
```go
type coachRequest struct {
Intent string `json:"intent"`
}
func (s *Server) handleCoach(c *gin.Context) {
var req coachRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
return
}
s.respond(c, s.ctrl.RequestCoach(req.Intent))
}
```
`respond` already broadcasts on success and maps errors. `ErrNotPlanning` is a
plain (non-`IllegalTransitionError`) error, so it maps to
`http.StatusBadRequest` — acceptable, since the UI only shows Sharpen during
planning. The pending → ready/error progression reaches the browser entirely
over the existing SSE stream; the POST response itself is not relied upon for
the proposal.
### UI (`internal/web/static/index.html`)
The Planning view gains an intent box and a Sharpen button **above** the three
existing inputs:
```
[ Rough intent .......................... ] [ Sharpen ]
(coach status line: thinking… / error note)
Next action [ ........................ ]
Success condition[ ........................ ]
Minutes [ 25 ]
[ Start commitment ]
```
**Partial-update requirement.** Today `render()` replaces the planning view's
`innerHTML` on every SSE message. With a coach, SSE messages now arrive *while
the user is typing*, so a full rebuild would wipe their input and focus. The
fix:
- Track the currently rendered runtime state in a module variable
(e.g. `renderedState`).
- When an SSE message arrives and `rs === 'planning'` **and** the planning view
is already mounted, do **not** rebuild. Instead call an
`updatePlanningCoach(state.coach)` that only:
- updates the coach status line (pending → "thinking…", error → the message,
idle/absent → empty);
- when status is `ready` and the proposal has not yet been applied for this
generation, writes `proposal.next_action`, `proposal.success_condition`, and
`Math.round(proposal.timebox_secs / 60)` into the three inputs, then runs the
existing `check()` to enable Start. Pre-fill happens once per ready proposal
(guard with a flag) so it does not clobber subsequent manual edits on every
SSE tick.
- Only rebuild the planning structure when transitioning *into* planning from a
different state.
The Sharpen button POSTs `{ intent }` to `/coach` and shows the pending state
optimistically; the disabled/enabled logic for Start is unchanged. Other runtime
states (`locked`/`active`/`review`) keep their current full-rebuild render.
## Configuration
Backend selection is config-driven from day one:
- Env var `ANTIDRIFT_AI_BACKEND` selects the adapter: `claude` (default) or
`codex`. Unknown values are a startup error.
- `cmd/antidriftd/main.go` reads the env var, calls `ai.NewBackend(name)`, wraps
it in `ai.NewService(backend)`, and calls `ctrl.SetCoach(service)`. If
`NewBackend` errors, the daemon logs a warning and runs **without** a coach
(manual planning still works) rather than failing to start — graceful
degradation extends to misconfiguration.
## Error Handling and Degradation
Every failure surfaces as a non-blocking `status=error` in the coach view, never
as a broken Planning view:
| Failure | Result |
| ------- | ------ |
| No backend wired (`SetCoach` never called / nil) | `RequestCoach` sets `status=error`, "coach unavailable"; returns nil |
| CLI binary missing | `backend.Run` errors → goroutine sets `status=error` |
| CLI timeout (>60s) | `context` cancels child → error → `status=error` |
| Empty / non-JSON output | `extractJSON`/`parseProposal` error → `status=error` |
| Missing/empty fields, non-positive timebox | `parseProposal` error → `status=error` |
| Request issued outside planning | `RequestCoach` returns `ErrNotPlanning` → HTTP 400 |
Error messages shown to the UI are sanitized to a short human string; raw CLI
stderr is logged server-side, not surfaced to the browser.
## Package Layout Changes
| Package | Change |
| ------- | ------ |
| `ai` (new) | `Backend` interface; `claudeBackend`, `codexBackend`; `NewBackend`; `Coach` interface; `Proposal`; `Service`; prompt builder; `extractJSON`; `parseProposal`; sentinel errors; `fakeBackend` (test) |
| `session` | `coach` fields; `SetCoach`; `RequestCoach`; coach reset in `EnterPlanning` and leave-planning paths; `CoachView`/`ProposalView`; `Coach` field on `State`; `stateLocked` projection |
| `web` | `POST /coach` route + `handleCoach` + `coachRequest` |
| `web/static/index.html` | intent box + Sharpen button; `updatePlanningCoach`; partial-update guard in `render()` |
| `cmd/antidriftd` | read `ANTIDRIFT_AI_BACKEND`; build backend + service; `ctrl.SetCoach`; graceful fallback |
`ai` stays small and single-purpose, consistent with the token-efficiency design
constraint.
## Testing Strategy
**`ai` package:**
- `extractJSON`: bare object, object wrapped in prose, fenced code block, no JSON
(error), multiple objects (returns first balanced one).
- `parseProposal`: valid; missing `next_action`; empty `success_condition`;
`timebox_minutes` of 0 and negative; minutes→secs conversion.
- `Service.Coach` against a `fakeBackend` returning canned strings: success,
chatty-wrapped success, malformed → error.
- `claudeBackend`/`codexBackend`: argument construction is correct and the prompt
is routed to stdin (assert on the built `*exec.Cmd` `Args`/`Stdin` fields; do
not spawn the real CLI). For codex, assert the `-o <tmpfile>` flag is present
and that `Run` would read that path (factor the temp-file path out so it is
injectable/observable in the test).
- `NewBackend`: returns claude by default, codex by name, error on unknown.
**`session` package** (with a fake `ai.Coach`):
- `RequestCoach` in planning, fake returns a proposal: status goes
`pending` then `ready`; `State().Coach.Proposal` matches; `onChange` fires
twice.
- Fake returns an error: status goes `pending` then `error`.
- Nil coach: status `error` "coach unavailable"; `RequestCoach` returns nil.
- Wrong state (locked/active): `RequestCoach` returns `ErrNotPlanning`; no
goroutine, no state change.
- Stale generation: two `RequestCoach` calls; the first (slow) fake result is
discarded, only the second is projected. (Drive via a fake whose return is
gated on a channel so ordering is deterministic.)
- Leaving planning discards a pending/ready proposal: `Coach` is nil in `State`
once active.
- Snapshot has no coach fields (round-trip a snapshot, assert unaffected).
**`web` package** (with a fake `ai.Coach` wired into a real controller):
- `POST /coach` in planning returns 200 and the broadcast state shows
`status=pending` (or `ready` if the fake is synchronous).
- `POST /coach` outside planning returns 400.
- `POST /coach` with invalid JSON returns 400.
- Coach-unavailable controller: `POST /coach` returns 200, state shows
`status=error`.
All tests use fakes; **no test invokes the real `claude`/`codex` CLI**. Tests
must remain race-clean (`go test -race ./...`), consistent with M1.
## Definition of Done
- `ai` package with both adapters, `Coach`/`Service`, parsing, and tests.
- `RequestCoach` async flow with generation-guard and graceful degradation.
- `/coach` route and Planning-view Sharpen flow that pre-fills without clobbering
user input.
- `ANTIDRIFT_AI_BACKEND` wiring in the daemon with graceful fallback.
- `go test -race ./...` passes; manual smoke: type an intent, Sharpen, see the
three fields populate, edit, Start.
- README/roadmap note that M2 is complete (consistent with prior milestones).
@@ -1,362 +0,0 @@
# M3 — Drift Interceptor — Design
Date: 2026-05-31
## Purpose
M3 makes drift *visible and interruptive* while a commitment is Active. The
daemon watches the focused window (the M1 evidence stream) and, when the user
wanders off-task, surfaces a dismissible interrupt in the active view asking
them to refocus, justify ("this is on task"), or end the session.
It uses **cheap local matching first** and the **LLM only for the ambiguous
cases**, keeping the slow CLI off the common path. This adds the second live AI
role — `JudgeDrift` — at the **cortex** layer: it judges at a decision point the
state machine exposes (an active session observing a window), but it never forces
a transition. Enforcement (minimizing/blocking) remains deferred to M8; M3's
"friction" is UI-only.
The ambient **Nudge** role is deliberately **out of scope** here; it is the fast
follow-on (M3.5).
## Scope
**In scope (M3):**
- Local allowed-context matching ported from `legacy/src/context.rs` into the
`evidence` package (window-class + title-substring matching).
- The coach (`ai` Coach, from M2) extended to also propose **allowed window
classes**; the planning form shows them as an editable field.
- A new `DriftJudge` AI role behind a leaf-preserving interface; `Service`
implements it via the same CLI backends as M2.
- Live drift judgment wired into the `RecordWindow` hot path: debounced
(≤ 1 judgment / ~10s) and **cached per window-class**, run in a background
goroutine, surfaced over SSE.
- An override loop: "this is on task" appends the window-class to the session's
allowed-context so it matches locally thereafter.
- Active-view drift interrupt UI; `POST /refocus` and `POST /ontask` routes.
- Allowed-context persisted in the snapshot (survives restart).
**Out of scope:**
- The ambient `Nudge` role (M3.5).
- Domain/URL and command matching. `AllowedContext` keeps those fields, but the
X11 sensor only yields window class + title — no browser URLs or process args —
so M3 matches on class + title only.
- Any enforcement (window-minimize, blocking): that is M8.
- Persisting the drift *verdict*. See "Persistence" — only allowed-context is
durable; the verdict recomputes after restart.
## Architecture
M3 extends the ports-and-adapters shape established in M1/M2. The `ai` package
gains a second role (`DriftJudge`) but stays a **leaf package**; the `evidence`
package gains pure matching logic; `session.Controller` orchestrates the
debounced async judgment exactly as it orchestrates the M2 coach; the browser
renders over the existing SSE stream.
### The drift pipeline (per window observation, while Active)
```
window observation ──▶ local match against allowed-context?
matched ───┴─── not matched
│ │
on-task debounce + per-class cache
(clear drift) │
fresh ──┴── cached
│ │
JudgeDrift (bg) use cached verdict
verdict on_task / drifting
drift state ──▶ SSE ──▶ active-view interrupt
```
A local match short-circuits to **on-task** with no LLM call. This is
authoritative: a window in an allowed class is treated as on-task even if the
user is technically idling there. Only **unmatched** windows reach the judge.
### `evidence` — local matching (ported from Rust)
New `internal/evidence/context.go`, porting `legacy/src/context.rs`:
```go
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
// M3 uses class + title only; domains/commands are matched by the helpers but
// have no data source yet.
func MatchesAllowed(ctx domain.AllowedContext, class, title string) bool
```
with helpers `windowClassAllowed`, `windowTitleAllowed` (and `domainAllowed`,
`commandAllowed` ported for completeness/tests, unused on the live path):
- class: trimmed, casefolded, **exact** match against `WindowClasses`.
- title: trimmed, casefolded **substring** match against
`WindowTitleSubstrings`.
- domain: exact or subdomain (`docs.github.com` matches `github.com`),
trailing-dot/whitespace/case normalized.
- command: executable basename, casefolded.
`MatchesAllowed` returns true if class OR title matches. `evidence` may import
`domain` (for `AllowedContext`) — `domain` is a pure leaf, so no cycle.
### `ai``DriftJudge`, leaf-preserving
The M2 review established that `ai` imports nothing from the app. To keep that,
`JudgeDrift` takes **primitives**, not `domain`/`evidence` types — the controller
formats the commitment and window into strings before calling.
```go
// Verdict is the drift judge's call on a single window.
type Verdict struct {
OnTask bool
Reason string
}
// DriftJudge decides whether the current window is on-task for a commitment.
type DriftJudge interface {
JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
}
```
`Service` (from M2) also implements `DriftJudge`:
```go
func (s *Service) JudgeDrift(ctx context.Context, commitment, windowClass, windowTitle string) (Verdict, error)
```
It builds a strict-JSON prompt, calls `backend.Run`, and parses:
```json
{"on_task": false, "reason": "Reddit is unrelated to drafting the report"}
```
- `buildDriftPrompt(commitment, class, title)` — gives the model the commitment
description and the current window, asks for ONLY the JSON object above.
- `parseVerdict(s string) (Verdict, error)` — reuses `extractJSON`; unmarshals
`{on_task bool, reason string}`; trims `reason`. A missing/empty body or
unparseable output returns an error (sentinel `ErrInvalidVerdict`); the caller
degrades by leaving drift state unchanged.
### Coach extension (allowed window classes)
`ai.Proposal` gains a field; the coach prompt asks for it:
```go
type Proposal struct {
NextAction string
SuccessCondition string
TimeboxSecs int64
AllowedWindowClasses []string // NEW
}
```
Prompt JSON shape extends to:
`{"next_action":..., "success_condition":..., "timebox_minutes":..., "allowed_window_classes":["code","firefox"]}`.
`parseProposal` reads the new array (optional — absent/empty is valid; the user
can fill it in). `ProposalView` (session) gains `allowed_window_classes`.
### `session.Controller` — orchestration
New ephemeral + durable state on the controller:
```go
// durable (persisted): the active session's allowed classes, mutable via override
allowedClasses []string
// ephemeral drift machinery
judge ai.DriftJudge
driftStatus string // "idle" | "pending" | "ontask" | "drifting"
driftReason string
driftGen int
lastJudgedAt time.Time
judgedClasses map[string]ai.Verdict // per-class cache for this session
```
**Injection:** `SetDriftJudge(ai.DriftJudge)` (mirrors `SetCoach`). Nil judge ⇒
drift stays idle; local matching still runs (a matched window shows on-task).
**Hot-path hook** in `RecordWindow` (while holding the lock, after `applyEvent`,
only when Active):
1. Build `domain.AllowedContext{WindowClasses: c.allowedClasses}` and test
`MatchesAllowed(ac, class, title)`. On match ⇒ set `driftStatus=ontask`,
clear reason. Done (no LLM). (Only `WindowClasses` is populated in M3, since
the coach proposes classes; title substrings are matched by the helper but
left empty.)
2. Else consult per-class cache: if `judgedClasses[class]` exists ⇒ apply it
(`ontask`/`drifting` + reason). Done.
3. Else **debounce**: if `now.Sub(lastJudgedAt) < driftDebounce` (10s) ⇒ leave
current state. Done.
4. Else launch judgment: bump `driftGen`, capture `gen`, set
`lastJudgedAt=now`, `driftStatus=pending`, capture `judge` + commitment text
+ class/title, then (after unlocking, per the M2 pattern) run the judge in a
goroutine with a `driftTimeout` (30s) context.
**Goroutine completion** (re-acquire lock): discard if `gen != driftGen` or no
longer Active (stale — commitment ended/changed). Else cache
`judgedClasses[class]=verdict`; if `class` is still the current window's class,
set `driftStatus` to `ontask`/`drifting` + reason. Unlock, `notify()`.
All `notify()` calls fire with the mutex released — identical discipline to M2's
`RequestCoach` and the existing focus path.
**Override / dismiss:**
```go
// OnTask appends the current window class to the session allowed-context, clears
// drift, and persists. The class now matches locally and is never re-judged.
func (c *Controller) OnTask() error
// Refocus clears the current drift verdict without changing allowed-context.
// The same off-task class may be judged again later.
func (c *Controller) Refocus() error
```
Both return `ErrNotActive` outside the Active state. `OnTask` appends to
`allowedClasses`, drops any cached drifting verdict for that class, sets
`driftStatus=ontask`, and persists the snapshot.
**Commitment start:** `StartManualCommitment` gains an `allowedClasses []string`
parameter, stored on the controller and persisted. Drift caches/state reset when
a session starts and when it ends (`End`).
### State projection
`State` gains a drift view, projected **only while Active**:
```go
type DriftView struct {
Status string `json:"status"` // idle | pending | ontask | drifting
Reason string `json:"reason,omitempty"`
}
// added to State:
// Drift *DriftView `json:"drift,omitempty"`
```
`CommitmentView` is unchanged; `ProposalView` gains
`AllowedWindowClasses []string json:"allowed_window_classes,omitempty"`.
### Persistence
`store.Snapshot` gains `AllowedWindowClasses []string`. `persistLocked` writes
the controller's `allowedClasses`; `New` restores them when a live Active session
is rebuilt. **The drift verdict is NOT persisted** — on restart it recomputes
from the first post-restart window observation (≤ one debounce window). This
avoids showing a stale "drifting" interrupt for a window the user has already
navigated away from, at the cost of a brief idle state after restart. This is a
deliberate refinement of "persist session state across restart".
### `web` layer
- `commitmentRequest` gains `AllowedWindowClasses []string json:"allowed_window_classes"`;
`handleCommitment` passes them to `StartManualCommitment`.
- `POST /refocus``ctrl.Refocus()`; `POST /ontask``ctrl.OnTask()`. Both via
the existing `respond` helper (so `ErrNotActive` maps to 400, success
broadcasts).
### UI (`internal/web/static/index.html`)
**Planning view:** add an "Allowed apps" input (comma-separated window classes),
pre-filled from `coach.proposal.allowed_window_classes` when a proposal lands
(same one-time, non-clobbering pre-fill as M2). The Start commitment POST
includes the parsed list.
**Active view:** when `state.drift.status === 'drifting'`, render an interrupt
block above/around the timer:
```
⚠ Possible drift
<reason>
[ Back to task ] [ This is on task ] [ End session ]
```
- `Back to task``POST /refocus`
- `This is on task``POST /ontask`
- `End session` → the existing `POST /complete` (Active → Review), same as the
active view's current Complete button
`status === 'pending'` may show a subtle "checking…" hint; `ontask`/`idle` show
nothing. The active view already rebuilds on SSE ticks and runs a countdown
timer; the drift block must integrate without resetting the timer — apply the
same partial-update care used for the planning coach (update the drift region
without tearing down the countdown). The interrupt is **non-modal** (it cannot
lock the user out — enforcement is M8).
## Configuration
No new configuration. The drift judge reuses the M2 backend selected by
`ANTIDRIFT_AI_BACKEND`; the daemon wires the single `Service` into both
`SetCoach` and `SetDriftJudge`. Debounce (10s) and timeout (30s) are constants.
## Error Handling and Degradation
| Condition | Result |
| --------- | ------ |
| Nil judge (unwired/misconfig) | Local matching still runs; unmatched windows leave drift `idle` — never blocks |
| CLI failure / timeout / unparseable verdict | Judgment discarded; drift state unchanged (no false "drifting"); logged server-side |
| Judge slow, user changes window | Per-class cache + generation guard; stale results discarded |
| `/refocus` or `/ontask` outside Active | `ErrNotActive` → HTTP 400 |
Drift judgment failures **never** fabricate a drift verdict; the safe default is
"not drifting".
## Package Layout Changes
| Package | Change |
| ------- | ------ |
| `evidence` | New `context.go` (matching helpers + `MatchesAllowed`) + tests ported from `legacy/src/context.rs` |
| `ai` | `Verdict`, `DriftJudge`, `Service.JudgeDrift`, `buildDriftPrompt`, `parseVerdict`, `ErrInvalidVerdict`; `Proposal.AllowedWindowClasses` + coach prompt/parse updates |
| `session` | `allowedClasses` (persisted), drift machinery (judge, status, reason, gen, debounce, per-class cache); `SetDriftJudge`; `RecordWindow` hook; `OnTask`/`Refocus`; `StartManualCommitment` allowed-classes param; `DriftView` + `State.Drift`; `ProposalView.AllowedWindowClasses`; snapshot field |
| `store` | `Snapshot.AllowedWindowClasses` |
| `web` | `commitmentRequest.AllowedWindowClasses`; `POST /refocus`, `POST /ontask` |
| `web/static/index.html` | planning "allowed apps" field; active-view drift interrupt; partial-update care |
| `cmd/antidriftd` | `ctrl.SetDriftJudge(service)` alongside `SetCoach` |
## Testing Strategy
**`evidence`:** port the `context.rs` test table (class exact/casefold, title
substring, domain exact/subdomain/normalization, command basename) plus
`MatchesAllowed` (class-only match, title-only match, neither).
**`ai`:** `parseVerdict` (valid on_task true/false, chatty-wrapped, missing
fields → error); `Service.JudgeDrift` over a `fakeBackend`; `parseProposal` now
reads `allowed_window_classes` (present, absent, empty); arg/prompt building does
not regress. No real CLI.
**`session`** (with a fake `DriftJudge`):
- Local match ⇒ `ontask`, judge never called.
- Unmatched ⇒ `pending` then `drifting`/`ontask` per the fake's verdict.
- Per-class cache: second observation of a judged class does not call the judge
again.
- Debounce: rapid unmatched observations within 10s trigger at most one judge
call (drive `clock` via the existing `SetClock`).
- Stale generation: slow judge result discarded after the session ends / a new
one starts (gate the fake on a channel, as in the M2 coach test).
- `OnTask` appends the class, clears drift, and a subsequent observation of that
class matches locally (no judge call); persisted across reload.
- `Refocus` clears drift without mutating allowed-context.
- Restart restores `allowedClasses` from the snapshot; drift starts `idle`.
- Nil judge: unmatched window leaves drift `idle`, no panic.
**`web`:** `/refocus` and `/ontask` happy paths + outside-Active 400; commitment
request carries allowed classes into the controller.
All tests use fakes; **no test spawns a real CLI**. `go test -race ./...` stays
clean.
## Definition of Done
- `evidence` matching ported with tests.
- `ai` `DriftJudge` + coach allowed-classes extension, with tests.
- Controller drift pipeline (local-first, debounced, cached, async) with
override/dismiss and persistence, race-clean.
- `/refocus`, `/ontask` routes; planning allowed-apps field; active-view drift
interrupt that doesn't disrupt the timer.
- Daemon wires `SetDriftJudge`.
- `go test -race ./...` and `go vet ./...` pass; manual smoke: start a session
with an allowed class, switch to an unrelated app, see the interrupt, exercise
both buttons.
- README/roadmap note M3 complete.
@@ -0,0 +1,246 @@
# Windows 11 Support — Design
**Date:** 2026-06-02
**Status:** Approved, ready for implementation planning
**Scope:** Full feature parity on Windows 11 — both OS ports (active-window
sensing and window-minimize enforcement) — implemented as `//go:build windows`
adapters behind the existing interfaces. No consumer code changes.
## Problem
AntiDrift's value is making drift visible by watching the active window and, on
confirmed off-task drift, minimizing it. Both capabilities are X11-only, gated
behind `//go:build linux`. On any non-Linux target the build links the
`//go:build !linux` no-op fallbacks, so the daemon starts and serves its UI but
the entire focus loop is dead: no evidence, no drift detection, no nudges, no
enforcement.
Today's Windows behavior, verified:
- `GOOS=windows GOARCH=amd64 go build ./cmd/antidriftd` compiles cleanly (no
cgo).
- The daemon would launch and open a browser (`cmd/antidriftd/main.go` already
handles the `windows` case via `rundll32`).
- `evidence.NewSource()` returns a no-op reporting "no active-window sensor on
this platform" (`internal/evidence/source_other.go`).
- `enforce.NewGuard()` returns a no-op whose `MinimizeActive` does nothing
(`internal/enforce/guard_other.go`).
This design fills both ports on Windows so AntiDrift functions end-to-end.
## Goals
- A real `evidence.Source` on Windows: emit `WindowSnapshot{Title, Class,
Health}` on foreground-window and title changes.
- A real `enforce.Guard` on Windows: minimize the foreground window on demand.
- Pure Go, no cgo. Cross-compilation from Linux stays clean.
- No changes to `session`, `web`, `domain`, or `cmd` — the work lives entirely
behind the two existing ports.
## Non-goals (YAGNI)
- Event-driven sensing via `SetWinEventHook` (see Approach B, rejected below).
The `Source` interface hides the polling-vs-event choice; B can replace A
later with zero consumer impact if latency ever matters.
- Windows ARM64.
- Installer, system tray, packaging, autostart.
- Any session-policy or web/UI changes.
## Constraints that shaped this design
- **Verification is compile-only for now.** Development is on Manjaro Linux with
no Windows 11 machine available. The design therefore minimizes untested
syscall surface: no callbacks, no Win32 message loop, no OS-thread affinity.
Live runtime verification is deferred (see Testing).
## Port contracts being satisfied
From `internal/evidence/evidence.go`:
```go
type WindowSnapshot struct {
Title string // full window title
Class string // app identity, matched case-folded against allowed classes
Health EvidenceHealth
}
type Source interface {
// Watch runs until ctx is cancelled, invoking onChange on every
// active-window change, and once immediately with the current window.
Watch(ctx context.Context, onChange func(WindowSnapshot))
}
```
From `internal/enforce/enforce.go`:
```go
type Guard interface {
// MinimizeActive minimizes the currently-focused window. Idempotent and
// best-effort: returns an error for diagnostics; callers never block on it.
MinimizeActive(ctx context.Context) error
}
```
The `Class` field is the authoritative on-task signal. `MatchesAllowed`
(`internal/evidence/context.go`) compares it **case-folded, exact match**
against the session's allowed window classes (or matches a title substring).
## Architecture
All new code is behind `//go:build windows`. No consumer changes — `session`,
`web`, and `cmd/antidriftd` already call `evidence.NewSource()` /
`enforce.NewGuard()` and degrade on the health flags.
### New files
- `internal/winapi/winapi.go` (`//go:build windows`) — a small shared binding
layer over the Win32 calls, so the two adapters don't each redeclare the same
procs.
- `internal/evidence/windows.go` (`//go:build windows`) — the polling `Source`.
- `internal/enforce/windows.go` (`//go:build windows`) — the `ShowWindow`
`Guard`.
### Build-tag edit (correctness-critical)
The fallbacks are currently tagged `//go:build !linux`, which is what compiles
on Windows today. Once `windows.go` files exist in those packages, the fallbacks
must exclude Windows too, or the build gets duplicate `NewSource`/`NewGuard`
symbols:
- `internal/evidence/source_other.go`: `//go:build !linux`
`//go:build !linux && !windows`
- `internal/enforce/guard_other.go`: `//go:build !linux`
`//go:build !linux && !windows`
This yields three mutually exclusive worlds per port: `linux` (X11), `windows`
(new), everything-else (no-op). macOS and any other GOOS remain no-ops, exactly
as today.
### Dependencies
`golang.org/x/sys` is already in `go.mod` (indirect, v0.41.0). `go mod tidy`
promotes it to a direct require. No new module, no cgo.
## Component design
### `internal/winapi` — shared Win32 binding
Most of the needed calls are already typed wrappers in `x/sys/windows` (verified
under `GOOS=windows go doc`), so the hand-written binding is deliberately tiny.
Provided by `golang.org/x/sys/windows`, used directly — no LazyDLL needed:
- `GetForegroundWindow() HWND`
- `GetWindowThreadProcessId(hwnd HWND, *uint32) (tid uint32, err error)` → owning PID
- `OpenProcess`, `QueryFullProcessImageName`, `CloseHandle` → process image path
- the `windows.HWND` type and the `windows.SW_MINIMIZE` (= 6) constant
Not wrapped by `x/sys/windows`; loaded once via
`windows.NewLazySystemDLL("user32.dll")` and called through `proc.Call`:
- `GetWindowTextW(hwnd, *uint16, max int32) int32` → title
- `ShowWindow(hwnd, SW_MINIMIZE) bool`
Public surface (two helpers the adapters consume):
- `ForegroundWindow() (hwnd uintptr, title, class string, ok bool)` — resolves
the foreground window's title and process-exe-base `class` in one call. `ok`
is false when there is no foreground window (null hwnd) — e.g. secure desktop,
UAC prompt, lock screen.
- `MinimizeForeground() error` — minimizes the current foreground window;
returns nil when nothing is focused.
`class` is derived from the owning process image path
(`QueryFullProcessImageName`) as the **base name minus the `.exe` extension,
lowercased**: `C:\Program Files\Microsoft VS Code\Code.exe``code`. This is
the closest analog to X11 `WM_CLASS` conventions and keeps allowed-class lists
readable. Path parsing is a pure function (see Testing).
### `evidence.Source` (Windows) — polling
`Watch(ctx, onChange)`:
1. Emit one snapshot immediately (honors the "once immediately" contract).
2. `time.NewTicker(750 * time.Millisecond)`.
3. On each tick, call `winapi.ForegroundWindow()` and track the last
`(hwnd, title)`. Invoke `onChange` **only when `hwnd` or `title` changed**
a steady window stays silent, and a same-window title change (e.g. a browser
tab switch) still fires. This reproduces the fidelity the X11 source gets
from `_NET_ACTIVE_WINDOW` plus name property notifies.
4. Map a successful read to
`WindowSnapshot{Title: title, Class: class, Health: {Available: true}}`.
5. Map `ok == false` to
`WindowSnapshot{Health: {Available: false, Reason: "no foreground window"}}`.
Recovers automatically on the next tick.
6. On `ctx.Done()`, stop the ticker and return. Nothing else to tear down.
Rationale for polling over `SetWinEventHook`: under compile-only verification,
the responsible choice is the design with no callback trampoline
(`syscall.NewCallback`), no `GetMessage`/`DispatchMessage` loop, and no
`runtime.LockOSThread` — three failure modes that are hard to validate without a
Windows machine. ~1s switch latency is irrelevant for a focus tracker, and
polling also catches in-window title changes for free.
### `enforce.Guard` (Windows)
`MinimizeActive(ctx)`:
1. Read the foreground window. If null, return nil (nothing focused — same as
the X11 `active == 0` case).
2. Otherwise `ShowWindow(hwnd, SW_MINIMIZE)`; on failure return a wrapped error.
Per-call and stateless, mirroring the short-lived X11 connection model. Errors
are for the caller to log; the caller never blocks on enforcement.
## Error handling and degradation
The existing contracts are unchanged; this design simply honors them on Windows.
- Sensor can't read a foreground window → `Available: false` snapshot with a
reason. The daemon already treats this as "no evidence this tick" and surfaces
the reason in the live view. Auto-recovers on the next tick.
- `ShowWindow` fails → error returned and logged upstream; enforcement is
best-effort, treated as "did nothing this time." No panic, no block.
- No new failure types reach `session`/`web`: they still see only a
`WindowSnapshot` and a possible `MinimizeActive` error, exactly as with X11.
## Known cross-platform nuance (not a bug)
The same application can produce a different `Class` on Linux vs Windows (X11
`WM_CLASS` vs Windows exe base name; e.g. Chrome may be `google-chrome` on Linux
and `chrome` on Windows). Allowed-class lists are per-session and case-folded,
so this only affects portability of class names *across machines*, never
matching within a single session. Documented, not engineered around.
## Testing and verification
Bounded honestly by the compile-only constraint.
1. **Cross-compilation gate (primary automated guarantee).**
`GOOS=windows GOARCH=amd64 go build ./...` must pass. Also build
`GOOS=darwin` to prove the `!linux && !windows` tag edit still selects the
no-op fallback (i.e. the everything-else world is intact).
2. **Pure-logic unit tests, no build tag (run on Linux).** The syscall-free
logic is extracted into pure functions and tested directly:
- exe-path → class normalization: `Code.exe``code`, strip `.exe`,
lowercase, handle no-extension names and UNC/odd separators.
- change-detection predicate: emit on `(hwnd, title)` change, stay silent
otherwise.
3. **No fabricated syscall mocks.** We will not write a fake that pretends to
exercise `user32` — that produces false confidence. Live verification is
deferred until a Windows 11 machine is available.
4. **`x11_integration_test.go` files stay `//go:build linux`**, untouched.
### Deferred manual verification (when a Windows 11 machine is available)
- Run `antidriftd`; confirm the browser opens and the live view shows the
current window title/class.
- Switch windows and change a browser tab; confirm snapshots update and the
health reads available.
- Start a commitment with "Enforce focus" armed; focus an off-task window;
confirm the drift judge fires and the window minimizes.
## Out of scope (restated)
`SetWinEventHook`, Windows ARM64, tray/installer/packaging/autostart, and any
session/web/domain changes. This is pure adapter work behind two existing ports.
@@ -0,0 +1,302 @@
# Controller Refactor — Harness + Modes — Design
**Date:** 2026-06-04
**Status:** Approved, ready for implementation planning
**Scope:** Rewrite the focus-only session controller into a generic
**harness** that hosts swappable **modes**. Focus becomes the first mode
(behavior-preserving extraction); **off-screen mode** becomes the second
(new, the proof the abstraction generalizes). The `antidrift``keel` code
rename and `~/.antidrift/``~/.keel/` runtime move land in the same pass,
clean-slate (no state migration).
## Problem
`session.Controller` (≈1,400 LOC across `session.go`, `roles.go`, `drift.go`,
`stats.go`, `views.go`) is a god-object. It conflates three things:
1. **Generic runtime orchestration** — holding the ports, the async
brain-call primitive (`runFetchAsync`), persistence, change-notify, the
evidence ingestion callback, the clock.
2. **The focus state machine**`locked→planning→active→transition→review`,
commitment lifecycle, timebox/deadline, drift/nudge/enforcement, evidence
stats, reflection, session sealing.
3. **The focus domain payload**`domain.Commitment`
(`next_action / success_condition / timebox`), sitting in a package named
`domain` as if it were generic.
Focus mode is not *a* mode — it **is** the controller. There is no seam to add
a second mode. Keel's thesis (the harness is the product; focus is one mode
among many) cannot be expressed until (1) is separated from (2)+(3).
The directory and git repo are already `keel`; only the code identity still
says `antidrift`. AntiDrift was never in production and its `~/.antidrift/`
ledger is disposable, so the rename and runtime move can be done outright with
no migration ceremony.
## Goals
- A generic `internal/harness` host that owns shared services and exactly one
active mode, and routes I/O to it.
- A small `Mode` contract that holds both a long-lived stateful mode (focus)
and a one-shot request/response mode (off-screen) without crippling either.
- Focus re-homed as `mode/focus` with **identical behavior** — existing focus
tests are the regression oracle.
- Off-screen mode built end-to-end: collect → assemble → brain → surface →
gated Marvin write.
- The `antidrift``keel` rename complete in code and runtime, clean-slate.
## Non-goals (YAGNI)
- **AW-bucket memory** (the §7 "remember across runs" layer). Off-screen v1 is
stateless. AW memory is the immediate fast-follow, its own spec.
- **Concurrent / ambient modes.** Exactly one active mode at a time
(idle ↔ focus *or* off-screen). Matches today's single-commitment reality.
- **Any third mode.**
- **State migration** from `~/.antidrift/`. Clean slate by decision.
- Renaming the off-screen mode's working code name (`offscreen`) — placeholder,
rename freely later.
## Decisions that shaped this design
- **One thin `Mode` interface + capability interfaces**, not a stateless
collect/assemble/effector pipeline framework (Procrustean for focus's
stateful machine, and a YAGNI framework for two modes), and not two formal
mode *kinds* (fragments the concept before the seams are felt). Capability
interfaces (`EvidenceConsumer`, `Expirer`) give the heterogeneity honesty
without the taxonomy. If a third one-shot mode appears, off-screen's flow can
be promoted into a real pipeline helper then — driven by evidence.
- **Generalize the web routes** (`/mode/{kind}/start`, `/mode/command/{name}`)
rather than keep focus's bespoke endpoints. The frontend is rewritten under
the state envelope regardless.
- **`Locked` collapses into harness idle.** "No active mode" is the resting
state; focus is `Planning→Active→Review→done`. One fewer state.
- **Off-screen reuses the existing single-file `knowledge.Source`** for the
`~/owc/goals-2026.md` frame, plus a small in-package glob read
(`filepath.Glob` + read) for the curated life-bug set. `knowledge.Source.Load`
is single-path by contract; no new adapter package — smallest real slice.
- **The confirm click is the effector gate.** No auto-write in v1.
## Package layout
```
cmd/keeld/main.go # wires harness + modes + surfaces + settings
internal/harness/
harness.go # Harness: services, the one active mode, change-notify, store root
services.go # Services handle passed to modes
async.go # generation-guarded async primitive (was runFetchAsync)
internal/mode/
mode.go # Mode interface, capability interfaces, Envelope
internal/mode/focus/ # today's session/* + domain/* + statemachine/* re-homed
focus.go statemachine.go drift.go stats.go views.go roles.go ...
internal/mode/offscreen/
offscreen.go # NEW: off-screen mode
internal/ai/ tasks/ knowledge/ evidence/ enforce/ # shared ports — only module path changes
internal/store/ # persistence primitives + per-mode namespacing
internal/settings/ statusfile/ web/ # web becomes mode-aware
```
`internal/domain` dissolves into `mode/focus`: `Commitment`, `RuntimeState`,
`EnforcementLevel`, `AllowedContext`, `PolicySnapshot` are all focus concepts.
`internal/statemachine` likewise moves under `mode/focus` (its states are the
focus lifecycle). Off-screen touches none of them.
## The Mode contract
```go
package mode
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
Kind() string // "focus", "offscreen"
Command(ctx context.Context, name string, payload json.RawMessage) error
View() any // JSON-marshalable projection
Active() bool // false ⇒ harness returns to idle
}
// Optional capabilities — the harness asserts for these.
type EvidenceConsumer interface { OnWindow(evidence.WindowSnapshot) } // focus only
type Expirer interface { Deadline() time.Time; Expire(context.Context) error } // focus only
```
`Command` returns an error for illegal/invalid commands; the web layer maps it
to HTTP (today's `IllegalTransitionError`→409 mapping moves with focus). `View`
returns the mode's own shape, wrapped by the harness in the envelope below.
## The Harness + Services
```go
type Harness struct {
mu sync.Mutex
active mode.Mode // nil = idle
factories map[string]func(Services) mode.Mode // "focus", "offscreen"
services Services
onChange []func()
}
func (h *Harness) Start(kind string) error // idle → mode (single-active invariant)
func (h *Harness) Command(ctx, name, payload) error // routes to active; cleans up active→done
func (h *Harness) State() mode.Envelope
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) // forwards iff active is EvidenceConsumer
func (h *Harness) Deadline() time.Time // delegates iff active is Expirer
func (h *Harness) Expire(ctx) error // delegates iff active is Expirer
```
After any `Command`/`Start`, if `active.Active()` is false the harness clears
`active` to nil (idle). `Start` on a non-idle harness is an error unless the
requested kind is already active.
`Services` is what a mode receives — the ports plus relocated infrastructure:
```go
type Services struct {
AI *ai.Service // coach/judge/nudge/reviewer + new proposer role
Tasks tasks.Provider // read (Today) + new write (Create)
Knowledge knowledge.Source
Enforce enforce.Guard
Clock func() time.Time
Store *store.Scope // mode-namespaced (~/.keel/modes/<kind>/)
Notify func() // fires harness change-listeners (SSE + status bar)
}
```
### The async primitive
`runFetchAsync` (today `session/roles.go:99`) moves to `harness/async.go` as a
helper parameterized by **the mode's own mutex** plus `Services.Notify`:
```go
type Async struct { mu *sync.Mutex; notify func() }
func (a Async) Run(timeout time.Duration, fetch func(context.Context), stale func() bool, apply func())
```
This preserves today's exact lock discipline: `fetch` runs with no lock held;
`stale`/`apply` run under the mode's mutex; `notify` fires once, after unlock.
Each mode constructs one `Async{mu: &m.mu, notify: svc.Notify}`. Generation
counters and status enums stay inside each mode, exactly as today.
## Focus mode (behavior-preserving extraction)
`session.Controller` minus the host plumbing becomes `focus.Mode`. The state
machine, drift/nudge/enforcement, evidence stats, reflection, and the planning
coach/tasks/knowledge fetches move verbatim. Focus implements `Mode`,
`EvidenceConsumer` (today's `RecordWindow`), and `Expirer` (today's
deadline/`Expire`). Its `View()` returns today's `session.State` shape,
unchanged, so the existing focus screens render identically.
Command mapping (today's routes → focus `Command` names): `planning`, `coach`,
`commitment`, `complete`, `end`, `refocus`, `ontask`.
`Locked` is removed: harness idle replaces it. `Planning→Active→Review→done`,
then the harness returns to idle. `admin_override` stays inside focus (an
enforcement-escape concern). **Existing focus tests move with the package and
must pass unchanged** — they are the proof the extraction preserved behavior.
## Off-screen mode (the proof)
The away-from-the-desk complement to focus. A one-shot request/response
lifecycle — this is what proves the abstraction holds for a mode that is *not*
a long-lived session.
```
start → assembling → proposed → (confirm | dismiss) → done
```
- **`Command "start"`** — collect + assemble + brain via the async primitive;
view status `pending`.
- **Collect (real frame elements):**
- `tasks.Provider.Today()` — life / non-work items.
- `~/owc/goals-2026.md` (Body/Spirit, People, Via Negativa) via the existing
single-file `knowledge.Source`.
- `~/owc` life-domain bugs via a small in-package glob read:
`physical-reset`, `workout-effort`, `nutrition-effort`,
`missing-social-life`, `make-environment-beautiful`, `marriage-strategy`.
- **Brain:** a new thin `ai.Proposer` role —
`Propose(ctx, brief) → Proposal{NextAction, Rationale}` — reusing
`ai.Backend`. A sibling to `Coach`, not a bend of it.
- **Surface:** phone-friendly proposal card (off-screen ⇒ you're not at the
desk; the phone surface earns its keep here).
- **`Command "confirm"` (gated effector):** a **new**
`tasks.Provider.Create(ctx, Task)` files the proposed task in Marvin,
preserving the `fieldUpdates` CRDT on write. The confirm click *is* the gate.
- **`Command "dismiss"`:** done, nothing written.
- **Memory:** none in v1 (AW buckets deferred).
Off-screen implements only `Mode` (no evidence stream, no timebox), so it
exercises the capability-assertion path: the harness forwards neither window
events nor an expiry timer to it.
## Surfacing
```go
package mode
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"` // active mode's View()
}
```
Web routes generalize:
- `POST /mode/{kind}/start``harness.Start`
- `POST /mode/command/{name}``harness.Command` (request body = payload)
- `GET /events` → SSE of the envelope (broadcaster mechanism unchanged)
- The server-owned expiry timer arms from `harness.Deadline()` and fires
`harness.Expire()` via the `Expirer` assertion (today's `web.armExpiry`
logic, retargeted).
`app.js` switches on `active_mode`: `""` → launcher (pick focus / off-screen);
`"focus"` → today's screens, fed by `.mode`; `"offscreen"` → the proposal card.
The status-file writer renders one line per active mode (focus's drift line,
off-screen's proposal summary, or `idle`). **This is the largest single chunk
of new work**: the focus UI is reshaped under the envelope and a small
off-screen view is added.
## Persistence (clean slate)
- Root `~/.keel/`; settings `~/.keel/settings.json`; status `~/.keel_status`.
- Per-mode namespace via `store.Scope`:
`~/.keel/modes/focus/{state.json, audit.jsonl, sessions/}`.
- Off-screen v1 persists nothing (ephemeral; restart = idle).
- **No migration** from `~/.antidrift/`.
## The rename (lands in this pass)
In-repo:
- `go.mod` module `antidrift``keel`; all import paths.
- `cmd/antidriftd``cmd/keeld`; binary `keeld`; log strings.
- `store`/`settings`/`statusfile` default paths → `~/.keel*`.
- `settings.SeedFromEnv` `ANTIDRIFT_*``KEEL_*`.
- `CLAUDE.md` naming-status section (rename now *done*).
- `keel-architecture.md`: §1/§8 (rename resolved), §5/§10 (remove the phantom
"house mode"; describe off-screen mode with the real collectors above).
External — Felix updates by hand (out of repo, listed in the plan):
- WM status-bar config reading `~/.antidrift_status``~/.keel_status`.
- Any systemd unit / shell alias / launcher invoking `antidriftd``keeld`.
## Testing
- **Focus:** existing tests relocate with the package (import paths only) and
must pass unchanged — the regression oracle for the extraction.
- **Harness:** new unit tests with a fake mode — single-active invariant,
command routing, capability assertion (window events only to
`EvidenceConsumer`s; expiry only to `Expirer`s), idle↔active transitions,
`Active()==false` cleanup to idle.
- **Off-screen:** new unit tests with fake `ai`/`tasks` — collect→propose then
`confirm` files exactly one task (CRDT-preserving); `dismiss` writes nothing;
the gate holds.
- **Web:** envelope routing test (idle / focus / off-screen shapes).
## Open question carried into the plan
Build order. Recommended: (1) rename in place (mechanical, keeps tests green),
(2) extract harness + `Mode` with focus as the only mode (tests green proves
the extraction), (3) add off-screen mode, (4) reshape the frontend under the
envelope. Each step leaves the daemon runnable.
@@ -0,0 +1,298 @@
# Ambient Drift Coach — Design
**Date:** 2026-06-05
**Status:** Approved, ready for implementation planning
**Scope:** Give Keel an always-on presence. Today Keel only coaches *inside* a
declared focus session; the moment you are just working, the harness goes idle,
drops every window snapshot, and the status bar reads `idle`. This slice adds an
**ambient drift coach**: a sentinel that watches the window stream continuously
(even when no mode is active), judges your recent activity against the broad
`~/owc` frame (goals, values, life-domains) plus today's Marvin tasks, and —
when you have clearly slid off what matters — surfaces a single coaching line to
the status bar, a `notify-send` toast, and a web-UI banner you can act on.
This builds the **Control loop / scheduler** component from
`docs/keel-architecture.md` §3, which today has zero implementation, and is the
first thing that makes Keel *show up unprompted* (SOUL: "when my partner drifts,
I name it").
## Problem
The focus mode is already a capable proactive coach — local on-task matching, an
LLM drift judge, a semantic "nudge" over recent window titles, surfaced to the
status bar and web UI, all sensibly debounced. But every bit of it hangs off a
**declared `Commitment`**. You must open the UI and start a session first. With
no session:
- `harness.RecordWindow` finds no active mode and silently drops the snapshot.
- `statusfile.Render` of an empty envelope returns `idle`.
- No brain call ever happens; Keel is blind and mute.
So the common case — Felix working without ceremonially declaring a task — gets
nothing. The fix is an observer that runs regardless of the active mode and is
grounded in the standing frame instead of a per-session commitment.
## Approach (and the one real fork)
The ambient coach is **not a mode**. It is an always-on **sentinel** that runs
*beside* the one-mode harness. Two harness invariants make "ambient as a mode"
the wrong shape:
1. The harness hosts exactly one mode and drops evidence when idle — an ambient
watcher must run continuously, including when idle.
2. `harness.Start` returns `ErrBusy` if a different mode is active — an ambient
mode would either block you from starting focus, or need a special
auto-yield carve-out that erodes the single-mode contract.
A sentinel sidesteps both and matches §3, where the control loop is a distinct
component beside the harness. **Coexistence rule:** when any harness mode is
active (`h.State().ActiveMode != ""`), the sentinel stays silent — focus mode
already owns coaching during a declared session, so there is never double
coaching.
## Components
Each unit has one responsibility, a defined interface, and is testable alone.
### 1. `internal/notify` — notification effector (new)
The OS-level "interrupt me" primitive, mirroring `enforce.Guard`.
- **Interface:** `Notifier interface { Notify(ctx context.Context, title, body string) error }`
- **Linux adapter:** shells `notify-send <title> <body>`. Best-effort: returns
an error for diagnostics; callers never block on it.
- **Constructor:** `NewNotifier() Notifier` returns the real adapter when the
`notify-send` binary is found on `PATH` (via `exec.LookPath`), else a nop. On
non-Linux it is a nop. This means a missing binary degrades to "no toast,"
never a failure.
- **Nop:** `Notify` returns nil, does nothing.
- **Depends on:** `os/exec` only. Leaf package.
### 2. `internal/frame` — shared frame assembler (new; DRY extraction)
The "what matters to Felix" brief, today duplicated inside off-screen's
`collect.go`. Extracting it removes the duplication and gives the sentinel the
same grounding off-screen uses.
- **Interface:** `Assemble(ctx context.Context, know knowledge.Source, tasks tasks.Provider) string`
returns a bounded markdown brief with three sections: **Today's tasks**
(from `tasks.Today`), **Goals** (`~/owc/goals-2026.md` via `knowledge.Load`),
and **Life domains** (every `~/owc/resources/bug-*.md` via `knowledge.Load`).
- **Best-effort:** never panics, never errors; tolerates nil ports and missing
files, degrading to a short, mostly-empty brief. Same truncation cap
(`maxBriefBytes`) and rune-safe clipping off-screen uses today.
- **Refactor:** off-screen's `briefTasks` / `briefGoals` / `briefLifeDomains`
are replaced by a call to `frame.Assemble`; off-screen keeps its own
proposal-history section (`briefHistory`) prepended around it. Off-screen's
existing tests must stay green.
- **Depends on:** `knowledge`, `tasks`. Imports nothing from `ai`/`mode`.
### 3. `internal/ai``AmbientDrift` method (new; mirrors `Nudge`)
The brain call grounded in the frame, not a commitment.
- **Method:** `func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)`
returns `""` when the activity plausibly serves the frame (on-track), and a
one-sentence coaching line when it is a slide.
- **Prompt:** an "ambient coach" prompt stating the user has **not** declared a
task; it is handed the frame and the recent window-title sequence and asked
whether the activity serves **any** of what matters, **forgivingly**
legitimate breaks, rest, and unplanned-but-useful work are on-track. Output is
`ONLY` JSON: `{"drifting": <bool>, "message": "<one sentence; REQUIRED when drifting>"}`.
- **Parse (`parseAmbientDrift`):** reuses `extractJSON` and the shared
`ErrEmptyResponse` / `ErrNoJSON` sentinels. `drifting:false``""`.
`drifting:true` with a message → the message. `drifting:true` with no message →
`""` (silence) — the ambient signal is advisory, so the safe degenerate is to
say nothing, exactly like `parseNudge`.
- **Depends on:** the existing `Backend`. `ai` stays a leaf (primitives only).
### 4. `internal/ambient` — the Sentinel (new; the meat)
Always-on observer: consume evidence → tick on cadence → judge → surface.
**Construction:** `New(deps)` where deps carry `*ai.Service`, `knowledge.Source`,
`tasks.Provider`, `notify.Notifier`, `memory.Store`, `clock func() time.Time`,
and `activeMode func() string` (reads `h.State().ActiveMode` for the coexistence
rule). Config: `cadence time.Duration` and `mode` (`off` | `status` | `notify`).
**State (under one mutex):** `recentTitles []string` (consecutive-dedup ring,
capped like focus's `recentTitlesMax`), `lastWindow evidence.WindowSnapshot`,
`line string` (current drift line; `""` when on-track), `lastEvalTitles`
(snapshot of the ring at the last brain call), `wasDrifting bool`,
`episodeNotified bool`, `snoozeUntil time.Time`, `gen int`, and `onChange []func()`.
**Methods:**
- `OnWindow(snap evidence.WindowSnapshot)` — called from the evidence fan-out.
Updates `lastWindow`, appends the title to the ring (skip consecutive dup,
cap). Cheap and lock-bounded; fires no brain call.
- `Run(ctx context.Context)` — cadence loop: a ticker every `cadence` calls
`evaluate()`; returns on ctx cancel.
- `evaluate()` — the decision (see Trigger discipline below).
- `Line() string` — current ambient line for the surfaces (`""` when on-track,
quiet, or snoozed).
- `Snooze(d time.Duration)` — sets `snoozeUntil = clock()+d`; clears the line and
episode flags; fires `onChange`. While snoozed, `evaluate()` returns early.
- `SetConfig(cadence time.Duration, mode string)` — live settings update.
- `AddOnChange(f func())` — register a surface refresh (status wake, web
broadcast). Fired whenever the line changes.
**Depends on:** `ai`, `frame`, `notify`, `memory`, `evidence`, and a
`activeMode` closure. It does **not** import `harness` (closure injection keeps
it decoupled and testable).
### 5. Wiring — `cmd/keeld/main.go`
- **Build** the notifier (`notify.NewNotifier()`) and the sentinel from settings
(cadence, mode) and the shared ports (the same `ai.Service`, `knowledge`,
`tasks`, `memory`, clock used to build `harness.Services`), with
`activeMode: func() string { return h.State().ActiveMode }`.
- **Fan the evidence stream to both** the harness and the sentinel:
```go
go src.Watch(ctx, func(w evidence.WindowSnapshot) {
h.RecordWindow(w)
sentinel.OnWindow(w)
})
```
- **Run** the sentinel: `go sentinel.Run(ctx)`.
- **Surface refresh:** register both the status writer's `Wake` and the web
broadcast with `sentinel.AddOnChange(...)`, so a new ambient line re-renders
the bar and pushes over SSE promptly.
- **Live settings:** `applyFn` (the existing POST `/settings` re-wire) also calls
`sentinel.SetConfig(...)` from the new settings, so the dial takes effect
without a restart.
## Trigger discipline (aggressive, but not trigger-happy)
A `notify-send` toast on a false positive is far more irritating than a stale
status line, so `evaluate()` is conservative and brain-call-thrifty:
1. If `mode == off` → return.
2. If snoozed (`clock() < snoozeUntil`) → return.
3. If `activeMode() != ""` → a mode is coaching; clear the line (if set), fire
`onChange`, return. (Coexistence rule.)
4. **Cheap gate:** if the ring is unchanged since `lastEvalTitles` **and** we are
currently on-track (`line == ""`) → skip the brain call. This naturally
pauses calls during steady deep work *and* while AFK (no new titles arrive in
either case), while still re-checking an unresolved drift.
5. Assemble the frame (`frame.Assemble`) and call
`ai.AmbientDrift(ctx, frame, recentTitles)` under a timeout.
6. Apply the result:
- **On-track (`""`):** if a line was set, clear it and reset
`wasDrifting=false`, `episodeNotified=false`; fire `onChange`. Otherwise no-op.
- **Drifting (`msg`):**
- **New episode** (`!wasDrifting`): set `line=msg`, `wasDrifting=true`,
`episodeNotified=false`; fire `onChange`. The **status bar / banner show
immediately**, but **no toast yet**.
- **Sustained** (already `wasDrifting` on this consecutive eval): record an
`ambient_nudge` event to `memory` (`{ "message": msg, "title":
lastWindow.Title, "class": lastWindow.Class }`), and — if `mode == notify`
and `!episodeNotified` — fire exactly one `notify-send` toast and set
`episodeNotified=true`. Further sustained ticks do not re-toast.
- On a brain **error**, leave the prior line intact and log — never fabricate
drift (same discipline as `Nudge`).
7. Update `lastEvalTitles` to the current ring.
Net effect with the default ~5-minute cadence: a 40-second glance at Twitter
never pops a toast (it resolves before the second tick); a sustained ~10-minute
slide shows on the bar at the first tick and pops one toast at the second.
Memory records only *sustained* drifts, independent of the surface mode.
## Surfaces
- **Status bar (`internal/statusfile`):** `Render` gains the current ambient line
(passed in by the `Writer`, which reads `sentinel.Line`). When the envelope is
idle (`ActiveMode == ""`): a non-empty line renders `⚠ <line>`; an empty line
renders `idle` as today. Non-idle envelopes are unchanged. The `Writer` adds an
`ambientLine func() string` source and `sentinel.AddOnChange(writer.Wake)`
drives prompt re-render; the existing minute ticker still applies.
- **Web UI (`internal/web` + `static/app.js`):** the served state gains an
`"ambient": "<line>"` field (read from `sentinel.Line`), pushed over the
existing SSE channel via `sentinel.AddOnChange(broadcast)`. `app.js` renders a
**banner** when the field is non-empty: the coaching line plus two controls —
**Snooze 1h** (`POST /ambient/snooze`) and **Start focus** (reuses the existing
start-session affordance so a nudge converts into a declared session). The
banner clears automatically when the line goes empty.
- **New endpoint:** `POST /ambient/snooze``sentinel.Snooze(1 * time.Hour)`.
Returns 204. No body needed in v1.
## Config (the agency-vs-cage dial, §8)
`settings.Settings` gains two fields (env-seedable, mirroring the existing ones):
- `ambient_mode` (`KEEL_AMBIENT_MODE`): `off` | `status` | `notify`. Default
`notify`. Validated in the settings applier (like `ai_backend`); an unknown
value is rejected with a wrapped sentinel so POST `/settings` can map it to 400.
- `ambient_cadence_secs` (`KEEL_AMBIENT_CADENCE_SECS`): integer seconds between
evaluations. Default `300`. A non-positive value falls back to the default.
The web settings form gains a select (`ambient_mode`) and a number input
(`ambient_cadence_secs`); `applyFn` pushes both into `sentinel.SetConfig`.
## Error handling & graceful degradation
- **No `notify-send` binary / non-Linux:** nop notifier; status line and banner
still work.
- **Brain error / timeout:** `AmbientDrift` returns an error; the sentinel logs
and leaves the prior line intact. Never fabricates drift.
- **AW down:** `memory` is already the nop store; `ambient_nudge` writes are
dropped silently.
- **`knowledge` / `tasks` errors:** `frame.Assemble` degrades to a partial brief
(best-effort), never errors.
- **Headless / no X11:** the evidence source is a no-op, so `OnWindow` is never
called, the ring stays empty, the cheap gate skips every brain call, and the
sentinel is silent. No new X11 requirement.
- **Nothing in the sentinel can crash the daemon** — every external call is
best-effort and lock-bounded.
## Testing
- **`internal/ambient`** (fake clock, fake `ai` with scriptable `AmbientDrift`
returns and a call counter, fake `notify.Notifier` recording calls, fake
`tasks`/`knowledge`, `activeMode` stub, `memory.Fake`):
- on-track → no line, no toast, no memory.
- single drift tick → line set, **no** toast, no memory yet.
- two consecutive drift ticks → line set, **exactly one** toast, one
`ambient_nudge` event.
- third consecutive drift tick → still one toast (once per episode).
- back on-track → line cleared, flags reset.
- `activeMode() != ""` → no eval, line cleared.
- `mode == status` → drift sets the line but never toasts (memory still records
the sustained drift).
- `mode == off` → nothing happens, no brain call.
- snooze → no eval / no line during the window.
- cheap gate → unchanged ring + on-track → assert the fake `ai` call count does
**not** increase.
- **`internal/notify`:** `NewNotifier` with `notify-send` absent → nop; `Notify`
returns nil, no panic. (The real shell path is not unit-tested.)
- **`internal/ai`:** `parseAmbientDrift` — on-track→`""`, drift+msg→msg, drift
with no msg→`""`, empty→`ErrEmptyResponse`, no-JSON→`ErrNoJSON`.
- **`internal/frame`:** `Assemble` with fake ports — sections present; nil ports
and missing files degrade without error; truncation honored. Off-screen's
existing tests stay green after the refactor.
- **`internal/statusfile`:** idle + ambient line → `⚠ …`; idle + empty line →
`idle`; non-idle envelopes unaffected.
- **`internal/web`:** served state includes `ambient`; `POST /ambient/snooze`
routes to `Snooze` and returns 204.
- **`internal/settings`:** `ambient_mode` validation (good values accepted,
unknown rejected with the sentinel); cadence default on non-positive.
## Out of scope (deliberate, for a later slice)
- **AFK precision via the AW afk bucket.** v1 uses the cheap-gate heuristic
(no new titles ⇒ no call), which covers both AFK and deep-focus well enough.
Reading AW's input-derived afk bucket to *prove* presence is a future collector.
- **Ambient enforcement** (window-minimize). The sentinel is advisory only;
minimizing windows with no declared session would be far too cagey. Enforcement
stays a focus-session decision.
- **Trend / spiral detection** over the accumulating `ambient_nudge` history
(§7). This slice *produces* that history; reading it for cross-time patterns is
the next increment.
- **Richer banner actions** beyond snooze + start-focus (e.g. "I'm on it",
per-domain mute).
## Open questions
- **Default cadence.** 300 s is a starting guess; may want tuning after living
with it. Configurable, so cheap to change.
- **Snooze duration.** Fixed 1 h in v1; a picker is a later refinement.
@@ -0,0 +1,200 @@
# Off-screen Memory — AW-backed Recall — Design
**Date:** 2026-06-05
**Status:** Approved, ready for implementation planning
**Scope:** Give Keel its first durable, cross-run memory by wiring **one** mode
(off-screen) to a real ActivityWatch bucket. Off-screen records its own
decisions (`proposal_made` / `action_taken` / `proposal_dismissed`) to
`keel.events` and reads its recent history back into the next brief, so
proposals stay fresh and follow up on what was not acted on. The memory layer
is introduced as a thin reusable port (`memory.Store`) behind which AW, HTTP,
and bucket names are sealed — the seam every later mode reuses.
This realizes the "Next increment: memory" note in
`docs/keel-architecture.md` §5 and the storage design in §7, at the smallest
honest vertical.
## Problem
Keel has no durable memory. Mode state dies in `~/.keel/.../state.json`; the
off-screen mode cannot remember what it proposed last time, so it can repeat
itself and can never follow up ("you said you'd walk before the call — did
you?"). The architecture (§7) resolves storage to **ActivityWatch buckets**
(no new DB), because AW is an append-only, timestamped, queryable event store —
the substrate that later unlocks trend/spiral detection no single tool can do.
Today, zero AW client code exists in the repo and no `keel.*` bucket exists.
This design builds the first real slice of that memory, and only that slice.
## Decisions (locked in brainstorming 2026-06-05)
1. **Slice width = thin port + one consumer.** Add a generic `memory.Store`
port to `harness.Services` (write an event, query recent), backed by AW, but
wire exactly one real behavior: off-screen remembers. Focus and other event
kinds come later.
2. **Behavior = fresh + follow-up.** The brief includes recent proposals *and*
their outcome (confirmed / dismissed / unactioned). The brain is told to
avoid blindly repeating and to follow up on a recent unactioned/dismissed one
when it still fits.
3. **Brain-only (no UI this slice).** Memory shapes the proposal; nothing new is
rendered. A "recently proposed" UI panel is a trivial later follow-up once the
data is flowing.
4. **AW from the start, behind a port.** Not the existing JSON `store` package —
§3/§7 lock storage to AW precisely for queryability. Modes never touch HTTP.
## Architecture
Two new leaf packages, matching the existing ports-and-adapters style
(`evidence.Source`, `knowledge.Source`, `tasks.Provider`, `ai.Backend`,
`enforce.Guard`):
### `internal/aw` — thin AW REST client
No Keel concepts; just buckets and events. Grounded in the live API
(`/api/0`, AW server v0.13.2): events are `{id, timestamp, duration, data{}}`,
and `GET .../events?limit=N` returns newest-first.
```go
type Event struct {
Timestamp time.Time
Duration float64
Data map[string]any
}
type Client struct { /* baseURL, *http.Client with timeout */ }
func New(baseURL string) *Client
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error // idempotent
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error // POST .../events
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) // GET .../events?limit=N
```
- `EnsureBucket` is idempotent: creating an existing bucket is treated as
success (AW returns a benign "already exists" response).
- `Insert` posts a single event (`duration: 0` for discrete derived events).
- Any transport/HTTP-status error is returned; callers treat memory as
best-effort.
### `internal/memory` — Keel-facing port + adapters
```go
type Event struct {
Kind string // "proposal_made", "action_taken", "proposal_dismissed"
At time.Time
Data map[string]any
}
type Store interface {
Record(ctx context.Context, e Event) error
Recent(ctx context.Context, kind string, n int) ([]Event, error)
}
```
- `awStore` implements `Store` over `*aw.Client`, bucket id `keel.events`. It
maps `memory.Event ↔ aw.Event`, carrying `Kind` inside `data._kind` and the
rest of `Data` alongside. Because `keel.events` interleaves all kinds,
`Recent(kind, n)` **over-fetches** a bounded window of recent bucket events
(a fixed cap, e.g. 200), filters by `_kind` client-side, and returns up to `n`
newest. Events older than that window are not seen — acceptable for "recent",
and revisited if/when the event volume grows.
- `nopStore``Record`/`Recent` no-ops; used when AW is unreachable/disabled.
- `fake` — in-memory `Store` for tests (records appended in memory, `Recent`
filters and returns newest-first).
### Wiring
- Add `Memory memory.Store` to `harness.Services`.
- `cmd/keeld.buildServices` constructs the AW-backed store: base URL from a new
`KEEL_AW_URL` setting (default `http://localhost:5600`), then
`EnsureBucket("keel.events", "keel.event", "keel")` **once at startup**. If
that fails, log once and fall back to `nopStore` so a missing/down AW never
breaks boot.
- `offscreen.New` / `offscreen.Factory` receive `svc.Memory`.
Modes depend only on the `memory.Store` interface; AW, HTTP, and bucket names
stay sealed inside the adapter — same shape as every other port.
## Data model
`keel.events` is an append-only log of Keel's **derived** events. This slice
writes three kinds, correlated by a `proposal_id` (short random hex minted when
a proposal is made; `crypto/rand`, in the daemon, not the sandbox):
| Kind | When | `data` |
|---|---|---|
| `proposal_made` | brain returns a proposal | `{proposal_id, mode:"offscreen", next_action, rationale}` |
| `action_taken` | confirm succeeds | `{proposal_id, mode:"offscreen", next_action}` |
| `proposal_dismissed` | dismiss | `{proposal_id, mode:"offscreen"}` |
**Reference, don't duplicate (§7):** `next_action` is Keel's *own* output, not
another tool's source of truth — recording it logs Keel's decision, it does not
duplicate Marvin. The Marvin task id is *not* captured because
`tasks.Provider.Create` returns only `error`; surfacing the real id is a future
enhancement gated on that signature and is out of scope here.
## Data flow
### Write path (all best-effort)
A memory write failure is logged and swallowed; it never fails the command.
- Proposal produced (async `onComplete`, status → `proposed`): mint a
`proposal_id`, stash it on the mode, `Record(proposal_made)`.
- `confirm()`: after `tasks.Create` succeeds, `Record(action_taken)`.
- `dismiss()`: `Record(proposal_dismissed)`.
### Read path
`offscreen.assembleBrief()` gains a `## Recently proposed` section:
1. `Recent("proposal_made", 5)` (newest-first); drop entries older than ~7 days.
2. For each, resolve outcome by matching `proposal_id` against recent
`action_taken` / `proposal_dismissed` → label `confirmed` / `dismissed` /
`unactioned`, with a relative age.
3. Render lines, e.g. `- "Walk before the call" (confirmed, 2h ago)`.
4. `buildProposePrompt` gains a rule: *don't repeat these; if a recent one was
dismissed or unactioned and still fits, follow up on it instead of inventing
new.*
If `Memory` is nil or `Recent` errors, the section is omitted — the proposal
still happens, ungrounded by history.
## Error handling / degradation
Memory is never on the critical path; it degrades exactly like the `knowledge`
and `tasks` ports already do in this mode.
- AW down at **startup**`EnsureBucket` fails → log once, use `nopStore`.
Off-screen works, unremembering.
- AW down **mid-run**`Record`/`Recent` error → logged, swallowed.
Propose / confirm / dismiss all still succeed.
## Testing
- `internal/aw` — against an `httptest` server: asserts the POST event body and
the `GET ?limit=N` round-trip; an error status surfaces as an error.
- `internal/memory``awStore` against `httptest` (the `_kind` mapping;
`Recent` filters by kind); `nopStore` trivial; the `fake` exercised directly.
- `internal/mode/offscreen` — using the `fake` store: (a) `proposal_made`
written on propose, (b) `action_taken` on confirm, (c) `proposal_dismissed`
on dismiss, (d) brief contains recent proposals with outcome labels, (e)
everything still works with a nil/erroring store. Existing offscreen tests get
the `fake` (or nil) injected — no behavior regressions.
## Out of scope (named to prevent scope creep)
- No `keel.state` bucket — only `keel.events`.
- No focus-mode events; no `mode_started` / `feedback_given` / `spiral_flagged`
/ `coach_line`.
- No UI rendering of history (brain-only).
- No spiral/trend detection — the *reason* for AW, but a later slice that reads
this data.
- No cross-device AW sync, no Marvin-id capture, no assembler privacy-filter
rework.
## What ships
Off-screen mode that writes its decisions to a real AW bucket and reads its own
history back into the next proposal — fresher proposals that follow up on what
was not done. The first durable-memory vertical, end to end, and the seam every
later mode reuses.
+2 -2
View File
@@ -1,4 +1,4 @@
module antidrift
module keel
go 1.26.3
@@ -7,6 +7,7 @@ require (
github.com/google/uuid v1.6.0
github.com/jezek/xgb v1.3.1
github.com/jezek/xgbutil v0.0.0-20260124183602-9fd151d6a51a
golang.org/x/sys v0.41.0
)
require (
@@ -36,7 +37,6 @@ require (
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+79
View File
@@ -0,0 +1,79 @@
// internal/ai/ambient.go
package ai
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// AmbientDriftJudge judges whether current activity serves the user's standing
// frame when NO task is declared. Like Nudge it takes primitives, not domain
// types, so ai stays a leaf. The returned string is a one-sentence advisory, or
// "" when the activity is plausibly on-track.
type AmbientDriftJudge interface {
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
}
// ErrInvalidAmbientDrift marks a parseable-but-unusable ambient response.
var ErrInvalidAmbientDrift = errors.New("ai: invalid ambient drift")
// AmbientDrift makes Service satisfy AmbientDriftJudge over the same backend.
func (s *Service) AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error) {
out, err := s.backend.Run(ctx, buildAmbientPrompt(frame, recentTitles))
if err != nil {
return "", err
}
return parseAmbientDrift(out)
}
func buildAmbientPrompt(frame string, recentTitles []string) string {
return `You are an ambient coach. The user has NOT declared a task. Below is their standing frame goals, values, and life-domains and today's tasks, then the recent sequence of window titles. Decide whether the recent activity plausibly serves ANY of what matters to them, or whether it is a slide into drift.
Be forgiving: legitimate breaks, rest, admin, and unplanned-but-useful work are ON-TRACK. Only call drift when the activity clearly serves none of what matters.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"drifting": <true or false>, "message": "<short explanation, one sentence>"}
Rules:
- drifting: true only if the recent activity serves none of the frame; false otherwise.
- message: one short sentence naming the drift. REQUIRED when drifting is true.
## Frame
` + frame + `
## Recent window titles (oldest to newest)
` + strings.Join(recentTitles, "\n")
}
type rawAmbientDrift struct {
Drifting bool `json:"drifting"`
Message string `json:"message"`
}
// parseAmbientDrift extracts the advisory from raw CLI output. On-track yields
// "". A drift with no message degrades to "" (silence) rather than an error:
// the ambient signal is advisory, so the safe degenerate is to say nothing —
// exactly like parseNudge.
func parseAmbientDrift(s string) (string, error) {
if strings.TrimSpace(s) == "" {
return "", ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return "", ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
}
var raw rawAmbientDrift
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidAmbientDrift, err)
}
if !raw.Drifting {
return "", nil
}
return strings.TrimSpace(raw.Message), nil
}
+68
View File
@@ -0,0 +1,68 @@
// internal/ai/ambient_test.go
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestServiceAmbientDriftSuccess(t *testing.T) {
fb := &fakeBackend{out: `{"drifting": true, "message": "deep in YouTube, none of today's goals"}`}
line, err := NewService(fb).AmbientDrift(context.Background(), "## Goals\nship keel", []string{"YouTube - Brave"})
if err != nil {
t.Fatalf("ambient drift: %v", err)
}
if line == "" {
t.Fatal("expected a non-empty drift line")
}
if !strings.Contains(fb.gotPrompt, "ship keel") {
t.Fatalf("prompt should embed the frame, got: %s", fb.gotPrompt)
}
if !strings.Contains(fb.gotPrompt, "YouTube - Brave") {
t.Fatalf("prompt should embed the recent titles, got: %s", fb.gotPrompt)
}
}
func TestServiceAmbientDriftBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).AmbientDrift(context.Background(), "frame", []string{"x"}); err == nil {
t.Fatal("want backend error")
}
}
func TestParseAmbientDrift(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr error
}{
{"on track yields empty", `{"drifting": false, "message": ""}`, "", nil},
{"drift returns message", `{"drifting": true, "message": "watching unrelated videos"}`, "watching unrelated videos", nil},
{"drift is trimmed", `{"drifting": true, "message": " slid "}`, "slid", nil},
{"drift without message degrades to silence", `{"drifting": true, "message": ""}`, "", nil},
{"json embedded in prose", `ok: {"drifting": true, "message": "off track"} done`, "off track", nil},
{"empty response", " ", "", ErrEmptyResponse},
{"no json", "no braces here", "", ErrNoJSON},
{"malformed json", `{"drifting": true, "message":`, "", ErrInvalidAmbientDrift},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseAmbientDrift(tt.in)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (b codexBackend) args(outfile string) []string {
}
func (b codexBackend) Run(ctx context.Context, prompt string) (string, error) {
f, err := os.CreateTemp("", "antidrift-codex-*.out")
f, err := os.CreateTemp("", "keel-codex-*.out")
if err != nil {
return "", fmt.Errorf("codex: temp file: %w", err)
}
+13 -8
View File
@@ -2,9 +2,10 @@ package ai
import "context"
// Coach turns a free-text intent into a validated Proposal.
// Coach turns a free-text intent into a validated Proposal. grounding is
// optional standing context about the user (the knowledge port); "" means none.
type Coach interface {
Coach(ctx context.Context, intent string) (Proposal, error)
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
}
// Backend is one way to reach an LLM CLI. Adapters differ only in the command
@@ -21,16 +22,16 @@ type Service struct {
func NewService(b Backend) *Service { return &Service{backend: b} }
func (s *Service) Coach(ctx context.Context, intent string) (Proposal, error) {
out, err := s.backend.Run(ctx, buildPrompt(intent))
func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) {
out, err := s.backend.Run(ctx, buildPrompt(intent, grounding))
if err != nil {
return Proposal{}, err
}
return parseProposal(out)
}
func buildPrompt(intent string) string {
return `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
func buildPrompt(intent, grounding string) string {
preamble := `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"next_action": "<one concrete action to start now>", "success_condition": "<observable, verifiable done state>", "timebox_minutes": <integer, typically 15-50>, "allowed_window_classes": ["<app/window class that is on-task, e.g. code, firefox>"]}
@@ -39,9 +40,13 @@ Rules:
- next_action: a single concrete imperative action, doable now.
- success_condition: observable and verifiable; how you'd know it is done.
- timebox_minutes: a realistic integer number of minutes for the action.
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.
- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.`
User intent: ` + intent
about := ""
if grounding != "" {
about = "\n\n## About the user\n" + grounding + "\nUse this standing context to make the commitment fit who they are and what matters to them."
}
return preamble + about + "\n\nUser intent: " + intent
}
// DriftJudge decides whether the current window is on-task for a commitment.
+23 -3
View File
@@ -22,7 +22,7 @@ func (f *fakeBackend) Name() string { return "fake" }
func TestServiceCoachSuccess(t *testing.T) {
fb := &fakeBackend{out: `here you go {"next_action":"Write tests","success_condition":"green","timebox_minutes":30}`}
svc := NewService(fb)
p, err := svc.Coach(context.Background(), "write the tests")
p, err := svc.Coach(context.Background(), "write the tests", "")
if err != nil {
t.Fatalf("coach: %v", err)
}
@@ -36,14 +36,34 @@ func TestServiceCoachSuccess(t *testing.T) {
func TestServiceCoachBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Coach(context.Background(), "x"); err == nil {
if _, err := NewService(fb).Coach(context.Background(), "x", ""); err == nil {
t.Fatal("want backend error")
}
}
func TestServiceCoachUnparseable(t *testing.T) {
fb := &fakeBackend{out: "I cannot help with that."}
if _, err := NewService(fb).Coach(context.Background(), "x"); !errors.Is(err, ErrNoJSON) {
if _, err := NewService(fb).Coach(context.Background(), "x", ""); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err)
}
}
func TestCoachPromptIncludesGrounding(t *testing.T) {
fb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
if _, err := NewService(fb).Coach(context.Background(), "ship it", "I value small diffs."); err != nil {
t.Fatalf("coach: %v", err)
}
if !strings.Contains(fb.gotPrompt, "About the user") || !strings.Contains(fb.gotPrompt, "small diffs") {
t.Fatalf("prompt missing grounding block: %s", fb.gotPrompt)
}
}
func TestCoachEmptyGroundingUnchanged(t *testing.T) {
withFb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`}
_, _ = NewService(withFb).Coach(context.Background(), "ship it", "")
// The empty-grounding prompt must equal buildPrompt(intent) of the old shape:
// it must NOT contain the grounding header.
if strings.Contains(withFb.gotPrompt, "About the user") {
t.Fatalf("empty grounding leaked a header: %s", withFb.gotPrompt)
}
}
+75
View File
@@ -0,0 +1,75 @@
package ai
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Nudger judges whether recent activity within an allowed app still serves the
// commitment. Like Coach and DriftJudge it takes primitives, not domain/evidence
// types, so ai stays a leaf package. The returned string is a one-sentence
// advisory, or "" when the trajectory is still on-task.
type Nudger interface {
Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error)
}
// ErrInvalidNudge marks a parseable-but-unusable nudge response.
var ErrInvalidNudge = errors.New("ai: invalid nudge")
// Nudge makes Service satisfy Nudger over the same backend as Coach and JudgeDrift.
func (s *Service) Nudge(ctx context.Context, commitment string, recentTitles []string) (string, error) {
out, err := s.backend.Run(ctx, buildNudgePrompt(commitment, recentTitles))
if err != nil {
return "", err
}
return parseNudge(out)
}
func buildNudgePrompt(commitment string, recentTitles []string) string {
return `You are a focus monitor. The user committed to a task and is working in an allowed application, so the application itself is fine. Judge whether the SEQUENCE of recent window titles shows them still working toward the commitment, or whether they have drifted onto unrelated work within that app.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"on_track": <true or false>, "message": "<short explanation, one sentence>"}
Rules:
- on_track: true if the recent titles plausibly serve the commitment, false if they show unrelated work.
- message: one short sentence naming the drift. REQUIRED when on_track is false.
Commitment: ` + commitment + `
Recent window titles (oldest to newest):
` + strings.Join(recentTitles, "\n")
}
type rawNudge struct {
OnTrack bool `json:"on_track"`
Message string `json:"message"`
}
// parseNudge extracts the advisory from raw CLI output. It reuses extractJSON
// and the shared empty/no-JSON sentinels. An on-track result yields "". A
// concern with no message degrades to "" (silence) rather than an error: the
// nudge is ambient, so the safe degenerate is to say nothing, unlike the
// interruptive drift verdict which rejects a reasonless drift.
func parseNudge(s string) (string, error) {
if strings.TrimSpace(s) == "" {
return "", ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return "", ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
}
var raw rawNudge
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidNudge, err)
}
if raw.OnTrack {
return "", nil
}
return strings.TrimSpace(raw.Message), nil
}
+64
View File
@@ -0,0 +1,64 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestServiceNudgeSuccess(t *testing.T) {
fb := &fakeBackend{out: `{"on_track": false, "message": "reading news, not the auth flow"}`}
advisory, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"})
if err != nil {
t.Fatalf("nudge: %v", err)
}
if advisory == "" {
t.Fatal("expected non-empty advisory")
}
if !strings.Contains(fb.gotPrompt, "fix auth") {
t.Fatalf("prompt should embed the commitment, got: %s", fb.gotPrompt)
}
}
func TestServiceNudgeBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Nudge(context.Background(), "fix auth", []string{"Firefox - Hacker News"}); err == nil {
t.Fatal("want backend error")
}
}
func TestParseNudge(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr error
}{
{"on track yields empty", `{"on_track": true, "message": ""}`, "", nil},
{"concern returns message", `{"on_track": false, "message": "editing unrelated CSS, not the auth flow"}`, "editing unrelated CSS, not the auth flow", nil},
{"concern is trimmed", `{"on_track": false, "message": " drifted "}`, "drifted", nil},
{"concern without message degrades to silence", `{"on_track": false, "message": ""}`, "", nil},
{"json embedded in prose", `sure thing: {"on_track": false, "message": "wrong project"} done`, "wrong project", nil},
{"empty response", " ", "", ErrEmptyResponse},
{"no json", "no braces here", "", ErrNoJSON},
{"malformed json", `{"on_track": false, "message":`, "", ErrInvalidNudge},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseNudge(tt.in)
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("err = %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
+73
View File
@@ -0,0 +1,73 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// OffscreenProposal is the brain's suggestion for one worthwhile thing to do
// away from the screen. It is deliberately distinct from Proposal (the focus
// coach's commitment shape) to avoid a name collision.
type OffscreenProposal struct {
NextAction string `json:"next_action"`
Rationale string `json:"rationale"`
}
// Proposer turns an off-screen brief (goals, life-domain notes, today's tasks)
// into a single grounded off-screen action.
type Proposer interface {
Propose(ctx context.Context, brief string) (OffscreenProposal, error)
}
// Propose makes Service satisfy Proposer over the same backend as Coach.
func (s *Service) Propose(ctx context.Context, brief string) (OffscreenProposal, error) {
out, err := s.backend.Run(ctx, buildProposePrompt(brief))
if err != nil {
return OffscreenProposal{}, err
}
return parseOffscreenProposal(out)
}
func buildProposePrompt(brief string) string {
return `You are an off-screen advisor. Read the brief below the user's goals, life-domain notes, and today's tasks and pick ONE worthwhile thing for them to do now, away from the screen.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"next_action": "<one worthwhile off-screen action to do now>", "rationale": "<one short sentence: which goal or value it serves>"}
Rules:
- next_action: a single concrete off-screen action, doable now.
- rationale: one short sentence naming the goal or value it serves.
- If the brief lists recently proposed actions, do not simply repeat them; if a recent one was dismissed or not done and still fits, follow up on it instead of inventing something new.
## Brief
` + brief
}
// parseOffscreenProposal extracts and validates an OffscreenProposal from raw
// CLI output, mirroring parseProposal's error discipline.
func parseOffscreenProposal(s string) (OffscreenProposal, error) {
if strings.TrimSpace(s) == "" {
return OffscreenProposal{}, ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return OffscreenProposal{}, ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
var raw OffscreenProposal
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return OffscreenProposal{}, fmt.Errorf("%w: %v", ErrInvalidProposal, err)
}
na := strings.TrimSpace(raw.NextAction)
if na == "" {
return OffscreenProposal{}, ErrInvalidProposal
}
return OffscreenProposal{
NextAction: na,
Rationale: strings.TrimSpace(raw.Rationale),
}, nil
}
+50
View File
@@ -0,0 +1,50 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestProposeParsesProposal(t *testing.T) {
svc := NewService(&fakeBackend{out: `{"next_action":"call mum","rationale":"people goal"}`})
p, err := svc.Propose(context.Background(), "off-screen brief")
if err != nil {
t.Fatalf("Propose: %v", err)
}
if p.NextAction != "call mum" || p.Rationale != "people goal" {
t.Fatalf("proposal = %+v", p)
}
}
func TestProposeBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Propose(context.Background(), "brief"); err == nil {
t.Fatal("want backend error")
}
}
func TestProposeUnparseable(t *testing.T) {
fb := &fakeBackend{out: "I cannot help."}
if _, err := NewService(fb).Propose(context.Background(), "brief"); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err)
}
}
func TestProposePromptIncludesBrief(t *testing.T) {
fb := &fakeBackend{out: `{"next_action":"a","rationale":"b"}`}
if _, err := NewService(fb).Propose(context.Background(), "water the tomato plants"); err != nil {
t.Fatalf("Propose: %v", err)
}
if !strings.Contains(fb.gotPrompt, "water the tomato plants") {
t.Fatalf("prompt should embed the brief, got: %s", fb.gotPrompt)
}
}
func TestProposePromptHasFollowUpRule(t *testing.T) {
p := buildProposePrompt("brief")
if !strings.Contains(p, "do not simply repeat") {
t.Fatalf("prompt missing follow-up rule:\n%s", p)
}
}
+84
View File
@@ -0,0 +1,84 @@
package ai
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Reflection is the reviewer's output: two short, single-line fields.
type Reflection struct {
Recap string // backward-looking: how the session went (shown on Review)
CarryForward string // forward-looking: one takeaway for the next session
}
// Reviewer reflects on a just-finished session, read against recent history. It
// takes primitives, not domain/store types, so ai stays a leaf package.
//
// finished: a compact description of the session that just ended.
// history: a compact description of the last few prior sessions ("" if none).
type Reviewer interface {
Review(ctx context.Context, finished, history string) (Reflection, error)
}
// ErrInvalidReflection marks output that parsed as JSON but lacked a usable
// recap, so callers can distinguish "no attempt" from "bad attempt".
var ErrInvalidReflection = errors.New("ai: invalid reflection")
// Review makes Service satisfy Reviewer over the same backend as Coach.
func (s *Service) Review(ctx context.Context, finished, history string) (Reflection, error) {
out, err := s.backend.Run(ctx, buildReviewPrompt(finished, history))
if err != nil {
return Reflection{}, err
}
return parseReflection(out)
}
func buildReviewPrompt(finished, history string) string {
preamble := `You are a focus reviewer. A work session just ended. Reflect on it in two short lines.
Respond with ONLY a JSON object, no prose and no code fences, exactly this shape:
{"recap": "<one sentence: how this session went>", "carry_forward": "<one sentence: the single most useful thing to do differently next session>"}
Rules:
- recap: one short sentence, backward-looking, grounded in the session below.
- carry_forward: one short, actionable sentence for the next session. It may reference patterns across the recent sessions if any are given.
- Keep each field to a single short sentence.`
hist := ""
if strings.TrimSpace(history) != "" {
hist = "\n\n## Recent sessions (oldest first)\n" + history
}
return preamble + "\n\n## Session that just ended\n" + finished + hist
}
type rawReflection struct {
Recap string `json:"recap"`
CarryForward string `json:"carry_forward"`
}
// parseReflection extracts a Reflection from raw CLI output. A blank recap is
// rejected (ErrInvalidReflection); an empty carry_forward is allowed.
func parseReflection(s string) (Reflection, error) {
if strings.TrimSpace(s) == "" {
return Reflection{}, ErrEmptyResponse
}
if strings.IndexByte(s, '{') < 0 {
return Reflection{}, ErrNoJSON
}
jsonStr, err := extractJSON(s)
if err != nil {
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
}
var raw rawReflection
if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil {
return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err)
}
recap := strings.TrimSpace(raw.Recap)
if recap == "" {
return Reflection{}, ErrInvalidReflection
}
return Reflection{Recap: recap, CarryForward: strings.TrimSpace(raw.CarryForward)}, nil
}
+77
View File
@@ -0,0 +1,77 @@
package ai
import (
"context"
"errors"
"strings"
"testing"
)
func TestReviewPromptIncludesSessionAndHistory(t *testing.T) {
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", "HISTORY-BLOCK"); err != nil {
t.Fatalf("review: %v", err)
}
if !strings.Contains(fb.gotPrompt, "FINISHED-BLOCK") {
t.Fatalf("prompt missing finished session: %s", fb.gotPrompt)
}
if !strings.Contains(fb.gotPrompt, "HISTORY-BLOCK") || !strings.Contains(fb.gotPrompt, "Recent sessions") {
t.Fatalf("prompt missing history block: %s", fb.gotPrompt)
}
}
func TestReviewPromptOmitsEmptyHistory(t *testing.T) {
fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`}
if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", ""); err != nil {
t.Fatalf("review: %v", err)
}
if strings.Contains(fb.gotPrompt, "Recent sessions") {
t.Fatalf("empty history must not add a history header: %s", fb.gotPrompt)
}
}
func TestReviewServiceParsesReflection(t *testing.T) {
fb := &fakeBackend{out: `sure: {"recap":"held focus on the port","carry_forward":"start in the editor next time"}`}
refl, err := NewService(fb).Review(context.Background(), "f", "h")
if err != nil {
t.Fatalf("review: %v", err)
}
if refl.Recap != "held focus on the port" || refl.CarryForward != "start in the editor next time" {
t.Fatalf("bad reflection: %+v", refl)
}
}
func TestReviewServiceBackendError(t *testing.T) {
fb := &fakeBackend{err: errors.New("boom")}
if _, err := NewService(fb).Review(context.Background(), "f", "h"); err == nil {
t.Fatal("want backend error")
}
}
func TestParseReflectionEmpty(t *testing.T) {
if _, err := parseReflection(""); !errors.Is(err, ErrEmptyResponse) {
t.Fatalf("want ErrEmptyResponse, got %v", err)
}
}
func TestParseReflectionNoJSON(t *testing.T) {
if _, err := parseReflection("I cannot help."); !errors.Is(err, ErrNoJSON) {
t.Fatalf("want ErrNoJSON, got %v", err)
}
}
func TestParseReflectionRequiresRecap(t *testing.T) {
if _, err := parseReflection(`{"recap":" ","carry_forward":"x"}`); !errors.Is(err, ErrInvalidReflection) {
t.Fatalf("blank recap should be invalid, got %v", err)
}
}
func TestParseReflectionAllowsEmptyCarryForward(t *testing.T) {
refl, err := parseReflection(`{"recap":"did the thing","carry_forward":""}`)
if err != nil {
t.Fatalf("carry_forward may be empty: %v", err)
}
if refl.Recap != "did the thing" || refl.CarryForward != "" {
t.Fatalf("bad reflection: %+v", refl)
}
}
+323
View File
@@ -0,0 +1,323 @@
// internal/ambient/sentinel.go
// Package ambient is Keel's always-on drift coach: a Sentinel that watches the
// window stream beside the one-mode harness and, when no mode is active, judges
// recent activity against the ~/owc frame and surfaces drift. It is not a mode —
// it runs continuously, including when the harness is idle.
package ambient
import (
"context"
"log"
"strings"
"sync"
"time"
"keel/internal/evidence"
"keel/internal/frame"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/notify"
"keel/internal/tasks"
)
// recentTitlesMax bounds the title ring (matches the focus mode's history cap).
const recentTitlesMax = 10
// evalTimeout bounds a single ambient brain call.
const evalTimeout = 30 * time.Second
// defaultCadence is used when the configured cadence is non-positive.
const defaultCadence = 5 * time.Minute
// kindAmbientNudge is the memory event recorded for a sustained ambient drift.
const kindAmbientNudge = "ambient_nudge"
// Ambient mode dial values (mirror settings.Ambient*).
const (
modeOff = "off"
modeStatus = "status"
modeNotify = "notify"
)
// AmbientDriftJudge is the brain call the sentinel needs. *ai.Service satisfies
// it; declaring it here keeps ambient decoupled from the ai package.
type AmbientDriftJudge interface {
AmbientDrift(ctx context.Context, frame string, recentTitles []string) (string, error)
}
// Deps are the ports the sentinel depends on. Knowledge and Tasks may be nil
// (frame.Assemble guards). Memory and Notifier may be nil (best-effort guards).
// ActiveMode reports the harness's active mode kind ("" = idle); nil counts as
// always-idle. Clock defaults to time.Now.
type Deps struct {
AI AmbientDriftJudge
Knowledge knowledge.Source
Tasks tasks.Provider
Notifier notify.Notifier
Memory memory.Store
Clock func() time.Time
ActiveMode func() string
}
// Sentinel watches activity and surfaces ambient drift.
type Sentinel struct {
mu sync.Mutex
deps Deps
cadence time.Duration
mode string
recent []string
lastWindow evidence.WindowSnapshot
line string
lastEvalTitles string
drifting bool
committed bool
snoozeUntil time.Time
onChange []func()
}
// New builds a sentinel. A non-positive cadence falls back to defaultCadence; an
// empty mode falls back to notify.
func New(d Deps, cadence time.Duration, mode string) *Sentinel {
if d.Clock == nil {
d.Clock = time.Now
}
if cadence <= 0 {
cadence = defaultCadence
}
return &Sentinel{deps: d, cadence: cadence, mode: normalizeMode(mode)}
}
func normalizeMode(m string) string {
if m == "" {
return modeNotify
}
return m
}
// OnWindow ingests one sensor observation: it records the latest window and
// appends the title to the dedup-capped ring. Cheap and lock-bounded; it fires
// no brain call.
func (s *Sentinel) OnWindow(w evidence.WindowSnapshot) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastWindow = w
t := strings.TrimSpace(w.Title)
if t == "" {
return
}
if n := len(s.recent); n > 0 && s.recent[n-1] == t {
return
}
s.recent = append(s.recent, t)
if len(s.recent) > recentTitlesMax {
s.recent = s.recent[len(s.recent)-recentTitlesMax:]
}
}
// Line returns the current ambient coaching line ("" when on-track, quiet, or
// snoozed) for the surfaces.
func (s *Sentinel) Line() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.line
}
// AddOnChange registers a surface refresh fired when the line changes.
func (s *Sentinel) AddOnChange(f func()) {
s.mu.Lock()
s.onChange = append(s.onChange, f)
s.mu.Unlock()
}
// fireOnChange notifies registered surfaces off the lock.
func (s *Sentinel) fireOnChange() {
s.mu.Lock()
fs := append([]func(){}, s.onChange...)
s.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// recentForTest returns a copy of the ring (test-only).
func (s *Sentinel) recentForTest() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.recent...)
}
// Run drives the cadence loop: every cadence it evaluates, until ctx is
// cancelled. It re-reads the cadence each cycle so SetConfig takes effect on the
// next tick.
func (s *Sentinel) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-time.After(s.currentCadence()):
s.evaluate(ctx)
}
}
}
func (s *Sentinel) currentCadence() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
return s.cadence
}
// SetConfig updates the cadence and mode live (next tick).
func (s *Sentinel) SetConfig(cadence time.Duration, mode string) {
s.mu.Lock()
if cadence > 0 {
s.cadence = cadence
}
s.mode = normalizeMode(mode)
s.mu.Unlock()
}
// Snooze mutes the sentinel for d: it clears the current line and episode and
// suppresses evaluation until the window elapses.
func (s *Sentinel) Snooze(d time.Duration) {
s.mu.Lock()
s.snoozeUntil = s.deps.Clock().Add(d)
changed := s.line != ""
s.line = ""
s.drifting = false
s.committed = false
s.mu.Unlock()
if changed {
s.fireOnChange()
}
}
// evaluate runs one decision cycle. See the spec's "Trigger discipline."
func (s *Sentinel) evaluate(ctx context.Context) {
s.mu.Lock()
if s.mode == modeOff {
s.mu.Unlock()
return
}
if s.deps.Clock().Before(s.snoozeUntil) {
s.mu.Unlock()
return
}
if s.activeModeLocked() != "" {
// A mode is coaching; stay silent and clear any stale line.
changed := s.clearLocked()
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
titles := append([]string(nil), s.recent...)
joined := strings.Join(titles, "\n")
// Cheap gate: nothing new to judge and we are on-track -> skip the brain call.
if joined == s.lastEvalTitles && s.line == "" {
s.mu.Unlock()
return
}
win := s.lastWindow
mode := s.mode
judge := s.deps.AI
s.mu.Unlock()
if judge == nil {
return
}
cctx, cancel := context.WithTimeout(ctx, evalTimeout)
fr := frame.Assemble(cctx, s.deps.Knowledge, s.deps.Tasks)
msg, err := judge.AmbientDrift(cctx, fr, titles)
cancel()
s.mu.Lock()
// A mode may have activated during the (off-lock) brain call; re-check so a
// result computed while idle is not surfaced over an now-active mode.
if s.activeModeLocked() != "" {
changed := s.clearLocked()
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
s.lastEvalTitles = joined
if err != nil {
s.mu.Unlock()
log.Printf("ambient: drift judge failed: %v", err)
return // never fabricate drift; leave the prior line intact
}
if msg == "" { // on-track
changed := s.clearLocked()
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
if !s.drifting { // new episode: show the line, no toast yet
s.line = msg
s.drifting = true
s.committed = false
s.mu.Unlock()
s.fireOnChange()
return
}
// Sustained drift. Commit once: record memory, and toast in notify mode.
changed := s.line != msg
s.line = msg
if s.committed {
s.mu.Unlock()
if changed {
s.fireOnChange()
}
return
}
s.committed = true
now := s.deps.Clock()
mem := s.deps.Memory
notifier := s.deps.Notifier
s.mu.Unlock()
if mem != nil {
ev := memory.Event{Kind: kindAmbientNudge, At: now, Data: map[string]any{
"message": msg, "title": win.Title, "class": win.Class,
}}
if e := mem.Record(ctx, ev); e != nil {
log.Printf("ambient: memory record: %v", e)
}
}
if mode == modeNotify && notifier != nil {
if e := notifier.Notify(ctx, "Keel — drift", msg); e != nil {
log.Printf("ambient: notify: %v", e)
}
}
s.fireOnChange()
}
// clearLocked resets line + episode state and reports whether the line changed.
// Caller holds mu.
func (s *Sentinel) clearLocked() bool {
changed := s.line != ""
s.line = ""
s.drifting = false
s.committed = false
return changed
}
// activeModeLocked reads the active-mode kind, treating a nil hook as idle.
// Caller holds mu (the hook does no locking on the sentinel).
func (s *Sentinel) activeModeLocked() string {
if s.deps.ActiveMode == nil {
return ""
}
return s.deps.ActiveMode()
}
+282
View File
@@ -0,0 +1,282 @@
// internal/ambient/sentinel_test.go
package ambient
import (
"context"
"testing"
"time"
"keel/internal/evidence"
"keel/internal/memory"
)
func snap(title, class string) evidence.WindowSnapshot {
return evidence.WindowSnapshot{Title: title, Class: class, Health: evidence.EvidenceHealth{Available: true}}
}
func TestNewLineEmpty(t *testing.T) {
s := New(Deps{}, 0, "notify")
if s.Line() != "" {
t.Fatalf("fresh sentinel line = %q, want empty", s.Line())
}
}
func TestOnWindowRingDedupesAndCaps(t *testing.T) {
s := New(Deps{}, 0, "notify")
s.OnWindow(snap("a", "x"))
s.OnWindow(snap("a", "x")) // consecutive dup ignored
s.OnWindow(snap("b", "x"))
if got := s.recentForTest(); len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("ring = %v, want [a b]", got)
}
for i := 0; i < recentTitlesMax+5; i++ {
s.OnWindow(snap(string(rune('A'+i)), "x"))
}
if got := s.recentForTest(); len(got) != recentTitlesMax {
t.Fatalf("ring len = %d, want %d", len(got), recentTitlesMax)
}
}
func TestOnWindowSkipsEmptyTitle(t *testing.T) {
s := New(Deps{}, 0, "notify")
s.OnWindow(snap("", "x"))
if got := s.recentForTest(); len(got) != 0 {
t.Fatalf("empty title must not enter ring, got %v", got)
}
}
var _ = context.Background
// --- helpers ---
type fakeJudge struct {
out []string // queued returns, consumed front-to-back
err error
calls int
}
func (f *fakeJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
f.calls++
if f.err != nil {
return "", f.err
}
if len(f.out) == 0 {
return "", nil
}
r := f.out[0]
f.out = f.out[1:]
return r, nil
}
type fakeNotifier struct {
calls int
body string
}
func (f *fakeNotifier) Notify(_ context.Context, _, body string) error {
f.calls++
f.body = body
return nil
}
func newTestSentinel(j *fakeJudge, n *fakeNotifier, mem *memoryFake, mode string, active *string) *Sentinel {
return New(Deps{
AI: j,
Notifier: n,
Memory: mem,
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
ActiveMode: func() string { return *active },
}, time.Minute, mode)
}
// memoryFake is a minimal memory.Store recorder.
type memoryFake struct {
events []memory.Event
}
func (m *memoryFake) Record(_ context.Context, e memory.Event) error {
m.events = append(m.events, e)
return nil
}
func (m *memoryFake) Recent(context.Context, string, int) ([]memory.Event, error) { return nil, nil }
// --- behavioural matrix ---
func TestEvaluateOnTrackIsSilent(t *testing.T) {
j := &fakeJudge{out: []string{""}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeNotify, &active)
s.OnWindow(snap("main.go - code", "code"))
s.evaluate(context.Background())
if s.Line() != "" || n.calls != 0 || len(mem.events) != 0 {
t.Fatalf("on-track must be silent: line=%q toasts=%d mem=%d", s.Line(), n.calls, len(mem.events))
}
}
func TestEvaluateSingleDriftShowsLineNoToast(t *testing.T) {
j := &fakeJudge{out: []string{"deep in YouTube"}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if s.Line() == "" {
t.Fatal("first drift must set the status line")
}
if n.calls != 0 {
t.Fatalf("first drift must NOT toast, got %d", n.calls)
}
if len(mem.events) != 0 {
t.Fatalf("first drift must not record memory yet, got %d", len(mem.events))
}
}
func TestEvaluateSustainedDriftTostsOnceAndRecords(t *testing.T) {
j := &fakeJudge{out: []string{"deep in YouTube", "still in YouTube", "yet more YouTube"}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background()) // first: line, no toast
s.evaluate(context.Background()) // second: sustained -> one toast + one memory
s.evaluate(context.Background()) // third: still drift -> NO second toast
if n.calls != 1 {
t.Fatalf("sustained drift must toast exactly once, got %d", n.calls)
}
if len(mem.events) != 1 {
t.Fatalf("sustained drift must record exactly one ambient_nudge, got %d", len(mem.events))
}
if mem.events[0].Kind != kindAmbientNudge {
t.Fatalf("memory kind = %q, want %q", mem.events[0].Kind, kindAmbientNudge)
}
}
func TestEvaluateReturnsToOnTrackClearsLine(t *testing.T) {
j := &fakeJudge{out: []string{"drift", "drift", ""}}
n := &fakeNotifier{}
active := ""
s := newTestSentinel(j, n, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
s.evaluate(context.Background())
s.evaluate(context.Background()) // on-track now
if s.Line() != "" {
t.Fatalf("return to on-track must clear the line, got %q", s.Line())
}
}
func TestEvaluateSilentWhenModeActive(t *testing.T) {
j := &fakeJudge{out: []string{"drift"}}
active := "focus"
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if j.calls != 0 {
t.Fatalf("an active mode must suppress the brain call, got %d", j.calls)
}
if s.Line() != "" {
t.Fatalf("an active mode must keep the line empty, got %q", s.Line())
}
}
func TestEvaluateStatusModeNeverToasts(t *testing.T) {
j := &fakeJudge{out: []string{"drift", "drift"}}
n := &fakeNotifier{}
mem := &memoryFake{}
active := ""
s := newTestSentinel(j, n, mem, modeStatus, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
s.evaluate(context.Background())
if n.calls != 0 {
t.Fatalf("status mode must never toast, got %d", n.calls)
}
if s.Line() == "" {
t.Fatal("status mode must still show the line")
}
if len(mem.events) != 1 {
t.Fatalf("status mode must still record the sustained drift, got %d", len(mem.events))
}
}
func TestEvaluateOffModeDoesNothing(t *testing.T) {
j := &fakeJudge{out: []string{"drift"}}
active := ""
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeOff, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if j.calls != 0 {
t.Fatalf("off mode must not call the brain, got %d", j.calls)
}
}
func TestEvaluateCheapGateSkipsUnchangedOnTrack(t *testing.T) {
j := &fakeJudge{out: []string{""}} // one on-track answer available
active := ""
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("main.go - code", "code"))
s.evaluate(context.Background()) // calls=1, on-track
s.evaluate(context.Background()) // unchanged ring + on-track -> skip
if j.calls != 1 {
t.Fatalf("cheap gate must skip the second call, got %d", j.calls)
}
}
// flippingJudge simulates a harness mode activating DURING the brain call: it
// returns a drift verdict but flips the active-mode flag as a side effect, so by
// the time evaluate re-acquires the lock a mode is active.
type flippingJudge struct{ active *string }
func (f *flippingJudge) AmbientDrift(_ context.Context, _ string, _ []string) (string, error) {
*f.active = "focus"
return "deep in YouTube", nil
}
// A mode activating mid-evaluation must suppress the surface: no line, no toast,
// no memory record (coexistence rule holds across the unlock-for-I/O window).
func TestEvaluateModeActivatingDuringJudgeStaysSilent(t *testing.T) {
active := ""
n := &fakeNotifier{}
mem := &memoryFake{}
s := New(Deps{
AI: &flippingJudge{active: &active},
Notifier: n,
Memory: mem,
Clock: func() time.Time { return time.Unix(1_700_000_000, 0) },
ActiveMode: func() string { return active },
}, time.Minute, modeNotify)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background())
if s.Line() != "" {
t.Fatalf("mode active after judge must leave line empty, got %q", s.Line())
}
if n.calls != 0 {
t.Fatalf("mode active after judge must not toast, got %d", n.calls)
}
if len(mem.events) != 0 {
t.Fatalf("mode active after judge must not record memory, got %d", len(mem.events))
}
}
func TestSnoozeMutesAndClears(t *testing.T) {
j := &fakeJudge{out: []string{"drift", "drift"}}
active := ""
s := newTestSentinel(j, &fakeNotifier{}, &memoryFake{}, modeNotify, &active)
s.OnWindow(snap("YouTube - Brave", "Brave"))
s.evaluate(context.Background()) // line set
s.Snooze(time.Hour)
if s.Line() != "" {
t.Fatalf("snooze must clear the line, got %q", s.Line())
}
callsBefore := j.calls
s.evaluate(context.Background()) // snoozed -> no call
if j.calls != callsBefore {
t.Fatalf("snooze must suppress evaluation, calls %d -> %d", callsBefore, j.calls)
}
}
+124
View File
@@ -0,0 +1,124 @@
// Package aw is a thin ActivityWatch REST client: it creates buckets and reads
// and writes events. It holds no Keel concepts, so it stays a leaf package.
package aw
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// Event is one ActivityWatch event. Duration is seconds; for Keel's discrete
// derived events it is 0.
type Event struct {
Timestamp time.Time
Duration float64
Data map[string]any
}
// Client talks to an aw-server over its /api/0 REST surface.
type Client struct {
baseURL string
hc *http.Client
}
// New builds a client for the given base URL (e.g. http://localhost:5600).
func New(baseURL string) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
hc: &http.Client{Timeout: 5 * time.Second},
}
}
// EnsureBucket creates the bucket if absent. It is idempotent: an already-exists
// response (304) is success.
func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error {
body, _ := json.Marshal(map[string]string{
"client": client,
"type": eventType,
"hostname": hostname(),
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified:
return nil
default:
return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode)
}
}
func hostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
return "keel"
}
type eventJSON struct {
Timestamp time.Time `json:"timestamp"`
Duration float64 `json:"duration"`
Data map[string]any `json:"data"`
}
// Insert posts a single event to the bucket. The AW events endpoint takes a
// JSON array, so the event is wrapped in a one-element list.
func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error {
body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode)
}
return nil
}
// Recent returns up to limit events from the bucket, newest first (the AW
// default ordering for the events endpoint).
func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) {
endpoint := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode)
}
var raw []eventJSON
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return nil, err
}
out := make([]Event, len(raw))
for i, e := range raw {
out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}
}
return out, nil
}
+108
View File
@@ -0,0 +1,108 @@
package aw
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestEnsureBucketPostsCreate(t *testing.T) {
var gotPath string
var gotBody map[string]string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
if err := c.EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("EnsureBucket: %v", err)
}
if gotPath != "/api/0/buckets/keel.events" {
t.Fatalf("path = %q", gotPath)
}
if gotBody["type"] != "keel.event" || gotBody["client"] != "keel" {
t.Fatalf("body = %+v", gotBody)
}
}
func TestEnsureBucketTreatsExistsAsOK(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified) // AW returns 304 when the bucket exists
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err != nil {
t.Fatalf("existing bucket should be OK, got %v", err)
}
}
func TestEnsureBucketErrorsOnServerError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
if err := New(srv.URL).EnsureBucket(context.Background(), "keel.events", "keel.event", "keel"); err == nil {
t.Fatal("expected error on 500")
}
}
func TestInsertPostsEventArray(t *testing.T) {
var gotPath string
var gotEvents []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.Method != http.MethodPost {
t.Errorf("method = %q, want POST", r.Method)
}
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotEvents)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := New(srv.URL)
ev := Event{Timestamp: time.Unix(1000, 0).UTC(), Data: map[string]any{"_kind": "proposal_made"}}
if err := c.Insert(context.Background(), "keel.events", ev); err != nil {
t.Fatalf("Insert: %v", err)
}
if gotPath != "/api/0/buckets/keel.events/events" {
t.Fatalf("path = %q", gotPath)
}
if len(gotEvents) != 1 || gotEvents[0]["data"].(map[string]any)["_kind"] != "proposal_made" {
t.Fatalf("events = %+v", gotEvents)
}
}
func TestRecentDecodesNewestFirst(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %q, want GET", r.Method)
}
if got := r.URL.Query().Get("limit"); got != "5" {
t.Errorf("limit = %q, want 5", got)
}
_, _ = io.WriteString(w, `[
{"id":2,"timestamp":"2026-06-05T10:00:00+00:00","duration":0,"data":{"_kind":"proposal_made"}},
{"id":1,"timestamp":"2026-06-04T10:00:00+00:00","duration":0,"data":{"_kind":"action_taken"}}
]`)
}))
defer srv.Close()
got, err := New(srv.URL).Recent(context.Background(), "keel.events", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["_kind"] != "proposal_made" {
t.Fatalf("got = %+v", got)
}
}
+16
View File
@@ -0,0 +1,16 @@
// Package enforce makes drift cost something at the OS level. Tier A minimizes
// the active window when the session is enforcing and the drift judge has
// confirmed the window is off-task. The Guard is a pure OS primitive; all
// policy — whether and when to enforce — lives in the session controller.
package enforce
import "context"
// Guard performs OS-level enforcement actions on demand.
type Guard interface {
// MinimizeActive minimizes the currently-focused window. It is idempotent
// (minimizing an already-minimized window is harmless) and best-effort: it
// returns an error for diagnostics, but callers never block on it and treat
// failure as "enforcement did nothing this time."
MinimizeActive(ctx context.Context) error
}
+14
View File
@@ -0,0 +1,14 @@
package enforce
import "testing"
// NewGuard must return a usable Guard on every platform (real on linux, no-op
// elsewhere). We assert non-nil only: calling MinimizeActive here would touch a
// real X server on linux, which the integration test covers under a DISPLAY
// guard. The behavioural contract is exercised in the session package via a fake
// Guard.
func TestNewGuardReturnsUsableGuard(t *testing.T) {
if g := NewGuard(); g == nil {
t.Fatal("NewGuard returned nil")
}
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux && !windows
package enforce
import "context"
// NewGuard returns a no-op guard on platforms without the X11 adapter.
func NewGuard() Guard { return noopGuard{} }
type noopGuard struct{}
func (noopGuard) MinimizeActive(context.Context) error { return nil }
+22
View File
@@ -0,0 +1,22 @@
//go:build windows
package enforce
import (
"context"
"keel/internal/winapi"
)
// NewGuard returns the Windows window-minimize guard.
func NewGuard() Guard { return windowsGuard{} }
type windowsGuard struct{}
// MinimizeActive minimizes the current foreground window. It is best-effort:
// with nothing focused it does nothing and returns nil. Stateless and per-call,
// with no connection or shared state to manage. ShowWindow does not report a
// minimize failure, so this always returns nil today.
func (windowsGuard) MinimizeActive(_ context.Context) error {
return winapi.MinimizeForeground()
}
+43
View File
@@ -0,0 +1,43 @@
//go:build linux
package enforce
import (
"context"
"fmt"
"github.com/jezek/xgbutil"
"github.com/jezek/xgbutil/ewmh"
"github.com/jezek/xgbutil/icccm"
)
// NewGuard returns the real X11 window-minimize guard.
func NewGuard() Guard { return x11Guard{} }
type x11Guard struct{}
// MinimizeActive iconifies the currently-focused window by sending an ICCCM
// WM_CHANGE_STATE -> IconicState client message to the root window. It opens a
// short-lived X connection per call: enforcement fires at most once per drift
// observation (which is debounce-gated upstream), so there is no shared
// connection or event loop to manage, and nothing to race on shutdown. Any X
// failure is returned for the caller to log; the caller never blocks on it.
func (x11Guard) MinimizeActive(_ context.Context) error {
X, err := xgbutil.NewConn()
if err != nil {
return fmt.Errorf("enforce: cannot connect to X server: %w", err)
}
defer X.Conn().Close()
active, err := ewmh.ActiveWindowGet(X)
if err != nil {
return fmt.Errorf("enforce: no active window: %w", err)
}
if active == 0 {
return nil // nothing focused; nothing to minimize
}
if err := ewmh.ClientEvent(X, active, "WM_CHANGE_STATE", icccm.StateIconic); err != nil {
return fmt.Errorf("enforce: minimize request failed: %w", err)
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
//go:build linux
package enforce
import (
"context"
"os"
"testing"
"time"
)
func TestX11GuardMinimizeActiveDoesNotPanic(t *testing.T) {
if os.Getenv("DISPLAY") == "" {
t.Skip("no DISPLAY; skipping live X11 minimize smoke test")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// Either it minimizes the active window or returns an error (e.g. no active
// window); we only assert it returns without panicking.
if err := NewGuard().MinimizeActive(ctx); err != nil {
t.Logf("MinimizeActive returned (acceptable): %v", err)
}
}
+5 -2
View File
@@ -3,7 +3,7 @@ package evidence
import (
"strings"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
// MatchesAllowed reports whether a window (class/title) is on-task per ctx.
@@ -19,7 +19,10 @@ func windowClassAllowed(ctx domain.AllowedContext, candidate string) bool {
return false
}
for _, allowed := range ctx.WindowClasses {
if normalizeCasefolded(allowed) == candidate {
// Substring, not equality: a short token a user types ("brave") matches the
// longer real WM_CLASS ("Brave-browser"). Equality silently disabled the
// fast path whenever the two differed, routing every window to the judge.
if a := normalizeCasefolded(allowed); a != "" && strings.Contains(candidate, a) {
return true
}
}
+15 -1
View File
@@ -3,7 +3,7 @@ package evidence
import (
"testing"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
@@ -19,6 +19,20 @@ func TestWindowClassMatchesCaseAndTrim(t *testing.T) {
}
}
func TestWindowClassMatchesSubstring(t *testing.T) {
// Users type a short app name ("brave") but the real WM_CLASS is longer
// ("Brave-browser"). The allowed token matching as a substring of the actual
// class keeps the local on-task fast path working instead of silently routing
// every window to the LLM judge.
ctx := domain.AllowedContext{WindowClasses: []string{"brave"}}
if !windowClassAllowed(ctx, "Brave-browser") {
t.Fatal("short allowed token should match the longer real class")
}
if windowClassAllowed(ctx, "firefox") {
t.Fatal("unrelated class must not match")
}
}
func TestWindowTitleMatchesSubstring(t *testing.T) {
ctx := domain.AllowedContext{WindowTitleSubstrings: []string{" antidrift "}}
if !windowTitleAllowed(ctx, "Commitment OS - AntiDrift") {
+26 -7
View File
@@ -6,6 +6,8 @@ package evidence
import (
"context"
"regexp"
"strings"
"unicode"
)
// EvidenceHealth records whether the sensor could observe the active window.
@@ -22,18 +24,35 @@ type WindowSnapshot struct {
}
// Source is the activity port. Watch runs until ctx is cancelled, invoking
// onChange on every active-window change, and once immediately with the
// current window.
// onChange once immediately with the current window and then on every change of
// the active window or its title (a tab switch changes the title but not the
// active window).
type Source interface {
Watch(ctx context.Context, onChange func(WindowSnapshot))
}
// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket
// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl.
// titleNoise matches the clock/ratio/percent runs that pollute bucket
// keys (e.g. "1:23", "50.5%", "-3.0").
var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`)
// ScrubTitle removes volatile numeric runs so window titles bucket stably.
// The raw event log keeps the unscrubbed title; only bucket keys are scrubbed.
// extraSpace collapses the whitespace runs left behind by the scrubs below.
var extraSpace = regexp.MustCompile(`\s+`)
// ScrubTitle normalizes a window title into a stable bucket key. It drops
// volatile numeric runs (clocks, ratios, percentages) and any non-ASCII symbol
// or control rune. The latter covers the animated spinner frames terminal apps
// (e.g. Claude Code) cycle through their title — braille and dingbat glyphs are
// both Unicode symbol category, so a single task no longer fragments into dozens
// of buckets. Letters and digits of every script survive, as do ASCII symbols
// (so "C++" is not mangled). Leftover whitespace is collapsed. The raw event log
// keeps the unscrubbed title; only bucket keys (and their display) are scrubbed.
func ScrubTitle(title string) string {
return titleNoise.ReplaceAllString(title, "")
title = titleNoise.ReplaceAllString(title, "")
title = strings.Map(func(r rune) rune {
if unicode.IsControl(r) || (r > unicode.MaxASCII && unicode.IsSymbol(r)) {
return -1
}
return r
}, title)
return strings.TrimSpace(extraSpace.ReplaceAllString(title, " "))
}
+8 -1
View File
@@ -11,9 +11,16 @@ func TestScrubTitle(t *testing.T) {
{"Plain title", "Plain title"}, // no digits-with-separator: untouched
{"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory)
{"12.5%", ""}, // percent decimal: stripped whole
{"1:23:45 remaining", " remaining"}, // clock ratio: stripped
{"1:23:45 remaining", "remaining"}, // clock ratio: stripped, space collapsed
{"-3.0 delta", "delta"}, // leading sign + decimal: stripped
{"Download 50.5% complete", "Download complete"}, // embedded percent decimal
{"⠸ antidrift", "antidrift"}, // braille spinner frame stripped
{"⠼ antidrift", "antidrift"}, // different frame collapses to same key
{"✳ Check latest commit date", "Check latest commit date"}, // dingbat-star indicator
{"⠂ Check latest commit date", "Check latest commit date"}, // same task, different frame
{"✳ ⠼ done", "done"}, // multiple glyphs in one title
{"C++ build", "C++ build"}, // ASCII symbols kept (not all-symbols)
{"日本語 window", "日本語 window"}, // non-ASCII letters preserved
}
for _, c := range cases {
if got := ScrubTitle(c.in); got != c.want {
+23
View File
@@ -0,0 +1,23 @@
package evidence
// foregroundTracker remembers the last observed foreground window so the
// Windows polling Source emits only on change. hwnd is held as uintptr so this
// file stays platform-neutral (it must build and test on Linux).
type foregroundTracker struct {
primed bool
available bool
hwnd uintptr
title string
}
// changed reports whether (available, hwnd, title) differs from the last
// observation and records the new values. The first call always returns true.
// Callers pass the zero values (hwnd 0, title "") when available is false, so a
// steady "no foreground window" run does not re-emit.
func (t *foregroundTracker) changed(available bool, hwnd uintptr, title string) bool {
if t.primed && available == t.available && hwnd == t.hwnd && title == t.title {
return false
}
t.primed, t.available, t.hwnd, t.title = true, available, hwnd, title
return true
}
@@ -0,0 +1,29 @@
package evidence
import "testing"
func TestForegroundTrackerChanged(t *testing.T) {
var tr foregroundTracker
if !tr.changed(true, 100, "A") {
t.Fatal("first observation should always report changed")
}
if tr.changed(true, 100, "A") {
t.Error("identical observation should not report changed")
}
if !tr.changed(true, 100, "B") {
t.Error("title change on same hwnd should report changed")
}
if !tr.changed(true, 200, "B") {
t.Error("hwnd change should report changed")
}
if !tr.changed(false, 0, "") {
t.Error("transition to unavailable should report changed")
}
if tr.changed(false, 0, "") {
t.Error("repeated unavailable should not report changed")
}
if !tr.changed(true, 200, "B") {
t.Error("transition back to available should report changed")
}
}
+44
View File
@@ -0,0 +1,44 @@
package evidence
import (
"context"
"time"
)
// pollLoop samples the active window via read and forwards snapshots through
// onChange, but only when the observation changes — a different window OR a
// different title. The title case is the one that matters: switching a browser
// tab changes the window title without changing the active window, so a sensor
// that re-reads only on active-window events never sees it and credits the stale
// title. Polling re-reads on a fixed cadence, so a tab switch is caught within
// one interval.
//
// It emits once immediately, then samples every interval, until ctx is
// cancelled. WindowSnapshot is comparable, so the change check is a plain
// equality. Factoring the loop here keeps the change-dedup identical across
// sensors and lets it be tested without a display. (The Windows sensor predates
// this and keeps its own equivalent loop.)
func pollLoop(ctx context.Context, interval time.Duration, read func() WindowSnapshot, onChange func(WindowSnapshot)) {
var last WindowSnapshot
var haveLast bool
emit := func() {
s := read()
if haveLast && s == last {
return
}
last, haveLast = s, true
onChange(s)
}
emit() // immediate current window
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
emit()
}
}
}
+95
View File
@@ -0,0 +1,95 @@
package evidence
import (
"context"
"sync"
"testing"
"time"
)
// TestPollLoopEmitsOnTitleChangeWithinSameWindow is the regression for the bug
// where the X11 sensor only re-read the window on _NET_ACTIVE_WINDOW changes and
// so missed a browser tab switch — same window class, new title — crediting all
// the time to the stale title. The poll loop must emit when only the title
// changes.
func TestPollLoopEmitsOnTitleChangeWithinSameWindow(t *testing.T) {
// Same window (class) throughout; the title changes mid-stream, like a tab
// switch. The repeated identical reads must NOT re-emit; the title change must.
titles := []string{"Keel - Brave", "Keel - Brave", "Consume - Brave", "Consume - Brave"}
var i int
read := func() WindowSnapshot {
idx := i
if idx >= len(titles) {
idx = len(titles) - 1
}
i++
return WindowSnapshot{Class: "Brave-browser", Title: titles[idx], Health: EvidenceHealth{Available: true}}
}
var mu sync.Mutex
var got []WindowSnapshot
onChange := func(s WindowSnapshot) {
mu.Lock()
got = append(got, s)
mu.Unlock()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
deadline := time.Now().Add(2 * time.Second)
for {
mu.Lock()
n := len(got)
mu.Unlock()
if n >= 2 {
break
}
if time.Now().After(deadline) {
cancel()
<-done
t.Fatalf("expected >=2 emits (one per distinct title), got %d", n)
}
time.Sleep(time.Millisecond)
}
cancel()
<-done
mu.Lock()
defer mu.Unlock()
if got[0].Title != "Keel - Brave" {
t.Fatalf("first emit title = %q, want %q", got[0].Title, "Keel - Brave")
}
if got[1].Title != "Consume - Brave" {
t.Fatalf("second emit title = %q, want %q (a title-only change must emit)", got[1].Title, "Consume - Brave")
}
}
// TestPollLoopDedupesIdenticalObservations confirms an unchanged observation is
// not re-emitted, so redundant samples never churn the consumer.
func TestPollLoopDedupesIdenticalObservations(t *testing.T) {
read := func() WindowSnapshot {
return WindowSnapshot{Class: "code", Title: "main.go", Health: EvidenceHealth{Available: true}}
}
var mu sync.Mutex
var n int
onChange := func(WindowSnapshot) {
mu.Lock()
n++
mu.Unlock()
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { pollLoop(ctx, time.Millisecond, read, onChange); close(done) }()
time.Sleep(50 * time.Millisecond)
cancel()
<-done
mu.Lock()
defer mu.Unlock()
if n != 1 {
t.Fatalf("identical observations must emit once, got %d emits", n)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !linux
//go:build !linux && !windows
package evidence
+10
View File
@@ -0,0 +1,10 @@
package evidence
import "log"
// unavailable builds an Unavailable snapshot and logs the reason. It is shared
// by every platform sensor so an unobservable window is recorded consistently.
func unavailable(reason string) WindowSnapshot {
log.Printf("evidence: %s", reason)
return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}}
}
+51
View File
@@ -0,0 +1,51 @@
//go:build windows
package evidence
import (
"context"
"time"
"keel/internal/winapi"
)
// pollInterval is how often the Windows sensor samples the foreground window.
// ~1s latency on a window switch is immaterial for a focus tracker, and polling
// avoids the message-loop/callback machinery a SetWinEventHook source needs.
const pollInterval = 750 * time.Millisecond
// NewSource returns the Windows active-window sensor (polling).
func NewSource() Source { return windowsSource{} }
type windowsSource struct{}
// Watch emits the current window immediately, then samples every pollInterval,
// emitting only when the foreground window or its title changes. A read with no
// foreground window yields an Unavailable snapshot (once, until it recovers). It
// runs until ctx is cancelled. It never panics the daemon.
func (windowsSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
var tr foregroundTracker
poll := func() {
hwnd, title, class, ok := winapi.ForegroundWindow()
if !tr.changed(ok, hwnd, title) {
return
}
if !ok {
onChange(unavailable("no foreground window"))
return
}
onChange(WindowSnapshot{Title: title, Class: class, Health: EvidenceHealth{Available: true}})
}
poll() // immediate current window
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
poll()
}
}
}
+15 -43
View File
@@ -4,25 +4,29 @@ package evidence
import (
"context"
"log"
"time"
"github.com/jezek/xgb/xproto"
"github.com/jezek/xgbutil"
"github.com/jezek/xgbutil/ewmh"
"github.com/jezek/xgbutil/icccm"
"github.com/jezek/xgbutil/xevent"
"github.com/jezek/xgbutil/xprop"
"github.com/jezek/xgbutil/xwindow"
)
// pollInterval is how often the X11 sensor samples the active window. ~1s
// latency on a switch is immaterial for a focus tracker, and polling is what
// catches a title change inside the same window — e.g. switching a browser tab,
// which changes the title but not the active window, so an _NET_ACTIVE_WINDOW
// event subscription would miss it and credit the stale title. Mirrors the
// Windows sensor's polling cadence.
const pollInterval = 750 * time.Millisecond
// NewSource returns the real X11 active-window sensor.
func NewSource() Source { return &x11Source{} }
type x11Source struct{}
// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW
// changes on the root window, and emits a snapshot immediately plus on every
// change. Any failure degrades to an Unavailable snapshot; it never panics the
// Watch opens one long-lived X connection and samples the active window every
// pollInterval, emitting a snapshot whenever the active window OR its title
// changes. Any failure degrades to an Unavailable snapshot; it never panics the
// daemon.
func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
X, err := xgbutil.NewConn()
@@ -33,38 +37,11 @@ func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) {
}
defer X.Conn().Close()
root := X.RootWin()
activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW")
if err != nil {
onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error()))
<-ctx.Done()
return
}
// Listen for property changes on the root window.
if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil {
onChange(unavailable("cannot listen on root window: " + err.Error()))
<-ctx.Done()
return
}
emit := func() { onChange(snapshot(X)) }
emit() // immediate current window
xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) {
if ev.Atom == activeAtom {
emit()
}
}).Connect(X, root)
// Run the event loop until ctx is cancelled.
go func() {
<-ctx.Done()
xevent.Quit(X)
}()
xevent.Main(X)
pollLoop(ctx, pollInterval, func() WindowSnapshot { return snapshot(X) }, onChange)
}
// snapshot reads the current active window's title and class. A missing active
// window (or read error) yields an Unavailable snapshot.
func snapshot(X *xgbutil.XUtil) WindowSnapshot {
active, err := ewmh.ActiveWindowGet(X)
if err != nil || active == 0 {
@@ -87,8 +64,3 @@ func snapshot(X *xgbutil.XUtil) WindowSnapshot {
Health: EvidenceHealth{Available: true},
}
}
func unavailable(reason string) WindowSnapshot {
log.Printf("evidence: %s", reason)
return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}}
}
+139
View File
@@ -0,0 +1,139 @@
// internal/frame/frame.go
// Package frame assembles the standing "what matters to Felix" brief — today's
// tasks plus the ~/owc goals and life-domain notes — that grounds a brain call.
// It is shared by the off-screen mode (which adds proposal history) and the
// ambient sentinel. Best-effort throughout: it never errors and tolerates nil
// ports and missing files.
package frame
import (
"context"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"keel/internal/knowledge"
"keel/internal/tasks"
)
// goalsPath is the standing-goals note loaded for the brief.
const goalsPath = "~/owc/goals-2026.md"
// bugGlob matches the life-domain ("life-bug") notes under ~/owc/resources.
const bugGlob = "bug-*.md"
// MaxBriefBytes caps the assembled brief so the prompt stays bounded.
const MaxBriefBytes = 8 * 1024
// Assemble builds the frame brief from today's tasks, the standing goals note,
// and the life-domain notes. It never panics and never errors; nil ports and
// missing files degrade to a short, mostly-empty brief.
func Assemble(ctx context.Context, know knowledge.Source, tasksProvider tasks.Provider) string {
var b strings.Builder
b.WriteString("## Today's tasks\n")
b.WriteString(briefTasks(ctx, tasksProvider))
b.WriteString("\n")
if goals := briefGoals(ctx, know); goals != "" {
b.WriteString("## Goals\n")
b.WriteString(goals)
b.WriteString("\n\n")
}
if domains := briefLifeDomains(ctx, know); domains != "" {
b.WriteString("## Life domains\n")
b.WriteString(domains)
b.WriteString("\n")
}
return Truncate(b.String(), MaxBriefBytes)
}
// briefTasks lists today's task titles, or "(none)" when the port is absent,
// errors, or returns nothing.
func briefTasks(ctx context.Context, tp tasks.Provider) string {
if tp == nil {
return "(none)\n"
}
list, err := tp.Today(ctx)
if err != nil || len(list) == 0 {
return "(none)\n"
}
var b strings.Builder
for _, t := range list {
title := strings.TrimSpace(t.Title)
if title == "" {
continue
}
b.WriteString("- ")
b.WriteString(title)
b.WriteString("\n")
}
if b.Len() == 0 {
return "(none)\n"
}
return b.String()
}
// briefGoals returns the goals note text, or "" when the port is absent or the
// file is missing/empty.
func briefGoals(ctx context.Context, know knowledge.Source) string {
if know == nil {
return ""
}
p, err := know.Load(ctx, goalsPath)
if err != nil {
return ""
}
return strings.TrimSpace(p.Text)
}
// briefLifeDomains loads every ~/owc/resources/bug-*.md note via the knowledge
// port (so reads honor its truncation) and concatenates their text. Returns ""
// when the port is absent or no notes are readable.
func briefLifeDomains(ctx context.Context, know knowledge.Source) string {
if know == nil {
return ""
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
matches, err := filepath.Glob(filepath.Join(home, "owc", "resources", bugGlob))
if err != nil || len(matches) == 0 {
return ""
}
var b strings.Builder
for _, path := range matches {
p, err := know.Load(ctx, path)
if err != nil {
continue
}
text := strings.TrimSpace(p.Text)
if text == "" {
continue
}
b.WriteString("### ")
b.WriteString(filepath.Base(path))
b.WriteString("\n")
b.WriteString(text)
b.WriteString("\n\n")
}
return strings.TrimRight(b.String(), "\n")
}
// Truncate clips s to at most max bytes with a marker, backing up to a rune
// boundary so it never splits a multibyte rune.
func Truncate(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)"
}
+70
View File
@@ -0,0 +1,70 @@
// internal/frame/frame_test.go
package frame
import (
"context"
"strings"
"testing"
"keel/internal/knowledge"
"keel/internal/tasks"
)
// fakeTasks is a tasks.Provider returning a fixed list.
type fakeTasks struct{ list []tasks.Task }
// emptyKnowledge is a knowledge.Source that returns no text for any path, so
// Assemble emits neither a Goals nor a Life-domains section regardless of what
// files exist on the host.
type emptyKnowledge struct{}
func (emptyKnowledge) Load(context.Context, string) (knowledge.Profile, error) {
return knowledge.Profile{}, nil
}
func (f fakeTasks) Today(context.Context) ([]tasks.Task, error) { return f.list, nil }
func (f fakeTasks) Create(context.Context, tasks.Task) error { return nil }
func TestAssembleListsTodaysTasks(t *testing.T) {
tp := fakeTasks{list: []tasks.Task{{Title: "write the keel spec"}}}
got := Assemble(context.Background(), nil, tp)
if !strings.Contains(got, "## Today's tasks") {
t.Fatalf("missing tasks header:\n%s", got)
}
if !strings.Contains(got, "write the keel spec") {
t.Fatalf("missing task title:\n%s", got)
}
}
// With no knowledge source and no tasks, Assemble degrades to a short brief and
// never panics — both ports may be nil.
func TestAssembleToleratesNilPorts(t *testing.T) {
got := Assemble(context.Background(), nil, nil)
if !strings.Contains(got, "## Today's tasks") {
t.Fatalf("expected a tasks header even with no ports:\n%s", got)
}
if !strings.Contains(got, "(none)") {
t.Fatalf("expected (none) for absent tasks:\n%s", got)
}
}
func TestAssembleWithEmptyKnowledge(t *testing.T) {
got := Assemble(context.Background(), emptyKnowledge{}, fakeTasks{})
if strings.Contains(got, "## Goals") {
t.Fatalf("empty knowledge should not emit a Goals section:\n%s", got)
}
if strings.Contains(got, "## Life domains") {
t.Fatalf("empty knowledge should not emit a Life domains section:\n%s", got)
}
}
func TestTruncateClipsWithMarker(t *testing.T) {
in := strings.Repeat("x", MaxBriefBytes+100)
got := Truncate(in, MaxBriefBytes)
if len(got) > MaxBriefBytes+len("\n…(truncated)") {
t.Fatalf("truncate did not clip: len=%d", len(got))
}
if !strings.HasSuffix(got, "(truncated)") {
t.Fatalf("missing truncation marker: %q", got[len(got)-20:])
}
}
+44
View File
@@ -0,0 +1,44 @@
// Package harness is the generic host: it owns shared services and exactly one
// active mode, and routes evidence, expiry, and commands to it.
package harness
import (
"context"
"sync"
"time"
)
// Async runs generation-guarded background work for a mode. It preserves the
// lock discipline of the original session.runFetchAsync: fetch runs with no
// lock held; stale and apply run under the mode's mutex; notify fires once,
// after the lock is released.
type Async struct {
mu *sync.Mutex
notify func()
}
// NewAsync binds the helper to a mode's mutex and the harness change-notify.
func NewAsync(mu *sync.Mutex, notify func()) Async {
return Async{mu: mu, notify: notify}
}
// Run launches a background fetch. stale returns true to DISCARD the result
// (a newer generation superseded it). apply records a non-stale result under
// the mutex and must NOT call notify — Run owns the post-unlock notify.
func (a Async) Run(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fetch(ctx)
a.mu.Lock()
if stale() {
a.mu.Unlock()
return
}
apply()
a.mu.Unlock()
if a.notify != nil {
a.notify()
}
}()
}
+48
View File
@@ -0,0 +1,48 @@
package harness
import (
"context"
"sync"
"testing"
"time"
)
func TestAsyncRunAppliesAndNotifies(t *testing.T) {
var mu sync.Mutex
notified := make(chan struct{}, 1)
a := NewAsync(&mu, func() { notified <- struct{}{} })
var applied bool
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { return false },
func() { applied = true },
)
select {
case <-notified:
case <-time.After(time.Second):
t.Fatal("notify never fired")
}
mu.Lock()
defer mu.Unlock()
if !applied {
t.Fatal("apply did not run")
}
}
func TestAsyncRunStaleSkipsApplyAndNotify(t *testing.T) {
var mu sync.Mutex
a := NewAsync(&mu, func() { t.Fatal("notify fired on stale result") })
done := make(chan struct{})
a.Run(time.Second,
func(ctx context.Context) {},
func() bool { close(done); return true },
func() { t.Fatal("apply ran on stale result") },
)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("stale guard never evaluated")
}
time.Sleep(20 * time.Millisecond) // allow a wrongful notify to surface
}
+187
View File
@@ -0,0 +1,187 @@
package harness
import (
"context"
"encoding/json"
"errors"
"sync"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
var (
ErrBusy = errors.New("harness: another mode is active")
ErrUnknownMode = errors.New("harness: unknown mode")
ErrIdle = errors.New("harness: no active mode")
)
// Factory builds a mode from the shared services, in its initial active state.
type Factory func(Services) mode.Mode
type Harness struct {
mu sync.Mutex
active mode.Mode
factories map[string]Factory
services Services
onChange []func()
}
// New wires Services.Notify to the harness so every mode's async completion
// reaches all listeners, then stores the services for the factories to receive.
func New(services Services, factories map[string]Factory) *Harness {
h := &Harness{factories: factories}
services.Notify = h.notify
h.services = services
return h
}
// AddOnChange registers a change listener (web broadcaster, status writer).
func (h *Harness) AddOnChange(f func()) {
h.mu.Lock()
h.onChange = append(h.onChange, f)
h.mu.Unlock()
}
// SetServices swaps the services future Start calls hand to factories. In v1 it
// affects the NEXT mode start only; an already-active mode keeps the services it
// was built with (settings changes take effect on the next session).
func (h *Harness) SetServices(s Services) {
s.Notify = h.notify
h.mu.Lock()
h.services = s
h.mu.Unlock()
}
// Services returns the shared services the harness hands to factories, with
// Notify wired to the harness change fan-out. Use it to build a mode for Adopt
// (e.g. startup restoration) so the restored mode's async work reaches the
// harness's onChange listeners.
func (h *Harness) Services() Services {
h.mu.Lock()
defer h.mu.Unlock()
return h.services
}
func (h *Harness) notify() {
h.mu.Lock()
fs := append([]func(){}, h.onChange...)
h.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// Adopt installs an already-constructed mode as active (startup restoration).
// It fails if a mode is already active.
func (h *Harness) Adopt(m mode.Mode) error {
h.mu.Lock()
if h.active != nil {
h.mu.Unlock()
return ErrBusy
}
h.active = m
h.mu.Unlock()
h.notify()
return nil
}
// Start activates a mode by kind. Idempotent if that kind is already active;
// ErrBusy if a different kind is active.
func (h *Harness) Start(kind string) error {
h.mu.Lock()
if h.active != nil {
busy := h.active.Kind() != kind
h.mu.Unlock()
if busy {
return ErrBusy
}
return nil
}
f, ok := h.factories[kind]
if !ok {
h.mu.Unlock()
return ErrUnknownMode
}
h.active = f(h.services)
h.mu.Unlock()
h.notify()
return nil
}
// Command routes to the active mode, then releases it if it went inactive.
func (h *Harness) Command(ctx context.Context, name string, payload json.RawMessage) error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return ErrIdle
}
if err := m.Command(ctx, name, payload); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
func (h *Harness) releaseIfDone() {
h.mu.Lock()
if h.active != nil && !h.active.Active() {
h.active = nil
}
h.mu.Unlock()
}
// State returns the surfacing envelope.
func (h *Harness) State() mode.Envelope {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if m == nil {
return mode.Envelope{}
}
return mode.Envelope{ActiveMode: m.Kind(), Mode: m.View()}
}
// RecordWindow forwards a window snapshot iff the active mode consumes evidence.
func (h *Harness) RecordWindow(w evidence.WindowSnapshot) {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if c, ok := m.(mode.EvidenceConsumer); ok {
c.OnWindow(w)
}
}
// Deadline returns the active mode's deadline iff it is an Expirer.
func (h *Harness) Deadline() time.Time {
h.mu.Lock()
m := h.active
h.mu.Unlock()
if e, ok := m.(mode.Expirer); ok {
return e.Deadline()
}
return time.Time{}
}
// Expire fires the active mode's expiry iff it is an Expirer, then releases it
// if it went inactive.
func (h *Harness) Expire() error {
h.mu.Lock()
m := h.active
h.mu.Unlock()
e, ok := m.(mode.Expirer)
if !ok {
return ErrIdle
}
if err := e.Expire(); err != nil {
return err
}
h.releaseIfDone()
h.notify()
return nil
}
+156
View File
@@ -0,0 +1,156 @@
package harness
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"keel/internal/evidence"
"keel/internal/mode"
)
// fakeMode implements Mode and (optionally) the capability interfaces.
type fakeMode struct {
mu sync.Mutex
kind string
active bool
cmds []string
windows int
deadline time.Time
expired bool
evidence bool // implements EvidenceConsumer when true
expirer bool // implements Expirer when true
}
func (f *fakeMode) Kind() string { return f.kind }
func (f *fakeMode) View() any { return map[string]bool{"active": f.active} }
func (f *fakeMode) Active() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.active }
func (f *fakeMode) Command(_ context.Context, name string, _ json.RawMessage) error {
f.mu.Lock()
defer f.mu.Unlock()
f.cmds = append(f.cmds, name)
if name == "finish" {
f.active = false
}
return nil
}
type evidenceMode struct{ *fakeMode }
func (e evidenceMode) OnWindow(evidence.WindowSnapshot) { e.mu.Lock(); e.windows++; e.mu.Unlock() }
type expirerMode struct{ *fakeMode }
func (e expirerMode) Deadline() time.Time { return e.deadline }
func (e expirerMode) Expire() error {
e.mu.Lock()
e.expired = true
e.active = false
e.mu.Unlock()
return nil
}
func newHarness(m mode.Mode) *Harness {
return New(Services{Clock: time.Now}, map[string]Factory{
m.Kind(): func(Services) mode.Mode { return m },
})
}
func TestStartSingleActiveInvariant(t *testing.T) {
h := New(Services{}, map[string]Factory{
"a": func(Services) mode.Mode { return &fakeMode{kind: "a", active: true} },
"b": func(Services) mode.Mode { return &fakeMode{kind: "b", active: true} },
})
if err := h.Start("a"); err != nil {
t.Fatalf("Start a: %v", err)
}
if err := h.Start("a"); err != nil {
t.Fatalf("re-Start a should be idempotent: %v", err)
}
if err := h.Start("b"); err != ErrBusy {
t.Fatalf("Start b while a active = %v, want ErrBusy", err)
}
if got := h.State().ActiveMode; got != "a" {
t.Fatalf("ActiveMode = %q, want a", got)
}
}
func TestCommandReleasesWhenInactive(t *testing.T) {
m := &fakeMode{kind: "a", active: true}
h := newHarness(m)
_ = h.Start("a")
if err := h.Command(context.Background(), "finish", nil); err != nil {
t.Fatalf("Command: %v", err)
}
if got := h.State().ActiveMode; got != "" {
t.Fatalf("after finish ActiveMode = %q, want idle", got)
}
}
func TestRecordWindowOnlyToEvidenceConsumers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
h.RecordWindow(evidence.WindowSnapshot{}) // must be a no-op, no panic
if plain.windows != 0 {
t.Fatalf("plain mode received %d windows, want 0", plain.windows)
}
ev := evidenceMode{&fakeMode{kind: "ev", active: true}}
h2 := newHarness(ev)
_ = h2.Start("ev")
h2.RecordWindow(evidence.WindowSnapshot{})
if ev.windows != 1 {
t.Fatalf("evidence mode received %d windows, want 1", ev.windows)
}
}
func TestExpiryOnlyForExpirers(t *testing.T) {
plain := &fakeMode{kind: "plain", active: true}
h := newHarness(plain)
_ = h.Start("plain")
if err := h.Expire(); err != ErrIdle {
t.Fatalf("Expire on non-expirer = %v, want ErrIdle", err)
}
if !h.Deadline().IsZero() {
t.Fatal("non-expirer Deadline should be zero")
}
ex := expirerMode{&fakeMode{kind: "ex", active: true, deadline: time.Unix(100, 0)}}
h2 := newHarness(ex)
_ = h2.Start("ex")
if got := h2.Deadline(); !got.Equal(time.Unix(100, 0)) {
t.Fatalf("Deadline = %v, want unix 100", got)
}
if err := h2.Expire(); err != nil {
t.Fatalf("Expire: %v", err)
}
if got := h2.State().ActiveMode; got != "" {
t.Fatalf("after Expire ActiveMode = %q, want idle", got)
}
}
func TestCommandWhileIdle(t *testing.T) {
h := New(Services{}, map[string]Factory{})
if err := h.Command(context.Background(), "x", nil); err != ErrIdle {
t.Fatalf("Command while idle = %v, want ErrIdle", err)
}
}
func TestServicesNotifyReachesOnChangeListeners(t *testing.T) {
h := New(Services{}, map[string]Factory{})
fired := make(chan struct{}, 1)
h.AddOnChange(func() { fired <- struct{}{} })
svc := h.Services()
if svc.Notify == nil {
t.Fatal("Services().Notify is nil; restore path would not propagate updates")
}
svc.Notify() // simulate a restored mode's async completion firing notify
select {
case <-fired:
case <-time.After(time.Second):
t.Fatal("Services().Notify did not reach the registered onChange listener")
}
}
+27
View File
@@ -0,0 +1,27 @@
package harness
import (
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/tasks"
)
// Services is the shared infrastructure every mode receives from the harness.
// Dir is the mode's namespaced persistence directory (~/.keel/modes/<kind>);
// modes build their own file paths under it. Notify fires the harness change
// listeners (web SSE + status bar). Memory is Keel's durable cross-run event
// store (AW-backed, or a nop when AW is down).
type Services struct {
AI *ai.Service
Tasks tasks.Provider
Knowledge knowledge.Source
Enforce enforce.Guard
Memory memory.Store
Clock func() time.Time
Dir string
Notify func()
}
+92
View File
@@ -0,0 +1,92 @@
package knowledge
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
// maxProfileBytes caps the grounding text so the coach prompt stays bounded.
const maxProfileBytes = 6 * 1024
// FileSource reads the user's profile from a single file. It is stateless: the
// selected path is passed to Load, so the controller can repoint it at runtime
// without a mutable field.
type FileSource struct {
defaultPath string // used when Load is called with path == ""
}
// NewFileSource builds the adapter. defaultPath is used when Load receives an
// empty path; if it too is empty, Load falls back to ~/.keel/knowledge.md.
func NewFileSource(defaultPath string) *FileSource {
return &FileSource{defaultPath: defaultPath}
}
// Load reads the profile at path (or the default). A missing file yields an
// empty-Text Profile and no error; only a real read failure errors. The
// resolved absolute path is always returned for display.
func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) {
resolved := s.resolve(path)
data, err := os.ReadFile(resolved)
if errors.Is(err, fs.ErrNotExist) {
return Profile{Path: resolved}, nil
}
if err != nil {
return Profile{Path: resolved}, fmt.Errorf("knowledge: read %s: %w", resolved, err)
}
text := strings.TrimSpace(string(data))
if text == "" {
return Profile{Path: resolved}, nil
}
return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil
}
// resolve picks path, else the default, else ~/.keel/knowledge.md; expands
// a leading ~; and makes the result absolute for stable display.
func (s *FileSource) resolve(path string) string {
p := path
if p == "" {
p = s.defaultPath
}
if p == "" {
if home, err := os.UserHomeDir(); err == nil {
p = filepath.Join(home, ".keel", "knowledge.md")
}
}
p = expandTilde(p)
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return p
}
func expandTilde(p string) string {
if p != "~" && !strings.HasPrefix(p, "~/") {
return p
}
home, err := os.UserHomeDir()
if err != nil {
return p
}
if p == "~" {
return home
}
return filepath.Join(home, p[2:])
}
// truncate clips s to max bytes on a UTF-8 rune boundary and appends a marker.
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut] + "\n…(truncated)"
}
+98
View File
@@ -0,0 +1,98 @@
package knowledge
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadPresentFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "knowledge.md")
if err := os.WriteFile(p, []byte(" I am a focus-tool author.\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := NewFileSource(p).Load(context.Background(), "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Text != "I am a focus-tool author." {
t.Errorf("Text = %q (whitespace should be trimmed)", got.Text)
}
if got.Path != p {
t.Errorf("Path = %q, want %q", got.Path, p)
}
}
func TestLoadAbsentFileIsNotError(t *testing.T) {
p := filepath.Join(t.TempDir(), "missing.md")
got, err := NewFileSource(p).Load(context.Background(), "")
if err != nil {
t.Fatalf("absent file must not error: %v", err)
}
if got.Text != "" {
t.Errorf("Text = %q, want empty for absent file", got.Text)
}
if got.Path != p {
t.Errorf("Path = %q, want %q even when absent", got.Path, p)
}
}
func TestLoadExplicitPathBeatsDefault(t *testing.T) {
dir := t.TempDir()
def := filepath.Join(dir, "default.md")
other := filepath.Join(dir, "other.md")
if err := os.WriteFile(def, []byte("default"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(other, []byte("explicit"), 0o644); err != nil {
t.Fatal(err)
}
got, err := NewFileSource(def).Load(context.Background(), other)
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Text != "explicit" || got.Path != other {
t.Fatalf("explicit path ignored: %+v", got)
}
}
func TestLoadTruncatesOversize(t *testing.T) {
p := filepath.Join(t.TempDir(), "big.md")
if err := os.WriteFile(p, []byte(strings.Repeat("a", maxProfileBytes+500)), 0o644); err != nil {
t.Fatal(err)
}
got, err := NewFileSource(p).Load(context.Background(), "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(got.Text) > maxProfileBytes+len("\n…(truncated)") {
t.Errorf("Text not truncated: %d bytes", len(got.Text))
}
if !strings.HasSuffix(got.Text, "(truncated)") {
t.Errorf("missing truncation marker: %q", got.Text[len(got.Text)-20:])
}
}
func TestLoadWhitespaceOnlyIsAbsent(t *testing.T) {
p := filepath.Join(t.TempDir(), "blank.md")
if err := os.WriteFile(p, []byte(" \n\t\n"), 0o644); err != nil {
t.Fatal(err)
}
got, err := NewFileSource(p).Load(context.Background(), "")
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.Text != "" {
t.Errorf("whitespace-only should be empty, got %q", got.Text)
}
}
func TestLoadReadErrorIsError(t *testing.T) {
dir := t.TempDir() // a directory is not readable as a file
if _, err := NewFileSource(dir).Load(context.Background(), ""); err == nil {
t.Fatal("reading a directory should error")
}
}
+23
View File
@@ -0,0 +1,23 @@
// Package knowledge is the Knowledge port: it answers "who am I; what are my
// priorities?" by loading the user's standing profile. It imports nothing from
// the rest of the app, so it stays a leaf package.
package knowledge
import "context"
// Profile is the user's standing context. Primitives only, so knowledge stays a
// leaf package.
type Profile struct {
Text string // grounding text; "" when no profile is available
Path string // resolved source location, for display
}
// Source answers "who am I; what are my priorities?" — the user's standing
// profile that grounds the advisor.
type Source interface {
// Load returns the user's profile. path selects an explicit location; ""
// means the adapter's configured default. A missing source is NOT an error:
// it yields an empty-Text Profile so the caller degrades to ungrounded. Only
// a real read failure (permissions, unreadable) returns an error.
Load(ctx context.Context, path string) (Profile, error)
}
+132
View File
@@ -0,0 +1,132 @@
// Package memory is Keel's durable, cross-run memory port. Modes Record derived
// events and read them back with Recent; the AW-backed adapter persists them to
// an ActivityWatch bucket. Event-kind names are owned by the modes that write
// them — this package stays a generic event store.
package memory
import (
"context"
"sync"
"time"
"keel/internal/aw"
)
// Event is one derived event Keel chooses to remember. Kind is the event type
// (e.g. "proposal_made"); Data carries pointers and small values, not copies of
// other tools' source of truth.
type Event struct {
Kind string
At time.Time
Data map[string]any
}
// Store records derived events and reads recent ones back by kind.
type Store interface {
Record(ctx context.Context, e Event) error
Recent(ctx context.Context, kind string, n int) ([]Event, error)
}
// nopStore is the no-memory fallback used when AW is unreachable or disabled.
type nopStore struct{}
// NewNop returns a Store that drops writes and returns no history.
func NewNop() Store { return nopStore{} }
func (nopStore) Record(context.Context, Event) error { return nil }
func (nopStore) Recent(context.Context, string, int) ([]Event, error) { return nil, nil }
// Fake is an in-memory Store for tests.
type Fake struct {
mu sync.Mutex
events []Event
}
// NewFake returns an empty in-memory Store.
func NewFake() *Fake { return &Fake{} }
func (f *Fake) Record(_ context.Context, e Event) error {
f.mu.Lock()
f.events = append(f.events, e)
f.mu.Unlock()
return nil
}
func (f *Fake) Recent(_ context.Context, kind string, n int) ([]Event, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Event
for i := len(f.events) - 1; i >= 0 && len(out) < n; i-- {
if f.events[i].Kind == kind {
out = append(out, f.events[i])
}
}
return out, nil
}
// Events returns a snapshot of everything recorded, for test assertions.
func (f *Fake) Events() []Event {
f.mu.Lock()
defer f.mu.Unlock()
return append([]Event(nil), f.events...)
}
// overfetch bounds how many recent bucket events awStore pulls before filtering
// by kind, because keel.events interleaves all kinds. Events older than this
// window are not seen — acceptable for "recent".
const overfetch = 200
// kindKey is the data field carrying the Event.Kind inside an AW event.
const kindKey = "_kind"
// AWClient is the subset of *aw.Client that awStore needs. Declaring it here
// lets tests inject a fake without HTTP; *aw.Client satisfies it structurally.
type AWClient interface {
Insert(ctx context.Context, bucketID string, e aw.Event) error
Recent(ctx context.Context, bucketID string, limit int) ([]aw.Event, error)
}
type awStore struct {
c AWClient
bucketID string
}
// NewAWStore returns a Store that persists events to the given AW bucket.
func NewAWStore(c AWClient, bucketID string) Store {
return &awStore{c: c, bucketID: bucketID}
}
func (s *awStore) Record(ctx context.Context, e Event) error {
data := make(map[string]any, len(e.Data)+1)
for k, v := range e.Data {
data[k] = v
}
data[kindKey] = e.Kind
return s.c.Insert(ctx, s.bucketID, aw.Event{Timestamp: e.At, Duration: 0, Data: data})
}
func (s *awStore) Recent(ctx context.Context, kind string, n int) ([]Event, error) {
if n <= 0 {
return nil, nil
}
raw, err := s.c.Recent(ctx, s.bucketID, overfetch)
if err != nil {
return nil, err
}
var out []Event
for _, ev := range raw {
if len(out) >= n {
break
}
if k, _ := ev.Data[kindKey].(string); k == kind {
data := make(map[string]any, len(ev.Data))
for dk, dv := range ev.Data {
if dk != kindKey {
data[dk] = dv
}
}
out = append(out, Event{Kind: kind, At: ev.Timestamp, Data: data})
}
}
return out, nil
}
+115
View File
@@ -0,0 +1,115 @@
package memory
import (
"context"
"testing"
"time"
"keel/internal/aw"
)
func TestFakeRecordThenRecentNewestFirstByKind(t *testing.T) {
f := NewFake()
ctx := context.Background()
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(1, 0), Data: map[string]any{"n": "a"}})
_ = f.Record(ctx, Event{Kind: "action_taken", At: time.Unix(2, 0)})
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(3, 0), Data: map[string]any{"n": "b"}})
got, err := f.Recent(ctx, "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if len(got) != 2 || got[0].Data["n"] != "b" {
t.Fatalf("want newest-first [b,a], got %+v", got)
}
}
func TestFakeRecentRespectsLimit(t *testing.T) {
f := NewFake()
ctx := context.Background()
for i := 0; i < 4; i++ {
_ = f.Record(ctx, Event{Kind: "proposal_made", At: time.Unix(int64(i), 0)})
}
got, _ := f.Recent(ctx, "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("limit not honored: %d", len(got))
}
}
func TestNopStoreIsInert(t *testing.T) {
s := NewNop()
if err := s.Record(context.Background(), Event{Kind: "x"}); err != nil {
t.Fatalf("nop Record: %v", err)
}
got, err := s.Recent(context.Background(), "x", 5)
if err != nil || len(got) != 0 {
t.Fatalf("nop Recent = %+v, %v", got, err)
}
}
// fakeAWClient is the AWClient subset, recording inserts and replaying a fixed
// recent list so we can test mapping and over-fetch filtering without HTTP.
type fakeAWClient struct {
inserted []aw.Event
recent []aw.Event // newest-first, mixed kinds
lastLimit int
}
func (f *fakeAWClient) Insert(_ context.Context, _ string, e aw.Event) error {
f.inserted = append(f.inserted, e)
return nil
}
func (f *fakeAWClient) Recent(_ context.Context, _ string, limit int) ([]aw.Event, error) {
f.lastLimit = limit
return f.recent, nil
}
func TestAWStoreRecordMapsKindIntoData(t *testing.T) {
fc := &fakeAWClient{}
s := NewAWStore(fc, "keel.events")
at := time.Unix(1000, 0).UTC()
if err := s.Record(context.Background(), Event{Kind: "proposal_made", At: at, Data: map[string]any{"proposal_id": "x"}}); err != nil {
t.Fatalf("Record: %v", err)
}
if len(fc.inserted) != 1 {
t.Fatalf("inserted %d", len(fc.inserted))
}
got := fc.inserted[0]
if !got.Timestamp.Equal(at) || got.Data["_kind"] != "proposal_made" || got.Data["proposal_id"] != "x" {
t.Fatalf("mapped event = %+v", got)
}
}
func TestAWStoreRecentOverfetchesAndFiltersByKind(t *testing.T) {
fc := &fakeAWClient{recent: []aw.Event{
{Timestamp: time.Unix(3, 0), Data: map[string]any{"_kind": "proposal_made", "n": "b"}},
{Timestamp: time.Unix(2, 0), Data: map[string]any{"_kind": "action_taken"}},
{Timestamp: time.Unix(1, 0), Data: map[string]any{"_kind": "proposal_made", "n": "a"}},
}}
s := NewAWStore(fc, "keel.events")
got, err := s.Recent(context.Background(), "proposal_made", 5)
if err != nil {
t.Fatalf("Recent: %v", err)
}
if fc.lastLimit != overfetch {
t.Fatalf("over-fetch limit = %d, want %d", fc.lastLimit, overfetch)
}
if len(got) != 2 || got[0].Kind != "proposal_made" || got[0].Data["n"] != "b" {
t.Fatalf("filtered = %+v", got)
}
if _, leaked := got[0].Data["_kind"]; leaked {
t.Fatalf("_kind leaked into returned Data: %+v", got[0].Data)
}
}
func TestAWStoreRecentCapsAtN(t *testing.T) {
var recent []aw.Event
for i := 0; i < 5; i++ {
recent = append(recent, aw.Event{Timestamp: time.Unix(int64(i), 0), Data: map[string]any{"_kind": "proposal_made"}})
}
s := NewAWStore(&fakeAWClient{recent: recent}, "keel.events")
got, _ := s.Recent(context.Background(), "proposal_made", 2)
if len(got) != 2 {
t.Fatalf("cap not applied: %d", len(got))
}
}
@@ -1,5 +1,5 @@
// Package domain holds the core commitment types and validation, ported from
// the original Rust implementation. These are pure data types with no I/O.
// Package domain holds the core commitment types and validation. These are
// pure data types with no I/O.
package domain
import (
+339
View File
@@ -0,0 +1,339 @@
package focus
import (
"context"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"log"
"strings"
"time"
)
const (
driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second
enforceTimeout = 5 * time.Second
nudgeDebounce = 5 * time.Minute
nudgeTimeout = 30 * time.Second
)
const recentTitlesMax = 10
const (
driftIdle = "idle"
driftPending = "pending"
driftOnTask = "ontask"
driftDrifting = "drifting"
)
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
// working; unmatched windows simply stay idle.
func (c *Mode) SetDriftJudge(j ai.DriftJudge) {
c.mu.Lock()
c.judge = j
c.mu.Unlock()
}
// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize
// enforcement; everything else behaves identically.
func (c *Mode) SetGuard(g enforce.Guard) {
c.mu.Lock()
defer c.mu.Unlock()
c.guard = g
}
// SetNudge injects the AI semantic nudge judge. A nil nudger disables nudging;
// local matching and the drift judge are unaffected.
func (c *Mode) SetNudge(n ai.Nudger) {
c.mu.Lock()
c.nudge = n
c.mu.Unlock()
}
// commitmentLineLocked renders the active commitment as a single line for AI
// prompts, or "" if there is none. Caller holds mu.
func (c *Mode) commitmentLineLocked() string {
if c.commitment == nil {
return ""
}
return c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}
// recentTitlesForTest returns a copy of the recent-titles ring (test-only).
func (c *Mode) recentTitlesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.recentTitles...)
}
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Mode) resetDriftLocked() {
c.driftStatus = driftIdle
c.driftReason = ""
c.driftGen++
c.nudgeEpoch++
c.lastJudgedAt = time.Time{}
c.judgedWindows = map[string]ai.Verdict{}
c.recentTitles = nil
c.nudgeMessage = ""
c.lastNudgedAt = time.Time{}
}
// OnTask appends the current window class to the session allowed-context, clears
// drift, drops the cached verdict for the current window, and persists. The
// class now matches locally and is never re-judged.
func (c *Mode) OnTask() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
var class string
if c.stats != nil {
class = c.stats.Current.Class
}
if strings.TrimSpace(class) != "" {
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
}
delete(c.judgedWindows, verdictKey(class, c.stats.Current.Title))
}
c.driftStatus = driftOnTask
c.driftReason = ""
return c.persistLocked()
}
// Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current window so it may be judged again later.
func (c *Mode) Refocus() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
if c.stats != nil {
delete(c.judgedWindows, verdictKey(c.stats.Current.Class, c.stats.Current.Title))
}
c.driftStatus = driftIdle
c.driftReason = ""
return c.persistLocked()
}
// RecordWindow ingests one sensor observation. It accumulates time only while
// Active, always tracks the latest window for display, and fires onChange after
// releasing the mutex.
func (c *Mode) RecordWindow(snap evidence.WindowSnapshot) {
c.mu.Lock()
c.latestWindow = snap
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
c.mu.Unlock()
c.notify()
return
}
now := c.clock()
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
c.applyEvent(now, snap)
c.recordTitleLocked(snap.Title)
launch := c.evaluateDriftLocked(now, snap)
enforceAct := c.enforceActionLocked()
c.mu.Unlock()
if launch != nil {
go launch()
}
if enforceAct != nil {
go enforceAct()
}
c.notify()
}
// enforceActionLocked returns the minimize thunk when this observation should be
// enforced — guard wired, level is block, and drift is confirmed — else nil. The
// returned func performs blocking X11 I/O and MUST run after the caller releases
// c.mu, following the off-lock async-I/O discipline used by the other roles.
func (c *Mode) enforceActionLocked() func() {
if c.guard == nil || c.enforcementLevel != domain.EnforcementBlock || c.driftStatus != driftDrifting {
return nil
}
guard := c.guard
return func() {
ctx, cancel := context.WithTimeout(context.Background(), enforceTimeout)
defer cancel()
if err := guard.MinimizeActive(ctx); err != nil {
log.Printf("focus: enforce minimize failed: %v", err)
}
}
}
// evaluateDriftLocked runs the local-first drift pipeline for one observation
// and updates synchronous drift state. When an async judgment is warranted it
// returns the judging closure; the caller runs it in a goroutine after
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
// already verified the runtime is Active.
func (c *Mode) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM. This is also the ONLY path
// where the semantic nudge runs — the drift judge stays silent here, so the
// two are mutually exclusive per observation.
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if evidence.MatchesAllowed(ac, class, title) {
c.driftStatus = driftOnTask
c.driftReason = ""
return c.maybeNudgeLocked(now)
}
// Past this point the window is not a local on-task match: the on-task
// stretch (if any) has ended. Void any soft nudge tied to it and advance the
// epoch so an in-flight nudge result is discarded rather than surfacing stale
// when the user returns.
c.nudgeMessage = ""
c.nudgeEpoch++
// 2. Per-window cache, keyed by class+title. A verdict for one window of an
// app must not leak to a sibling window of the same class — a browser hosts
// both an on-task reading page and an off-task chat under one window class, so
// keying by class alone latched one tab's verdict onto every other tab.
key := verdictKey(class, title)
if v, ok := c.judgedWindows[key]; ok {
c.applyVerdictLocked(v)
return nil
}
// 3. No judge wired: never block; leave idle.
if c.judge == nil {
c.driftStatus = driftIdle
c.driftReason = ""
return nil
}
// 4. Debounce: at most one judgment per driftDebounce window.
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
return nil
}
prevStatus, prevReason := c.driftStatus, c.driftReason
c.driftGen++
gen := c.driftGen
c.lastJudgedAt = now
c.driftStatus = driftPending
c.driftReason = ""
judge := c.judge
commitment := c.commitmentLineLocked()
return func() {
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
defer cancel()
v, err := judge.JudgeDrift(ctx, commitment, class, title)
c.mu.Lock()
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: a newer judgment started or the session ended
}
if err != nil {
// Degrade: never fabricate drift. Revert the optimistic pending.
if c.driftStatus == driftPending {
c.driftStatus = prevStatus
c.driftReason = prevReason
}
c.mu.Unlock()
log.Printf("focus: drift judge failed: %v", err)
c.notify()
return
}
c.judgedWindows[key] = v
var enforceAct func()
if c.stats != nil && verdictKey(c.stats.Current.Class, c.stats.Current.Title) == key {
c.applyVerdictLocked(v)
enforceAct = c.enforceActionLocked()
}
c.mu.Unlock()
if enforceAct != nil {
enforceAct()
}
c.notify()
}
}
// maybeNudgeLocked decides whether to launch a semantic nudge on the on-task
// local-match path. It returns the nudging closure when eligible (judge wired,
// at least two titles of history, debounce elapsed), else nil. The closure
// follows the drift-judge discipline: it runs in a goroutine after the caller
// releases the mutex, re-acquires the lock, guards on nudgeEpoch (the current
// on-task-stretch id), so a nudge whose stretch has since ended — a drift
// episode or a session change — is discarded rather than surfacing stale, never
// fabricates a concern on error, and notifies only after unlocking. Caller holds
// mu and has already verified the runtime is Active.
func (c *Mode) maybeNudgeLocked(now time.Time) func() {
if c.nudge == nil || len(c.recentTitles) < 2 {
return nil
}
if !c.lastNudgedAt.IsZero() && now.Sub(c.lastNudgedAt) < nudgeDebounce {
return nil
}
c.lastNudgedAt = now
epoch := c.nudgeEpoch
nudge := c.nudge
commitment := c.commitmentLineLocked()
titles := append([]string(nil), c.recentTitles...)
return func() {
ctx, cancel := context.WithTimeout(context.Background(), nudgeTimeout)
defer cancel()
msg, err := nudge.Nudge(ctx, commitment, titles)
c.mu.Lock()
if epoch != c.nudgeEpoch || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: the on-task stretch ended (drift episode) or the session changed
}
if err != nil {
c.mu.Unlock()
log.Printf("focus: nudge failed: %v", err)
return // never fabricate a concern; leave any prior message intact
}
c.nudgeMessage = msg // "" clears a prior advisory once back on trajectory
c.mu.Unlock()
c.notify()
}
}
// recordTitleLocked appends a non-empty title to the recent ring, skipping a
// consecutive duplicate, and caps the ring at recentTitlesMax. Caller holds mu.
func (c *Mode) recordTitleLocked(title string) {
t := strings.TrimSpace(title)
if t == "" {
return
}
if n := len(c.recentTitles); n > 0 && c.recentTitles[n-1] == t {
return
}
c.recentTitles = append(c.recentTitles, t)
if len(c.recentTitles) > recentTitlesMax {
c.recentTitles = c.recentTitles[len(c.recentTitles)-recentTitlesMax:]
}
}
// verdictKey identifies the judged window for the per-window verdict cache. It
// combines the window class with the scrubbed, casefolded title so sibling
// windows of one app (a browser's reading tab vs its chat tab) are judged
// independently, while transient title noise (clocks, counts) does not split a
// window into ever-changing keys that defeat the cache.
func verdictKey(class, title string) string {
return strings.ToLower(strings.TrimSpace(class)) + "\x00" + strings.ToLower(evidence.ScrubTitle(title))
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Mode) applyVerdictLocked(v ai.Verdict) {
if v.OnTask {
c.driftStatus = driftOnTask
c.driftReason = ""
return
}
c.driftStatus = driftDrifting
c.driftReason = v.Reason
}
+50
View File
@@ -0,0 +1,50 @@
package focus
import (
"path/filepath"
"keel/internal/harness"
"keel/internal/mode"
)
// build constructs a focus Mode under the mode's namespaced dir and injects the
// shared services through the existing setters (the same API tests use).
func build(svc harness.Services) (*Mode, error) {
m, err := New(filepath.Join(svc.Dir, "state.json"))
if err != nil {
return nil, err
}
if svc.Clock != nil {
m.SetClock(svc.Clock)
}
m.SetCoach(svc.AI)
m.SetReviewer(svc.AI)
m.SetDriftJudge(svc.AI)
m.SetNudge(svc.AI)
m.SetTasks(svc.Tasks)
m.SetKnowledge(svc.Knowledge)
m.SetGuard(svc.Enforce)
m.SetOnChange(svc.Notify) // harness fan-out becomes focus's change listener
return m, nil
}
// Factory builds a fresh focus mode entering Planning (used by harness.Start).
func Factory(svc harness.Services) mode.Mode {
m, err := build(svc)
if err != nil {
m, _ = New("") // degrade to an in-memory Locked mode; EnterPlanning still works
m.SetOnChange(svc.Notify)
}
_ = m.EnterPlanning()
return m
}
// Restore loads a persisted focus session and reports whether one was live
// (Planning/Active/Transition/Review). Used at startup to re-adopt a session.
func Restore(svc harness.Services) (*Mode, bool, error) {
m, err := build(svc)
if err != nil {
return nil, false, err
}
return m, m.Active(), nil
}
+358
View File
@@ -0,0 +1,358 @@
// Package focus owns the daemon's in-memory state of truth and persists a
// snapshot on every change. Transitions go through the pure statemachine. It
// also owns per-session evidence stats: it accumulates active-window time while
// Active, logs raw focus events, and seals each session into the audit chain.
package focus
import (
"errors"
"log"
"path/filepath"
"sort"
"sync"
"time"
"keel/internal/ai"
"keel/internal/enforce"
"keel/internal/evidence"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/mode/focus/statemachine"
"keel/internal/store"
"keel/internal/tasks"
"github.com/google/uuid"
)
const (
unavailableTitle = "(evidence unavailable)"
sessionRetention = 30 * 24 * time.Hour
)
var ErrNotPlanning = errors.New("focus: coaching is only available while planning")
var ErrNotActive = errors.New("focus: only available while a commitment is active")
// Mode holds runtime state and the active commitment behind a mutex.
type Mode struct {
mu sync.Mutex
async harness.Async
runtimeState domain.RuntimeState
commitment *domain.Commitment
deadline time.Time
snapshotPath string
auditPath string
sessionsDir string
clock func() time.Time
onChanges []func()
latestWindow evidence.WindowSnapshot
stats *EvidenceStats
outcomePending string
coach ai.Coach
coachStatus string
coachProposal *ai.Proposal
coachErr string
coachGen int
tasksProvider tasks.Provider
tasksStatus string
tasksList []tasks.Task
tasksGen int
knowledgeSrc knowledge.Source
knowledgeStatus string
knowledgeText string // cached grounding the coach reads
knowledgePath string // selected path; "" = adapter default
knowledgeChars int
knowledgeGen int
reviewer ai.Reviewer
reflectionStatus string
reflectionRecap string
carryForward string // latest-wins takeaway; grounds the next coach
reflectionGen int
allowedClasses []string // durable: the active session's allowed window classes
enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement
guard enforce.Guard
judge ai.DriftJudge
driftStatus string
driftReason string
driftGen int
nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard
lastJudgedAt time.Time
judgedWindows map[string]ai.Verdict
nudge ai.Nudger
recentTitles []string // in-memory ring of recent distinct titles this session
nudgeMessage string // current soft advisory ("" = none)
lastNudgedAt time.Time
}
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
// in-memory stats from the raw log if a live session was interrupted.
func New(snapshotPath string) (*Mode, error) {
s, err := store.Load(snapshotPath)
if err != nil {
return nil, err
}
dir := filepath.Dir(snapshotPath)
c := &Mode{
runtimeState: s.RuntimeState,
commitment: s.Commitment,
snapshotPath: snapshotPath,
auditPath: filepath.Join(dir, "audit.jsonl"),
sessionsDir: filepath.Join(dir, "sessions"),
clock: time.Now,
outcomePending: s.OutcomePending,
reflectionStatus: s.ReflectionStatus,
reflectionRecap: s.ReflectionRecap,
carryForward: s.CarryForward,
}
if s.DeadlineUnixSecs > 0 {
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
}
if c.runtimeState == "" {
c.runtimeState = domain.RuntimeLocked
}
c.async = harness.NewAsync(&c.mu, c.notify)
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
c.allowedClasses = s.AllowedWindowClasses
c.enforcementLevel = s.EnforcementLevel
// Drift state is not persisted: recompute fresh after restart to avoid
// stale interrupts. This also initializes judgedWindows (the pipeline
// writes to it) so a restored Active session never panics on a nil map.
c.resetDriftLocked()
c.replayStats(s.SessionID)
}
return c, nil
}
// SetClock overrides the time source (tests only). Call before starting a
// session.
func (c *Mode) SetClock(f func() time.Time) {
c.mu.Lock()
c.clock = f
c.mu.Unlock()
}
// SetOnChange registers the sole change listener, replacing any already set. A
// listener is fired after a state change (focus updates, role results) with the
// mutex released. Use AddOnChange to register additional listeners.
func (c *Mode) SetOnChange(f func()) {
c.mu.Lock()
c.onChanges = []func(){f}
c.mu.Unlock()
}
// AddOnChange registers an additional change listener alongside any already set,
// so several consumers (the web broadcaster, the status-file writer) all react
// to the same notification rather than only the last one wired.
func (c *Mode) AddOnChange(f func()) {
c.mu.Lock()
c.onChanges = append(c.onChanges, f)
c.mu.Unlock()
}
func (c *Mode) notify() {
c.mu.Lock()
fs := append([]func(){}, c.onChanges...)
c.mu.Unlock()
for _, f := range fs {
if f != nil {
f()
}
}
}
// State returns the current broadcastable state. Safe for concurrent use.
func (c *Mode) State() State {
c.mu.Lock()
defer c.mu.Unlock()
return c.stateLocked()
}
// Deadline returns the active commitment deadline, or the zero time.
func (c *Mode) Deadline() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.deadline
}
func (c *Mode) persistLocked() error {
snap := store.Snapshot{
RuntimeState: c.runtimeState,
Commitment: c.commitment,
OutcomePending: c.outcomePending,
}
if !c.deadline.IsZero() {
snap.DeadlineUnixSecs = c.deadline.Unix()
}
if c.stats != nil {
snap.SessionID = c.stats.SessionID
}
snap.AllowedWindowClasses = c.allowedClasses
snap.EnforcementLevel = c.enforcementLevel
snap.ReflectionStatus = c.reflectionStatus
snap.ReflectionRecap = c.reflectionRecap
snap.CarryForward = c.carryForward
return store.Save(c.snapshotPath, snap)
}
// EnterPlanning moves Locked -> Planning.
func (c *Mode) EnterPlanning() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
if err != nil {
return err
}
c.runtimeState = next
c.resetCoachLocked()
c.startTasksFetchLocked()
c.startKnowledgeFetchLocked()
return c.persistLocked()
}
// AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Mode) AllowedClassesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.allowedClasses...)
}
// EnforcementLevelForTest exposes the active session's enforcement level. Tests
// only.
func (c *Mode) EnforcementLevelForTest() domain.EnforcementLevel {
c.mu.Lock()
defer c.mu.Unlock()
return c.enforcementLevel
}
// StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning ->
// Active.
func (c *Mode) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error {
c.mu.Lock()
defer c.mu.Unlock()
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
if err != nil {
return err
}
commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate)
if err != nil {
return err
}
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted)
if err != nil {
return err
}
now := c.clock()
c.runtimeState = next
c.commitment = &commitment
c.deadline = now.Add(timebox)
c.outcomePending = ""
c.resetCoachLocked()
c.allowedClasses = append([]string(nil), allowedClasses...)
c.enforcementLevel = level
c.resetDriftLocked()
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: now.Unix(),
Buckets: map[bucketKey]time.Duration{},
OnTask: map[bucketKey]time.Duration{},
OffTask: map[bucketKey]time.Duration{},
}
seed := c.latestWindow
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
c.applyEvent(now, seed)
return c.persistLocked()
}
// Complete moves Active -> Review with a "completed" outcome.
func (c *Mode) Complete() error { return c.enterReview("completed") }
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
func (c *Mode) Expire() error { return c.enterReview("expired") }
func (c *Mode) enterReview(outcome string) error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
if err != nil {
return err
}
if c.commitment != nil {
completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete)
if err != nil {
return err
}
c.commitment.State = completed
}
// Flush the final open segment, then freeze accounting.
if c.stats != nil && c.stats.hasLast {
c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt))
c.stats.hasLast = false
}
c.runtimeState = next
c.outcomePending = outcome
c.startReflectionFetchLocked()
return c.persistLocked()
}
// End moves Review -> Locked, writes the session summary to the audit chain,
// and clears the commitment and stats.
func (c *Mode) End() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
if err != nil {
return err
}
if c.stats != nil {
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
// State integrity over audit completeness: the transition still
// completes. Surfaced for the operator; no auto-retry in M1.
log.Printf("focus: audit append failed: %v", err)
}
}
c.runtimeState = next
c.commitment = nil
c.deadline = time.Time{}
c.stats = nil
c.outcomePending = ""
c.allowedClasses = nil
c.enforcementLevel = ""
c.resetDriftLocked()
return c.persistLocked()
}
func (c *Mode) buildSummaryLocked() store.SessionSummary {
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
for k, d := range c.stats.Buckets {
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds })
outcome := c.outcomePending
if outcome == "" {
outcome = "completed"
}
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
}
return store.SessionSummary{
SessionID: c.stats.SessionID,
NextAction: na,
SuccessCond: sc,
Outcome: outcome,
StartedUnix: c.stats.StartedUnix,
EndedUnix: c.clock().Unix(),
SwitchCount: c.stats.SwitchCount,
Buckets: buckets,
}
}
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
package focus
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"keel/internal/evidence"
"keel/internal/mode"
"keel/internal/mode/focus/domain"
)
// Compile-time assertions: focus.Mode must satisfy the harness contracts.
var _ mode.Mode = (*Mode)(nil)
var _ mode.EvidenceConsumer = (*Mode)(nil)
var _ mode.Expirer = (*Mode)(nil)
// Kind identifies this mode to the harness.
func (c *Mode) Kind() string { return "focus" }
var errBadPayload = errors.New("focus: invalid command payload")
type commitmentPayload struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes"`
Enforce bool `json:"enforce"`
}
// Command maps surface command names onto the existing focus transitions.
func (c *Mode) Command(ctx context.Context, name string, payload json.RawMessage) error {
switch name {
case "planning":
return c.EnterPlanning()
case "coach":
var r struct {
Intent string `json:"intent"`
}
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
return c.RequestCoach(r.Intent)
case "commitment":
var r commitmentPayload
if err := json.Unmarshal(payload, &r); err != nil {
return errBadPayload
}
level := domain.EnforcementWarn
if r.Enforce {
level = domain.EnforcementBlock
}
return c.StartManualCommitment(r.NextAction, r.SuccessCondition,
time.Duration(r.TimeboxSecs)*time.Second, r.AllowedWindowClasses, level)
case "complete":
return c.Complete()
case "end":
return c.End()
case "refocus":
return c.Refocus()
case "ontask":
return c.OnTask()
default:
return fmt.Errorf("focus: unknown command %q", name)
}
}
// View returns the focus state projection (the existing State shape).
func (c *Mode) View() any { return c.State() }
// Active is true from Planning through Review. End() drives the runtime back to
// Locked, which the harness reads as "done" and releases.
func (c *Mode) Active() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.runtimeState != domain.RuntimeLocked
}
// OnWindow forwards the sensor stream to the existing evidence recorder.
func (c *Mode) OnWindow(w evidence.WindowSnapshot) { c.RecordWindow(w) }
+378
View File
@@ -0,0 +1,378 @@
package focus
import (
"context"
"errors"
"fmt"
"keel/internal/ai"
"keel/internal/knowledge"
"keel/internal/mode/focus/domain"
"keel/internal/store"
"keel/internal/tasks"
"strings"
"time"
)
const coachTimeout = 60 * time.Second
const (
coachIdle = "idle"
coachPending = "pending"
coachReady = "ready"
coachError = "error"
)
const tasksTimeout = 30 * time.Second
const (
tasksIdle = "idle"
tasksPending = "pending"
tasksReady = "ready"
tasksError = "error"
)
const knowledgeTimeout = 10 * time.Second
const (
knowledgeIdle = "idle"
knowledgePending = "pending"
knowledgeReady = "ready"
knowledgeAbsent = "absent"
knowledgeError = "error"
)
const reflectionTimeout = 30 * time.Second
const reflectionHistoryN = 5
// reflectionTopBuckets caps how many per-window time buckets the finished-session
// block lists for the reviewer, keeping the prompt compact.
const reflectionTopBuckets = 3
const (
reflectionIdle = "idle"
reflectionPending = "pending"
reflectionReady = "ready"
reflectionAbsent = "absent"
)
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Mode) SetCoach(coach ai.Coach) {
c.mu.Lock()
c.coach = coach
c.mu.Unlock()
}
// resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu.
func (c *Mode) resetCoachLocked() {
c.coachStatus = coachIdle
c.coachProposal = nil
c.coachErr = ""
c.coachGen++
}
// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps
// the planning tasks list absent.
func (c *Mode) SetTasks(p tasks.Provider) {
c.mu.Lock()
c.tasksProvider = p
c.mu.Unlock()
}
// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider
// is set. Mirrors RequestCoach: generation-guarded, discards stale or
// post-planning results, and notifies on completion. Caller holds mu.
func (c *Mode) startTasksFetchLocked() {
c.tasksList = nil
if c.tasksProvider == nil {
c.tasksStatus = tasksIdle
return
}
c.tasksGen++
gen := c.tasksGen
c.tasksStatus = tasksPending
provider := c.tasksProvider
var list []tasks.Task
var err error
c.async.Run(tasksTimeout,
func(ctx context.Context) { list, err = provider.Today(ctx) },
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.tasksStatus = tasksError
c.tasksList = nil
} else {
c.tasksStatus = tasksReady
c.tasksList = list
}
})
}
// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source
// keeps the planning knowledge view absent and the coach ungrounded.
func (c *Mode) SetKnowledge(s knowledge.Source) {
c.mu.Lock()
c.knowledgeSrc = s
c.mu.Unlock()
}
// SetKnowledgePath selects an explicit profile path (session-only; not
// persisted). While planning, it re-loads immediately so the indicator and the
// cached grounding update. An empty path resets to the adapter default.
func (c *Mode) SetKnowledgePath(path string) {
c.mu.Lock()
c.knowledgePath = strings.TrimSpace(path)
planning := c.runtimeState == domain.RuntimePlanning
if planning {
c.startKnowledgeFetchLocked()
}
c.mu.Unlock()
if planning {
c.notify()
}
}
// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set.
// Mirrors startTasksFetchLocked: generation-guarded, discards stale or
// post-planning results, and notifies on completion. The loaded text is cached
// in knowledgeText for the coach to read. Caller holds mu.
func (c *Mode) startKnowledgeFetchLocked() {
c.knowledgeText = ""
c.knowledgeChars = 0
if c.knowledgeSrc == nil {
c.knowledgeStatus = knowledgeIdle
return
}
c.knowledgeGen++
gen := c.knowledgeGen
c.knowledgeStatus = knowledgePending
src := c.knowledgeSrc
path := c.knowledgePath
var prof knowledge.Profile
var err error
c.async.Run(knowledgeTimeout,
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.knowledgeStatus = knowledgeError
c.knowledgeText = ""
c.knowledgeChars = 0
if prof.Path != "" {
c.knowledgePath = prof.Path
}
} else if prof.Text == "" {
c.knowledgeStatus = knowledgeAbsent
c.knowledgeText = ""
c.knowledgeChars = 0
c.knowledgePath = prof.Path
} else {
c.knowledgeStatus = knowledgeReady
c.knowledgeText = prof.Text
c.knowledgeChars = len(prof.Text)
c.knowledgePath = prof.Path
}
})
}
// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and
// leaves the coach ungrounded by any carry-forward.
func (c *Mode) SetReviewer(r ai.Reviewer) {
c.mu.Lock()
c.reviewer = r
c.mu.Unlock()
}
// startReflectionFetchLocked kicks off an asynchronous reflection when a
// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the
// completion guard is generation-only (not state-gated): the carry-forward must
// still apply if the user clicks End before the reviewer returns. A superseded
// review (a later session's fetch) bumps the generation and discards this one.
// The recap and carry-forward are cleared up front so a failed/slow reviewer
// never leaves stale data from the previous session. Caller holds mu.
func (c *Mode) startReflectionFetchLocked() {
c.reflectionRecap = ""
c.carryForward = ""
if c.reviewer == nil {
c.reflectionStatus = reflectionIdle
return
}
c.reflectionGen++
gen := c.reflectionGen
c.reflectionStatus = reflectionPending
reviewer := c.reviewer
finished := c.buildReflectionFinishedLocked()
// Read the history synchronously, here under the lock, on purpose: it must
// happen-before End appends the just-finished session to the audit chain, so
// that session is excluded from "recent history" and not double-counted (it
// is already carried in `finished`). Moving this into the goroutine would
// race with End's append and reintroduce that double-count. The read is
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
// on any hot path.
history := buildReflectionHistory(c.auditPath)
var refl ai.Reflection
var err error
c.async.Run(reflectionTimeout,
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
func() bool { return gen != c.reflectionGen },
func() {
if err != nil || strings.TrimSpace(refl.Recap) == "" {
c.reflectionStatus = reflectionAbsent
c.reflectionRecap = ""
c.carryForward = ""
} else {
c.reflectionStatus = reflectionReady
c.reflectionRecap = refl.Recap
c.carryForward = refl.CarryForward
}
_ = c.persistLocked()
})
}
// buildReflectionFinishedLocked renders the just-finished session as a compact
// block for the reviewer: the commitment, the outcome, on/off/unclassified time
// totals, and a top-N on-task list and top-N off-task list. The split fields are
// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still
// set (End clears them, but enterReview runs before End).
func (c *Mode) buildReflectionFinishedLocked() string {
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
}
outcome := c.outcomePending
if outcome == "" {
outcome = "completed"
}
var b strings.Builder
fmt.Fprintf(&b, "Next action: %s\n", na)
fmt.Fprintf(&b, "Success condition: %s\n", sc)
fmt.Fprintf(&b, "Outcome: %s\n", outcome)
if c.stats != nil {
onMin := int64(sumDurations(c.stats.OnTask).Seconds()) / 60
offMin := int64(sumDurations(c.stats.OffTask).Seconds()) / 60
unclMin := int64(c.stats.unclassified.Seconds()) / 60
fmt.Fprintf(&b, "On-task %dm / Off-task %dm / Unclassified %dm\n", onMin, offMin, unclMin)
fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount)
writeBucketList(&b, "On-task", c.stats.OnTask)
writeBucketList(&b, "Off-task", c.stats.OffTask)
}
return strings.TrimRight(b.String(), "\n")
}
// writeBucketList renders a labeled, time-descending list of buckets capped at
// reflectionTopBuckets. It writes nothing — not even the label — when the map is
// empty, so a single-sided session shows only the list that has time.
func writeBucketList(b *strings.Builder, label string, m map[bucketKey]time.Duration) {
views := bucketViews(m)
if len(views) == 0 {
return
}
fmt.Fprintf(b, "%s:\n", label)
for i, bv := range views {
if i >= reflectionTopBuckets {
break
}
fmt.Fprintf(b, "- %s · %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60)
}
}
// sumDurations totals the durations in a bucket map.
func sumDurations(m map[bucketKey]time.Duration) time.Duration {
var total time.Duration
for _, d := range m {
total += d
}
return total
}
// buildReflectionHistory renders the last few prior sessions as compact lines.
// The just-finished session is not yet in the chain (End appends it), so it is
// not double-counted. Returns "" when there is no usable history.
func buildReflectionHistory(auditPath string) string {
sums, err := store.RecentSessions(auditPath, reflectionHistoryN)
if err != nil || len(sums) == 0 {
return ""
}
var b strings.Builder
for _, s := range sums {
top := ""
if len(s.Buckets) > 0 {
top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60)
}
fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top)
}
return strings.TrimRight(b.String(), "\n")
}
// composedGroundingLocked combines the standing profile (knowledge port) with
// the latest carry-forward takeaway into the single free-form grounding string
// the coach already accepts. Caller holds mu.
func (c *Mode) composedGroundingLocked() string {
g := c.knowledgeText
if c.carryForward != "" {
if g != "" {
g += "\n\n"
}
g += "Last session's takeaway: " + c.carryForward
}
return g
}
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted.
func (c *Mode) RequestCoach(intent string) error {
c.mu.Lock()
if c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return ErrNotPlanning
}
if c.coach == nil {
c.coachStatus = coachError
c.coachErr = "coach unavailable"
c.coachProposal = nil
c.mu.Unlock()
c.notify()
return nil
}
c.coachGen++
gen := c.coachGen
c.coachStatus = coachPending
c.coachErr = ""
c.coachProposal = nil
coach := c.coach
grounding := c.composedGroundingLocked()
c.mu.Unlock()
c.notify()
var prop ai.Proposal
var err error
c.async.Run(coachTimeout,
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.coachStatus = coachError
c.coachErr = coachErrorMessage(err)
c.coachProposal = nil
} else {
c.coachStatus = coachReady
c.coachProposal = &prop
c.coachErr = ""
}
})
return nil
}
func coachErrorMessage(err error) string {
switch {
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
return "coach returned an unusable response"
case errors.Is(err, context.DeadlineExceeded):
return "coach timed out"
default:
return "coach unavailable"
}
}
@@ -1,16 +1,16 @@
// Package statemachine holds the pure runtime and commitment transition
// functions ported from the Rust implementation. No I/O, no shared state.
// functions. No I/O, no shared state.
package statemachine
import (
"fmt"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
// RuntimeAction enumerates runtime transitions. Activate's policy acceptance
// and admin-override exit targets from the Rust enum are flattened into
// distinct actions so the action type stays a simple value.
// and admin-override exit targets are flattened into distinct actions so the
// action type stays a simple value.
type RuntimeAction string
const (
@@ -3,7 +3,7 @@ package statemachine
import (
"testing"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) {
+113
View File
@@ -0,0 +1,113 @@
package focus
import (
"keel/internal/evidence"
"keel/internal/store"
"time"
)
// bucketKey identifies a time bucket; Title is the scrubbed title.
type bucketKey struct{ Class, Title string }
// EvidenceStats is the in-memory accounting for the current session only.
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration // total time per bucket (unchanged)
OnTask map[bucketKey]time.Duration // on-task portion per bucket
OffTask map[bucketKey]time.Duration // off-task portion per bucket
unclassified time.Duration // idle/pending time; aggregate only
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
// applyEvent advances stats by one observation: it credits the prior segment to
// the prior bucket, counts a context switch on key change, and records the new
// current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Mode) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast {
c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt))
}
newKey := keyFor(snap)
if c.stats.hasLast && newKey != c.stats.lastKey {
c.stats.SwitchCount++
}
c.stats.lastKey = newKey
c.stats.lastFocusAt = now
c.stats.hasLast = true
c.stats.Current = snap
}
// creditLocked credits duration d to bucket k: always to the running total, and
// to the on/off/unclassified split per the live drift status — which, at every
// credit site, is the classification of the segment being credited (applyEvent
// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs
// before the state transition). idle/pending route to unclassified: honest, never
// falsely on-task. Caller holds mu.
func (c *Mode) creditLocked(k bucketKey, d time.Duration) {
c.stats.Buckets[k] += d
switch c.driftStatus {
case driftOnTask:
c.stats.OnTask[k] += d
case driftDrifting:
c.stats.OffTask[k] += d
default: // driftIdle, driftPending
c.stats.unclassified += d
}
}
// replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Mode) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID)
if err != nil || len(events) == 0 {
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: c.clock().Unix(),
Buckets: map[bucketKey]time.Duration{},
OnTask: map[bucketKey]time.Duration{},
OffTask: map[bucketKey]time.Duration{},
}
return
}
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: events[0].AtUnixMillis / 1000,
Buckets: map[bucketKey]time.Duration{},
OnTask: map[bucketKey]time.Duration{},
OffTask: map[bucketKey]time.Duration{},
}
// Drift status is not persisted, so replayed pre-crash segments credit to
// unclassified (driftStatus is driftIdle here). Totals in Buckets are exact;
// only the on/off split degrades — honest, never falsely on-task.
for _, e := range events {
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
}
}
func keyFor(snap evidence.WindowSnapshot) bucketKey {
if !snap.Health.Available {
return bucketKey{Class: "", Title: unavailableTitle}
}
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
}
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
return store.FocusEvent{
AtUnixMillis: now.UnixMilli(),
Class: snap.Class,
Title: snap.Title,
Available: snap.Health.Available,
Reason: snap.Health.Reason,
}
}
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
return evidence.WindowSnapshot{
Title: e.Title,
Class: e.Class,
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
}
}
+197
View File
@@ -0,0 +1,197 @@
package focus
import (
"keel/internal/mode/focus/domain"
"sort"
"time"
)
// CommitmentView is the UI projection of the active commitment.
type CommitmentView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
}
// ProposalView / CoachView project the ephemeral planning-coach state.
type ProposalView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
}
// DriftView is the active-only drift projection. Nudge is a separate axis from
// Status: it is populated precisely when Status is "ontask" (semantic drift
// inside an allowed app), so it cannot be folded into the status enum.
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Nudge string `json:"nudge,omitempty"`
Enforced bool `json:"enforced,omitempty"`
}
type CoachView struct {
Status string `json:"status"`
Proposal *ProposalView `json:"proposal,omitempty"`
Error string `json:"error,omitempty"`
}
// TaskView is one to-do item in the planning tasks list.
type TaskView struct {
ID string `json:"id"`
Title string `json:"title"`
Day string `json:"day,omitempty"`
}
// TasksView projects the ephemeral planning tasks state (Marvin's today list).
type TasksView struct {
Status string `json:"status"`
Tasks []TaskView `json:"tasks,omitempty"`
}
// KnowledgeView projects the ephemeral planning knowledge state (the standing
// profile that grounds the coach). The profile text is intentionally omitted —
// only its presence, source path, and size cross the wire.
type KnowledgeView struct {
Status string `json:"status"`
Path string `json:"path,omitempty"`
Chars int `json:"chars,omitempty"`
}
// ReflectionView projects the reviewer's output. Recap is shown on Review;
// CarryForward is shown on the next Planning screen. Unlike the knowledge
// profile, these short lines exist to be displayed, so they cross the wire.
type ReflectionView struct {
Status string `json:"status,omitempty"`
Recap string `json:"recap,omitempty"`
CarryForward string `json:"carry_forward,omitempty"`
}
// WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct {
Class string `json:"class"`
Title string `json:"title"`
}
type BucketView struct {
Class string `json:"class"`
Title string `json:"title"`
Seconds int64 `json:"seconds"`
}
type EvidenceView struct {
Available bool `json:"available"`
Reason string `json:"reason"`
Current WindowView `json:"current"`
SwitchCount int `json:"switch_count"`
Buckets []BucketView `json:"buckets"`
}
// State is the broadcastable view of the controller.
type State struct {
RuntimeState domain.RuntimeState `json:"runtime_state"`
Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"`
Tasks *TasksView `json:"tasks,omitempty"`
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
Reflection *ReflectionView `json:"reflection,omitempty"`
Drift *DriftView `json:"drift,omitempty"`
}
func (c *Mode) stateLocked() State {
st := State{RuntimeState: c.runtimeState}
if c.commitment != nil {
view := &CommitmentView{
NextAction: c.commitment.NextAction,
SuccessCondition: c.commitment.SuccessCondition,
TimeboxSecs: c.commitment.TimeboxSecs,
}
if !c.deadline.IsZero() {
view.DeadlineUnixSecs = c.deadline.Unix()
}
st.Commitment = view
}
if c.stats != nil {
st.Evidence = &EvidenceView{
Available: c.stats.Current.Health.Available,
Reason: c.stats.Current.Health.Reason,
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
SwitchCount: c.stats.SwitchCount,
Buckets: bucketViews(c.stats.Buckets),
}
}
if c.runtimeState == domain.RuntimePlanning {
// Surface the live window so planning can show the real class to copy into
// the allowed-apps field — exact class names are otherwise invisible, and a
// non-matching token silently disables the local on-task fast path.
st.Evidence = &EvidenceView{
Available: c.latestWindow.Health.Available,
Reason: c.latestWindow.Health.Reason,
Current: WindowView{Class: c.latestWindow.Class, Title: c.latestWindow.Title},
}
status := c.coachStatus
if status == "" {
status = coachIdle
}
cv := &CoachView{Status: status, Error: c.coachErr}
if c.coachProposal != nil {
cv.Proposal = &ProposalView{
NextAction: c.coachProposal.NextAction,
SuccessCondition: c.coachProposal.SuccessCondition,
TimeboxSecs: c.coachProposal.TimeboxSecs,
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
}
}
st.Coach = cv
if c.tasksProvider != nil {
tstatus := c.tasksStatus
if tstatus == "" {
tstatus = tasksIdle
}
tv := &TasksView{Status: tstatus}
for _, t := range c.tasksList {
tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day})
}
st.Tasks = tv
}
if c.knowledgeSrc != nil {
kstatus := c.knowledgeStatus
if kstatus == "" {
kstatus = knowledgeIdle
}
st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars}
}
if c.carryForward != "" {
st.Reflection = &ReflectionView{CarryForward: c.carryForward}
}
}
if c.runtimeState == domain.RuntimeReview {
rstatus := c.reflectionStatus
if rstatus == "" {
rstatus = reflectionIdle
}
st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap}
}
if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus
if status == "" {
status = driftIdle
}
enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting
st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced}
}
return st
}
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
out := make([]BucketView, 0, len(buckets))
for k, d := range buckets {
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
return out
}
+44
View File
@@ -0,0 +1,44 @@
// Package mode defines the contract the harness uses to host a single
// collect→brain→act unit. Focus and off-screen are implementations.
package mode
import (
"context"
"encoding/json"
"time"
"keel/internal/evidence"
)
// Mode is one collect→brain→act configuration. The harness runs at most one.
type Mode interface {
// Kind identifies the mode ("focus", "offscreen").
Kind() string
// Command routes a surface-issued command into the mode. It returns an
// error for illegal/invalid commands; the web layer maps it to HTTP.
Command(ctx context.Context, name string, payload json.RawMessage) error
// View projects the mode's current state for surfaces (JSON-marshalable).
View() any
// Active reports whether the mode still holds the harness. When it returns
// false the harness releases the mode and goes idle.
Active() bool
}
// EvidenceConsumer is implemented by modes that read the window sensor stream.
type EvidenceConsumer interface {
OnWindow(evidence.WindowSnapshot)
}
// Expirer is implemented by modes with a server-authoritative deadline. Expire
// takes no context to match focus's existing Expire() error method, so focus
// satisfies this interface with zero changes to its tested surface.
type Expirer interface {
Deadline() time.Time
Expire() error
}
// Envelope is the surfacing wrapper: the active mode's kind plus its View().
type Envelope struct {
ActiveMode string `json:"active_mode"` // "" = idle
Mode any `json:"mode,omitempty"`
}
+104
View File
@@ -0,0 +1,104 @@
// internal/mode/offscreen/collect.go
package offscreen
import (
"context"
"fmt"
"strings"
"time"
"keel/internal/frame"
)
const (
recentProposals = 5 // how many recent proposals to show
outcomeScan = 50 // how many outcome events to scan for correlation
recentWindow = 7 * 24 * time.Hour
)
// assembleBrief builds the off-screen prompt context: the shared frame brief
// (today's tasks + ~/owc goals and life-domains) plus off-screen's own recent
// proposal history. Best-effort: never panics, never errors, tolerates nil
// ports and missing files.
func (m *Mode) assembleBrief() string {
base := frame.Assemble(context.Background(), m.know, m.tasks)
hist := m.briefHistory()
if hist == "" {
return base
}
var b strings.Builder
b.WriteString(base)
if !strings.HasSuffix(base, "\n") {
b.WriteString("\n")
}
b.WriteString("\n## Recently proposed\n")
b.WriteString(hist)
return frame.Truncate(b.String(), frame.MaxBriefBytes)
}
// briefHistory renders recent proposals with their outcome, newest first and
// within recentWindow. Returns "" when there is no memory or no recent history.
func (m *Mode) briefHistory() string {
if m.mem == nil {
return ""
}
ctx := context.Background()
proposals, err := m.mem.Recent(ctx, kindProposalMade, recentProposals)
if err != nil || len(proposals) == 0 {
return ""
}
taken := m.idSet(ctx, kindActionTaken)
dismissed := m.idSet(ctx, kindProposalDismissed)
now := m.now()
var b strings.Builder
for _, ev := range proposals {
if now.Sub(ev.At) > recentWindow {
continue
}
id, _ := ev.Data["proposal_id"].(string)
action, _ := ev.Data["next_action"].(string)
if action == "" {
continue
}
outcome := "unactioned"
if taken[id] {
outcome = "confirmed"
} else if dismissed[id] {
outcome = "dismissed"
}
fmt.Fprintf(&b, "- %q (%s, %s)\n", action, outcome, relativeAge(now.Sub(ev.At)))
}
return b.String()
}
// idSet reads recent events of a kind and returns their proposal_ids as a set.
func (m *Mode) idSet(ctx context.Context, kind string) map[string]bool {
out := map[string]bool{}
evs, err := m.mem.Recent(ctx, kind, outcomeScan)
if err != nil {
return out
}
for _, e := range evs {
if id, _ := e.Data["proposal_id"].(string); id != "" {
out[id] = true
}
}
return out
}
// relativeAge renders a coarse human age for a duration.
func relativeAge(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
+235
View File
@@ -0,0 +1,235 @@
// Package offscreen is the away-from-the-desk mode: it proposes one worthwhile
// off-screen action from the life-domain frame and files it on confirm. It is
// one-shot — after confirm or dismiss it goes inactive and the harness releases
// it.
package offscreen
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"sync"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/knowledge"
"keel/internal/memory"
"keel/internal/mode"
"keel/internal/tasks"
)
const proposeTimeout = 60 * time.Second
const (
statusPending = "pending"
statusProposed = "proposed"
statusError = "error"
statusDone = "done"
)
const (
kindProposalMade = "proposal_made"
kindActionTaken = "action_taken"
kindProposalDismissed = "proposal_dismissed"
)
// Mode is the one-shot off-screen advisor: collect → propose → confirm/dismiss.
type Mode struct {
mu sync.Mutex
async harness.Async
ai *ai.Service
tasks tasks.Provider
know knowledge.Source // may be nil; assembleBrief must guard
mem memory.Store // may be nil; record helpers guard
now func() time.Time // event timestamps; never nil after New
status string
gen int
proposal *ai.OffscreenProposal
errMsg string
done bool
proposalID string
}
var _ mode.Mode = (*Mode)(nil)
// New builds an off-screen mode from the shared services. It does not start the
// brain; call Command(ctx, "start", nil) (or use Factory) to kick off a brief.
func New(svc harness.Services) *Mode {
now := svc.Clock
if now == nil {
now = time.Now
}
m := &Mode{
ai: svc.AI,
tasks: svc.Tasks,
know: svc.Knowledge,
mem: svc.Memory,
now: now,
status: statusPending,
}
m.async = harness.NewAsync(&m.mu, svc.Notify)
return m
}
func (m *Mode) Kind() string { return "offscreen" }
// Active reports whether the mode still holds the harness. It goes false after
// confirm or dismiss.
func (m *Mode) Active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return !m.done
}
// Command routes a surface command. start (re)runs the brief; confirm files the
// proposed action and finishes; dismiss finishes without writing.
func (m *Mode) Command(ctx context.Context, name string, _ json.RawMessage) error {
switch name {
case "start":
return m.start()
case "confirm":
return m.confirm(ctx)
case "dismiss":
m.mu.Lock()
id := m.proposalID
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindProposalDismissed,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen"},
})
return nil
default:
return fmt.Errorf("offscreen: unknown command %q", name)
}
}
// start assembles the brief and kicks off the brain asynchronously. A new call
// bumps the generation so a stale in-flight result is discarded.
func (m *Mode) start() error {
m.mu.Lock()
m.gen++
gen := m.gen
m.status = statusPending
m.proposal = nil
m.errMsg = ""
m.mu.Unlock()
brief := m.assembleBrief()
var p ai.OffscreenProposal
var err error
m.async.Run(proposeTimeout,
func(ctx context.Context) { p, err = m.ai.Propose(ctx, brief) },
func() bool { return gen != m.gen },
func() {
if err != nil {
m.status = statusError
m.errMsg = err.Error()
return
}
m.status = statusProposed
m.proposal = &p
id := newProposalID()
m.proposalID = id
m.recordAsync(memory.Event{
Kind: kindProposalMade,
At: m.now(),
Data: map[string]any{
"proposal_id": id,
"mode": "offscreen",
"next_action": p.NextAction,
"rationale": p.Rationale,
},
})
})
return nil
}
// confirm files the proposed action as a task and ends the mode. It errors if
// there is nothing to confirm yet.
func (m *Mode) confirm(ctx context.Context) error {
m.mu.Lock()
p := m.proposal
m.mu.Unlock()
if p == nil {
return errors.New("offscreen: nothing to confirm")
}
if err := m.tasks.Create(ctx, tasks.NewTask(p.NextAction)); err != nil {
return err
}
m.mu.Lock()
id := m.proposalID
m.status = statusDone
m.done = true
m.mu.Unlock()
m.recordSync(memory.Event{
Kind: kindActionTaken,
At: m.now(),
Data: map[string]any{"proposal_id": id, "mode": "offscreen", "next_action": p.NextAction},
})
return nil
}
// View projects the mode's state for surfaces. Always JSON-marshalable.
func (m *Mode) View() any {
m.mu.Lock()
defer m.mu.Unlock()
v := map[string]any{"status": m.status}
if m.proposal != nil {
v["next_action"] = m.proposal.NextAction
v["rationale"] = m.proposal.Rationale
}
if m.errMsg != "" {
v["error"] = m.errMsg
}
return v
}
// Factory builds a fresh off-screen mode and kicks off the brief immediately.
func Factory(svc harness.Services) mode.Mode {
m := New(svc)
_ = m.start()
return m
}
// recordAsync persists an event off the mode's lock, best-effort. Used on the
// propose path, which runs under the mode mutex (async apply).
func (m *Mode) recordAsync(ev memory.Event) {
if m.mem == nil {
return
}
go func() {
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}()
}
// recordSync persists an event inline, best-effort. Used on confirm/dismiss,
// which already do synchronous work.
func (m *Mode) recordSync(ev memory.Event) {
if m.mem == nil {
return
}
if err := m.mem.Record(context.Background(), ev); err != nil {
log.Printf("offscreen: memory record %s: %v", ev.Kind, err)
}
}
// newProposalID returns a short random hex id correlating a proposal with its
// outcome event.
func newProposalID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return "proposal"
}
return hex.EncodeToString(b[:])
}
+248
View File
@@ -0,0 +1,248 @@
package offscreen
import (
"context"
"errors"
"strings"
"testing"
"time"
"keel/internal/ai"
"keel/internal/harness"
"keel/internal/memory"
"keel/internal/tasks"
)
// stubBackend is a local fake ai.Backend; ai's package-internal fake is not
// reachable from this package, so we define our own here.
type stubBackend struct {
out string
err error
}
func (b stubBackend) Name() string { return "stub" }
func (b stubBackend) Run(context.Context, string) (string, error) {
return b.out, b.err
}
type fakeTasks struct {
created []string
}
func (f *fakeTasks) Today(context.Context) ([]tasks.Task, error) { return nil, nil }
func (f *fakeTasks) Create(_ context.Context, t tasks.Task) error {
f.created = append(f.created, t.Title)
return nil
}
func newTestMode(t *testing.T, ft *fakeTasks, backend ai.Backend) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Clock: func() time.Time { return time.Unix(0, 0) },
Notify: func() {},
}
return New(svc)
}
// waitStatus polls the mode's View until status reaches want, or fails after 2s.
func waitStatus(t *testing.T, m *Mode, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
v, _ := m.View().(map[string]any)
if v != nil && v["status"] == want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("status never reached %q (last view: %+v)", want, m.View())
}
func okBackend() stubBackend {
return stubBackend{out: `{"next_action":"call mum","rationale":"people goal"}`}
}
func TestConfirmFilesExactlyOneTask(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
if len(ft.created) != 1 {
t.Fatalf("created %d tasks, want 1", len(ft.created))
}
if ft.created[0] != "call mum" {
t.Fatalf("created task title = %q, want %q", ft.created[0], "call mum")
}
if m.Active() {
t.Fatal("mode should be done after confirm")
}
}
func TestDismissWritesNothing(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
if len(ft.created) != 0 {
t.Fatalf("dismiss created %d tasks, want 0", len(ft.created))
}
if m.Active() {
t.Fatal("mode should be done after dismiss")
}
}
func TestProposeErrorSurfacesInView(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, stubBackend{err: errors.New("boom")})
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusError)
v, _ := m.View().(map[string]any)
if v == nil || v["error"] == nil || v["error"] == "" {
t.Fatalf("expected error field in view, got %+v", v)
}
if !m.Active() {
t.Fatal("mode should stay active on error so the user can retry or dismiss")
}
}
func TestConfirmWithoutProposalErrors(t *testing.T) {
ft := &fakeTasks{}
m := newTestMode(t, ft, okBackend())
// No start → no proposal.
if err := m.Command(context.Background(), "confirm", nil); err == nil {
t.Fatal("confirm without a proposal should error")
}
if len(ft.created) != 0 {
t.Fatalf("confirm without proposal created %d tasks, want 0", len(ft.created))
}
}
func TestUnknownCommandErrors(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend())
if err := m.Command(context.Background(), "bogus", nil); err == nil {
t.Fatal("unknown command should error")
}
}
// newTestModeMem is newTestMode with an injected in-memory Store.
func newTestModeMem(t *testing.T, ft *fakeTasks, backend ai.Backend, mem *memory.Fake) *Mode {
t.Helper()
svc := harness.Services{
AI: ai.NewService(backend),
Tasks: ft,
Memory: mem,
Clock: func() time.Time { return time.Unix(1000, 0) },
Notify: func() {},
}
return New(svc)
}
// waitEvent polls the fake until an event of kind appears, or fails after 2s.
func waitEvent(t *testing.T, f *memory.Fake, kind string) memory.Event {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
for _, e := range f.Events() {
if e.Kind == kind {
return e
}
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("event %q never recorded (events: %+v)", kind, f.Events())
return memory.Event{}
}
func TestProposeRecordsProposalMade(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
if err := m.Command(context.Background(), "start", nil); err != nil {
t.Fatalf("start: %v", err)
}
waitStatus(t, m, statusProposed)
ev := waitEvent(t, mem, "proposal_made")
if ev.Data["next_action"] != "call mum" || ev.Data["proposal_id"] == "" {
t.Fatalf("proposal_made data = %+v", ev.Data)
}
}
func TestConfirmRecordsActionTakenWithSameID(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
made := waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "confirm", nil); err != nil {
t.Fatalf("confirm: %v", err)
}
taken := waitEvent(t, mem, "action_taken")
if taken.Data["proposal_id"] != made.Data["proposal_id"] {
t.Fatalf("action_taken id %v != proposal_made id %v", taken.Data["proposal_id"], made.Data["proposal_id"])
}
}
func TestDismissRecordsProposalDismissed(t *testing.T) {
mem := memory.NewFake()
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
_ = m.Command(context.Background(), "start", nil)
waitStatus(t, m, statusProposed)
_ = waitEvent(t, mem, "proposal_made")
if err := m.Command(context.Background(), "dismiss", nil); err != nil {
t.Fatalf("dismiss: %v", err)
}
_ = waitEvent(t, mem, "proposal_dismissed")
}
func TestBriefIncludesRecentProposalsWithOutcome(t *testing.T) {
mem := memory.NewFake()
ctx := context.Background()
// at = the test clock (Unix 1000); confirmed walk, dismissed stretch.
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w", "next_action": "walk"}})
_ = mem.Record(ctx, memory.Event{Kind: "action_taken", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "w"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_made", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s", "next_action": "stretch"}})
_ = mem.Record(ctx, memory.Event{Kind: "proposal_dismissed", At: time.Unix(1000, 0),
Data: map[string]any{"proposal_id": "s"}})
m := newTestModeMem(t, &fakeTasks{}, okBackend(), mem)
brief := m.assembleBrief()
if !strings.Contains(brief, "Recently proposed") {
t.Fatalf("brief missing history section:\n%s", brief)
}
if !strings.Contains(brief, "walk") || !strings.Contains(brief, "confirmed") {
t.Fatalf("brief missing confirmed walk:\n%s", brief)
}
if !strings.Contains(brief, "stretch") || !strings.Contains(brief, "dismissed") {
t.Fatalf("brief missing dismissed stretch:\n%s", brief)
}
}
func TestBriefOmitsHistoryWhenNoMemory(t *testing.T) {
m := newTestMode(t, &fakeTasks{}, okBackend()) // no Memory injected
if strings.Contains(m.assembleBrief(), "Recently proposed") {
t.Fatal("history section should be absent without memory")
}
}
+45
View File
@@ -0,0 +1,45 @@
// internal/notify/notify.go
// Package notify is Keel's desktop-notification effector: a best-effort
// "interrupt the user" primitive behind a Notifier port. On Linux with
// notify-send installed it pops a toast; everywhere else it is a silent nop.
// Mirrors internal/enforce: a pure OS primitive, all policy lives in callers.
package notify
import (
"context"
"fmt"
"os/exec"
)
// Notifier shows a desktop notification. Best-effort: it returns an error for
// diagnostics, but callers never block on it and treat failure as "no toast."
type Notifier interface {
Notify(ctx context.Context, title, body string) error
}
// NewNotifier returns the notify-send adapter when that binary is on PATH, else
// a nop. This makes a missing binary (or a non-Linux host) degrade to silence
// rather than an error.
func NewNotifier() Notifier {
if path, err := exec.LookPath("notify-send"); err == nil {
return sendNotifier{bin: path}
}
return nopNotifier{}
}
// sendNotifier shells out to notify-send. The app name groups Keel's toasts.
type sendNotifier struct{ bin string }
func (s sendNotifier) Notify(ctx context.Context, title, body string) error {
cmd := exec.CommandContext(ctx, s.bin, "--app-name=Keel", title, body)
if err := cmd.Run(); err != nil {
return fmt.Errorf("notify: notify-send: %w", err)
}
return nil
}
// nopNotifier is the silent fallback.
type nopNotifier struct{}
func (nopNotifier) Notify(context.Context, string, string) error { return nil }
+22
View File
@@ -0,0 +1,22 @@
// internal/notify/notify_test.go
package notify
import (
"context"
"testing"
)
// nopNotifier must be returned when notify-send is absent, and must never error.
func TestNopNotifierIsSilentAndSafe(t *testing.T) {
n := nopNotifier{}
if err := n.Notify(context.Background(), "title", "body"); err != nil {
t.Fatalf("nop Notify must return nil, got %v", err)
}
}
// NewNotifier always returns a usable Notifier (never nil), whatever the host.
func TestNewNotifierNeverNil(t *testing.T) {
if NewNotifier() == nil {
t.Fatal("NewNotifier returned nil")
}
}
-753
View File
@@ -1,753 +0,0 @@
// Package session owns the daemon's in-memory state of truth and persists a
// snapshot on every change. Transitions go through the pure statemachine. It
// also owns per-session evidence stats: it accumulates active-window time while
// Active, logs raw focus events, and seals each session into the audit chain.
package session
import (
"context"
"errors"
"log"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/statemachine"
"antidrift/internal/store"
"github.com/google/uuid"
)
const (
unavailableTitle = "(evidence unavailable)"
sessionRetention = 30 * 24 * time.Hour
)
const coachTimeout = 60 * time.Second
const (
coachIdle = "idle"
coachPending = "pending"
coachReady = "ready"
coachError = "error"
)
const (
driftDebounce = 10 * time.Second
driftTimeout = 30 * time.Second
)
const (
driftIdle = "idle"
driftPending = "pending"
driftOnTask = "ontask"
driftDrifting = "drifting"
)
var ErrNotPlanning = errors.New("session: coaching is only available while planning")
var ErrNotActive = errors.New("session: only available while a commitment is active")
// bucketKey identifies a time bucket; Title is the scrubbed title.
type bucketKey struct{ Class, Title string }
// EvidenceStats is the in-memory accounting for the current session only.
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
// Controller holds runtime state and the active commitment behind a mutex.
type Controller struct {
mu sync.Mutex
runtimeState domain.RuntimeState
commitment *domain.Commitment
deadline time.Time
snapshotPath string
auditPath string
sessionsDir string
clock func() time.Time
onChange func()
latestWindow evidence.WindowSnapshot
stats *EvidenceStats
outcomePending string
coach ai.Coach
coachStatus string
coachProposal *ai.Proposal
coachErr string
coachGen int
allowedClasses []string // durable: the active session's allowed window classes
judge ai.DriftJudge
driftStatus string
driftReason string
driftGen int
lastJudgedAt time.Time
judgedClasses map[string]ai.Verdict
}
// CommitmentView is the UI projection of the active commitment.
type CommitmentView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
DeadlineUnixSecs int64 `json:"deadline_unix_secs"`
}
// ProposalView / CoachView project the ephemeral planning-coach state.
type ProposalView struct {
NextAction string `json:"next_action"`
SuccessCondition string `json:"success_condition"`
TimeboxSecs int64 `json:"timebox_secs"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
}
// DriftView is the active-only drift projection.
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
}
type CoachView struct {
Status string `json:"status"`
Proposal *ProposalView `json:"proposal,omitempty"`
Error string `json:"error,omitempty"`
}
// WindowView / BucketView / EvidenceView are the evidence projection.
type WindowView struct {
Class string `json:"class"`
Title string `json:"title"`
}
type BucketView struct {
Class string `json:"class"`
Title string `json:"title"`
Seconds int64 `json:"seconds"`
}
type EvidenceView struct {
Available bool `json:"available"`
Reason string `json:"reason"`
Current WindowView `json:"current"`
SwitchCount int `json:"switch_count"`
Buckets []BucketView `json:"buckets"`
}
// State is the broadcastable view of the controller.
type State struct {
RuntimeState domain.RuntimeState `json:"runtime_state"`
Commitment *CommitmentView `json:"commitment"`
Evidence *EvidenceView `json:"evidence"`
Coach *CoachView `json:"coach,omitempty"`
Drift *DriftView `json:"drift,omitempty"`
}
// New loads any persisted snapshot, prunes stale session logs, and rebuilds
// in-memory stats from the raw log if a live session was interrupted.
func New(snapshotPath string) (*Controller, error) {
s, err := store.Load(snapshotPath)
if err != nil {
return nil, err
}
dir := filepath.Dir(snapshotPath)
c := &Controller{
runtimeState: s.RuntimeState,
commitment: s.Commitment,
snapshotPath: snapshotPath,
auditPath: filepath.Join(dir, "audit.jsonl"),
sessionsDir: filepath.Join(dir, "sessions"),
clock: time.Now,
outcomePending: s.OutcomePending,
}
if s.DeadlineUnixSecs > 0 {
c.deadline = time.Unix(s.DeadlineUnixSecs, 0)
}
if c.runtimeState == "" {
c.runtimeState = domain.RuntimeLocked
}
_ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock())
if c.runtimeState == domain.RuntimeActive && s.SessionID != "" {
c.allowedClasses = s.AllowedWindowClasses
// Drift state is not persisted: recompute fresh after restart to avoid
// stale interrupts. This also initializes judgedClasses (the pipeline
// writes to it) so a restored Active session never panics on a nil map.
c.resetDriftLocked()
c.replayStats(s.SessionID)
}
return c, nil
}
// SetClock overrides the time source (tests only). Call before starting a
// session.
func (c *Controller) SetClock(f func() time.Time) {
c.mu.Lock()
c.clock = f
c.mu.Unlock()
}
// SetOnChange registers a callback fired after an evidence-driven state change
// (focus updates). It is invoked with the mutex released.
func (c *Controller) SetOnChange(f func()) {
c.mu.Lock()
c.onChange = f
c.mu.Unlock()
}
func (c *Controller) notify() {
c.mu.Lock()
f := c.onChange
c.mu.Unlock()
if f != nil {
f()
}
}
// State returns the current broadcastable state. Safe for concurrent use.
func (c *Controller) State() State {
c.mu.Lock()
defer c.mu.Unlock()
return c.stateLocked()
}
// Deadline returns the active commitment deadline, or the zero time.
func (c *Controller) Deadline() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.deadline
}
func (c *Controller) stateLocked() State {
st := State{RuntimeState: c.runtimeState}
if c.commitment != nil {
view := &CommitmentView{
NextAction: c.commitment.NextAction,
SuccessCondition: c.commitment.SuccessCondition,
TimeboxSecs: c.commitment.TimeboxSecs,
}
if !c.deadline.IsZero() {
view.DeadlineUnixSecs = c.deadline.Unix()
}
st.Commitment = view
}
if c.stats != nil {
st.Evidence = &EvidenceView{
Available: c.stats.Current.Health.Available,
Reason: c.stats.Current.Health.Reason,
Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title},
SwitchCount: c.stats.SwitchCount,
Buckets: bucketViews(c.stats.Buckets),
}
}
if c.runtimeState == domain.RuntimePlanning {
status := c.coachStatus
if status == "" {
status = coachIdle
}
cv := &CoachView{Status: status, Error: c.coachErr}
if c.coachProposal != nil {
cv.Proposal = &ProposalView{
NextAction: c.coachProposal.NextAction,
SuccessCondition: c.coachProposal.SuccessCondition,
TimeboxSecs: c.coachProposal.TimeboxSecs,
AllowedWindowClasses: c.coachProposal.AllowedWindowClasses,
}
}
st.Coach = cv
}
if c.runtimeState == domain.RuntimeActive {
status := c.driftStatus
if status == "" {
status = driftIdle
}
st.Drift = &DriftView{Status: status, Reason: c.driftReason}
}
return st
}
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView {
out := make([]BucketView, 0, len(buckets))
for k, d := range buckets {
out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds })
return out
}
func (c *Controller) persistLocked() error {
snap := store.Snapshot{
RuntimeState: c.runtimeState,
Commitment: c.commitment,
OutcomePending: c.outcomePending,
}
if !c.deadline.IsZero() {
snap.DeadlineUnixSecs = c.deadline.Unix()
}
if c.stats != nil {
snap.SessionID = c.stats.SessionID
}
snap.AllowedWindowClasses = c.allowedClasses
return store.Save(c.snapshotPath, snap)
}
// EnterPlanning moves Locked -> Planning.
func (c *Controller) EnterPlanning() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning)
if err != nil {
return err
}
c.runtimeState = next
c.resetCoachLocked()
return c.persistLocked()
}
// SetCoach injects the AI coach. Mirrors SetOnChange. A nil coach makes
// RequestCoach degrade gracefully.
func (c *Controller) SetCoach(coach ai.Coach) {
c.mu.Lock()
c.coach = coach
c.mu.Unlock()
}
// resetCoachLocked returns coach state to idle and invalidates any in-flight
// request. Caller holds mu.
func (c *Controller) resetCoachLocked() {
c.coachStatus = coachIdle
c.coachProposal = nil
c.coachErr = ""
c.coachGen++
}
// AllowedClassesForTest exposes the session allowed classes for tests.
func (c *Controller) AllowedClassesForTest() []string {
c.mu.Lock()
defer c.mu.Unlock()
return append([]string(nil), c.allowedClasses...)
}
// SetDriftJudge injects the AI drift judge. A nil judge keeps local matching
// working; unmatched windows simply stay idle.
func (c *Controller) SetDriftJudge(j ai.DriftJudge) {
c.mu.Lock()
c.judge = j
c.mu.Unlock()
}
// resetDriftLocked clears all drift machinery for a fresh session. Caller holds mu.
func (c *Controller) resetDriftLocked() {
c.driftStatus = driftIdle
c.driftReason = ""
c.driftGen++
c.lastJudgedAt = time.Time{}
c.judgedClasses = map[string]ai.Verdict{}
}
// OnTask appends the current window class to the session allowed-context, clears
// drift, drops any cached verdict for that class, and persists. The class now
// matches locally and is never re-judged.
func (c *Controller) OnTask() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
var class string
if c.stats != nil {
class = c.stats.Current.Class
}
if strings.TrimSpace(class) != "" {
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if !evidence.MatchesAllowed(ac, class, "") {
c.allowedClasses = append(c.allowedClasses, strings.TrimSpace(class))
}
delete(c.judgedClasses, class)
}
c.driftStatus = driftOnTask
c.driftReason = ""
return c.persistLocked()
}
// Refocus clears the current drift verdict without changing allowed-context, and
// drops the cached verdict for the current class so it may be judged again later.
func (c *Controller) Refocus() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.runtimeState != domain.RuntimeActive {
return ErrNotActive
}
if c.stats != nil {
delete(c.judgedClasses, c.stats.Current.Class)
}
c.driftStatus = driftIdle
c.driftReason = ""
return c.persistLocked()
}
// RequestCoach starts an async coach call for intent. Returns ErrNotPlanning if
// not in planning; otherwise never a hard error (failures surface as coach
// state). The proposal is ephemeral and never persisted.
func (c *Controller) RequestCoach(intent string) error {
c.mu.Lock()
if c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return ErrNotPlanning
}
if c.coach == nil {
c.coachStatus = coachError
c.coachErr = "coach unavailable"
c.coachProposal = nil
c.mu.Unlock()
c.notify()
return nil
}
c.coachGen++
gen := c.coachGen
c.coachStatus = coachPending
c.coachErr = ""
c.coachProposal = nil
coach := c.coach
c.mu.Unlock()
c.notify()
go func() {
ctx, cancel := context.WithTimeout(context.Background(), coachTimeout)
defer cancel()
prop, err := coach.Coach(ctx, intent)
c.mu.Lock()
if gen != c.coachGen || c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return // stale or left planning: discard
}
if err != nil {
c.coachStatus = coachError
c.coachErr = coachErrorMessage(err)
c.coachProposal = nil
} else {
c.coachStatus = coachReady
c.coachProposal = &prop
c.coachErr = ""
}
c.mu.Unlock()
c.notify()
}()
return nil
}
func coachErrorMessage(err error) string {
switch {
case errors.Is(err, ai.ErrEmptyResponse), errors.Is(err, ai.ErrNoJSON), errors.Is(err, ai.ErrInvalidProposal):
return "coach returned an unusable response"
case errors.Is(err, context.DeadlineExceeded):
return "coach timed out"
default:
return "coach unavailable"
}
}
// StartManualCommitment validates input, activates a new commitment, mints a
// session, seeds evidence stats from the latest window, and moves Planning ->
// Active.
func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error {
c.mu.Lock()
defer c.mu.Unlock()
commitment, err := domain.NewManual(nextAction, successCondition, timebox)
if err != nil {
return err
}
commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate)
if err != nil {
return err
}
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted)
if err != nil {
return err
}
now := c.clock()
c.runtimeState = next
c.commitment = &commitment
c.deadline = now.Add(timebox)
c.outcomePending = ""
c.resetCoachLocked()
c.allowedClasses = append([]string(nil), allowedClasses...)
c.resetDriftLocked()
sessionID := "session-" + uuid.Must(uuid.NewV7()).String()
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: now.Unix(),
Buckets: map[bucketKey]time.Duration{},
}
seed := c.latestWindow
_ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed))
c.applyEvent(now, seed)
return c.persistLocked()
}
// Complete moves Active -> Review with a "completed" outcome.
func (c *Controller) Complete() error { return c.enterReview("completed") }
// Expire moves Active -> Review with an "expired" outcome (timebox elapsed).
func (c *Controller) Expire() error { return c.enterReview("expired") }
func (c *Controller) enterReview(outcome string) error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview)
if err != nil {
return err
}
if c.commitment != nil {
completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete)
if err != nil {
return err
}
c.commitment.State = completed
}
// Flush the final open segment, then freeze accounting.
if c.stats != nil && c.stats.hasLast {
c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt)
c.stats.hasLast = false
}
c.runtimeState = next
c.outcomePending = outcome
return c.persistLocked()
}
// End moves Review -> Locked, writes the session summary to the audit chain,
// and clears the commitment and stats.
func (c *Controller) End() error {
c.mu.Lock()
defer c.mu.Unlock()
next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod)
if err != nil {
return err
}
if c.stats != nil {
if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil {
// State integrity over audit completeness: the transition still
// completes. Surfaced for the operator; no auto-retry in M1.
log.Printf("session: audit append failed: %v", err)
}
}
c.runtimeState = next
c.commitment = nil
c.deadline = time.Time{}
c.stats = nil
c.outcomePending = ""
c.allowedClasses = nil
c.resetDriftLocked()
return c.persistLocked()
}
func (c *Controller) buildSummaryLocked() store.SessionSummary {
buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets))
for k, d := range c.stats.Buckets {
buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())})
}
sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds })
outcome := c.outcomePending
if outcome == "" {
outcome = "completed"
}
var na, sc string
if c.commitment != nil {
na, sc = c.commitment.NextAction, c.commitment.SuccessCondition
}
return store.SessionSummary{
SessionID: c.stats.SessionID,
NextAction: na,
SuccessCond: sc,
Outcome: outcome,
StartedUnix: c.stats.StartedUnix,
EndedUnix: c.clock().Unix(),
SwitchCount: c.stats.SwitchCount,
Buckets: buckets,
}
}
// RecordWindow ingests one sensor observation. It accumulates time only while
// Active, always tracks the latest window for display, and fires onChange after
// releasing the mutex.
func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) {
c.mu.Lock()
c.latestWindow = snap
if c.runtimeState != domain.RuntimeActive || c.stats == nil {
c.mu.Unlock()
c.notify()
return
}
now := c.clock()
_ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap))
c.applyEvent(now, snap)
launch := c.evaluateDriftLocked(now, snap)
c.mu.Unlock()
if launch != nil {
go launch()
}
c.notify()
}
// evaluateDriftLocked runs the local-first drift pipeline for one observation
// and updates synchronous drift state. When an async judgment is warranted it
// returns the judging closure; the caller runs it in a goroutine after
// releasing the mutex (the M2 RequestCoach discipline). Caller holds mu and has
// already verified the runtime is Active.
func (c *Controller) evaluateDriftLocked(now time.Time, snap evidence.WindowSnapshot) func() {
class, title := snap.Class, snap.Title
// 1. Local match: authoritative on-task, no LLM.
ac := domain.AllowedContext{WindowClasses: c.allowedClasses}
if evidence.MatchesAllowed(ac, class, title) {
c.driftStatus = driftOnTask
c.driftReason = ""
return nil
}
// 2. Per-class cache.
if v, ok := c.judgedClasses[class]; ok {
c.applyVerdictLocked(v)
return nil
}
// 3. No judge wired: never block; leave idle.
if c.judge == nil {
c.driftStatus = driftIdle
c.driftReason = ""
return nil
}
// 4. Debounce: at most one judgment per driftDebounce window.
if !c.lastJudgedAt.IsZero() && now.Sub(c.lastJudgedAt) < driftDebounce {
return nil
}
prevStatus, prevReason := c.driftStatus, c.driftReason
c.driftGen++
gen := c.driftGen
c.lastJudgedAt = now
c.driftStatus = driftPending
c.driftReason = ""
judge := c.judge
commitment := ""
if c.commitment != nil {
commitment = c.commitment.NextAction + " — " + c.commitment.SuccessCondition
}
return func() {
ctx, cancel := context.WithTimeout(context.Background(), driftTimeout)
defer cancel()
v, err := judge.JudgeDrift(ctx, commitment, class, title)
c.mu.Lock()
if gen != c.driftGen || c.runtimeState != domain.RuntimeActive {
c.mu.Unlock()
return // stale: a newer judgment started or the session ended
}
if err != nil {
// Degrade: never fabricate drift. Revert the optimistic pending.
if c.driftStatus == driftPending {
c.driftStatus = prevStatus
c.driftReason = prevReason
}
c.mu.Unlock()
log.Printf("session: drift judge failed: %v", err)
c.notify()
return
}
c.judgedClasses[class] = v
if c.stats != nil && c.stats.Current.Class == class {
c.applyVerdictLocked(v)
}
c.mu.Unlock()
c.notify()
}
}
// applyVerdictLocked maps a verdict onto drift state. Caller holds mu.
func (c *Controller) applyVerdictLocked(v ai.Verdict) {
if v.OnTask {
c.driftStatus = driftOnTask
c.driftReason = ""
return
}
c.driftStatus = driftDrifting
c.driftReason = v.Reason
}
// applyEvent advances stats by one observation: it credits the prior segment to
// the prior bucket, counts a context switch on key change, and records the new
// current window. Used by both live tracking and crash replay. Caller holds mu.
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) {
if c.stats.hasLast {
c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt)
}
newKey := keyFor(snap)
if c.stats.hasLast && newKey != c.stats.lastKey {
c.stats.SwitchCount++
}
c.stats.lastKey = newKey
c.stats.lastFocusAt = now
c.stats.hasLast = true
c.stats.Current = snap
}
// replayStats rebuilds in-memory stats from the raw session log after a crash.
func (c *Controller) replayStats(sessionID string) {
events, err := store.ReplaySession(c.sessionsDir, sessionID)
if err != nil || len(events) == 0 {
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: c.clock().Unix(),
Buckets: map[bucketKey]time.Duration{},
}
return
}
c.stats = &EvidenceStats{
SessionID: sessionID,
StartedUnix: events[0].AtUnixMillis / 1000,
Buckets: map[bucketKey]time.Duration{},
}
for _, e := range events {
c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e))
}
}
func keyFor(snap evidence.WindowSnapshot) bucketKey {
if !snap.Health.Available {
return bucketKey{Class: "", Title: unavailableTitle}
}
return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)}
}
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent {
return store.FocusEvent{
AtUnixMillis: now.UnixMilli(),
Class: snap.Class,
Title: snap.Title,
Available: snap.Health.Available,
Reason: snap.Health.Reason,
}
}
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot {
return evidence.WindowSnapshot{
Title: e.Title,
Class: e.Class,
Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason},
}
}
-551
View File
@@ -1,551 +0,0 @@
package session
import (
"context"
"errors"
"path/filepath"
"sync/atomic"
"testing"
"time"
"antidrift/internal/ai"
"antidrift/internal/domain"
"antidrift/internal/evidence"
"antidrift/internal/store"
)
func newTestController(t *testing.T) (*Controller, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "state.json")
c, err := New(path)
if err != nil {
t.Fatalf("new controller: %v", err)
}
return c, path
}
func TestHappyPathDrivesStates(t *testing.T) {
c, _ := newTestController(t)
if c.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("should start Locked")
}
if err := c.EnterPlanning(); err != nil {
t.Fatalf("enter planning: %v", err)
}
if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil {
t.Fatalf("start commitment: %v", err)
}
st := c.State()
if st.RuntimeState != domain.RuntimeActive {
t.Fatalf("should be Active, got %s", st.RuntimeState)
}
if st.Commitment == nil || st.Commitment.NextAction != "Port session" {
t.Fatalf("active commitment missing: %+v", st.Commitment)
}
if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() {
t.Fatalf("deadline should be in the future")
}
if err := c.Complete(); err != nil {
t.Fatalf("complete: %v", err)
}
if c.State().RuntimeState != domain.RuntimeReview {
t.Fatalf("should be Review after complete")
}
if err := c.End(); err != nil {
t.Fatalf("end: %v", err)
}
if c.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("should be Locked after end")
}
}
func TestStartCommitmentRejectsInvalidInput(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil {
t.Fatalf("empty next action should error")
}
if c.State().RuntimeState != domain.RuntimePlanning {
t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState)
}
}
func TestStateRestoresFromSnapshot(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
_ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil)
second, err := New(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
st := second.State()
if st.RuntimeState != domain.RuntimeActive {
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
}
if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" {
t.Fatalf("restored commitment missing: %+v", st.Commitment)
}
}
func TestRestoredCommitmentKeepsDeadline(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
_ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil)
want := first.State().Commitment.DeadlineUnixSecs
second, err := New(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
got := second.State().Commitment
if got == nil || got.DeadlineUnixSecs != want {
t.Fatalf("restored deadline: got %+v want %d", got, want)
}
}
// fakeClock returns successive instants on demand.
type fakeClock struct{ now time.Time }
func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) }
func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } }
func snap(class, title string) evidence.WindowSnapshot {
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
}
func bucketSeconds(t *testing.T, st State, class, title string) int64 {
t.Helper()
if st.Evidence == nil {
t.Fatalf("expected evidence projection")
}
for _, b := range st.Evidence.Buckets {
if b.Class == class && b.Title == title {
return b.Seconds
}
}
return -1
}
func TestAccumulatesBucketsAndSwitches(t *testing.T) {
c, _ := newTestController(t)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
c.RecordWindow(snap("code", "antidrift")) // latest window before start
_ = c.EnterPlanning()
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift
clk.advance(10 * time.Second)
c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox
clk.advance(20 * time.Second)
c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back
st := c.State()
if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 {
t.Errorf("code bucket = %d, want 10", got)
}
if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 {
t.Errorf("firefox bucket = %d, want 20", got)
}
if st.Evidence.SwitchCount != 2 {
t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount)
}
}
func TestNoAccountingOutsideActive(t *testing.T) {
c, _ := newTestController(t)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
// Locked: RecordWindow must not create stats.
c.RecordWindow(snap("code", "x"))
if c.State().Evidence != nil {
t.Fatalf("no session: evidence should be nil")
}
}
func TestUnavailableAccruesToReservedBucket(t *testing.T) {
c, _ := newTestController(t)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
_ = c.EnterPlanning()
_ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow
// latestWindow was zero-value (unavailable) at seed time.
clk.advance(5 * time.Second)
c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket
st := c.State()
if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 {
t.Errorf("unavailable bucket = %d, want 5", got)
}
}
func TestCrashReplayRebuildsStats(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, _ := New(path)
clk := &fakeClock{now: time.Unix(1000, 0)}
first.SetClock(clk.fn())
first.RecordWindow(snap("code", "antidrift"))
_ = first.EnterPlanning()
_ = first.StartManualCommitment("work", "done", 25*time.Minute, nil)
clk.advance(15 * time.Second)
first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch
// Simulate crash + restart: New replays the raw log.
second, err := New(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
st := second.State()
if st.RuntimeState != domain.RuntimeActive {
t.Fatalf("restored state should be Active, got %s", st.RuntimeState)
}
if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 {
t.Errorf("replayed code bucket = %d, want 15", got)
}
if st.Evidence.SwitchCount != 1 {
t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount)
}
}
func TestEndWritesAuditSummary(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "state.json")
c, _ := New(path)
clk := &fakeClock{now: time.Unix(1000, 0)}
c.SetClock(clk.fn())
c.RecordWindow(snap("code", "antidrift"))
_ = c.EnterPlanning()
_ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil)
clk.advance(30 * time.Second)
c.RecordWindow(snap("firefox", "docs"))
clk.advance(10 * time.Second)
if err := c.Complete(); err != nil { // flush: +10s to firefox/docs
t.Fatalf("complete: %v", err)
}
if err := c.End(); err != nil {
t.Fatalf("end: %v", err)
}
auditPath := filepath.Join(dir, "audit.jsonl")
if err := store.VerifyChain(auditPath); err != nil {
t.Fatalf("chain should verify: %v", err)
}
}
type fakeCoach struct {
prop ai.Proposal
err error
gate chan struct{} // if non-nil, Coach blocks until it receives
}
func (f *fakeCoach) Coach(ctx context.Context, intent string) (ai.Proposal, error) {
if f.gate != nil {
<-f.gate
}
return f.prop, f.err
}
// waitCoachStatus polls until the coach view reaches want, or fails after 2s.
func waitCoachStatus(t *testing.T, c *Controller, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
st := c.State()
if st.Coach != nil && st.Coach.Status == want {
return st
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("coach status never reached %q (last: %+v)", want, c.State().Coach)
return State{}
}
func TestRequestCoachReady(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "Do X", SuccessCondition: "X done", TimeboxSecs: 1500}})
if err := c.RequestCoach("do x"); err != nil {
t.Fatalf("request coach: %v", err)
}
st := waitCoachStatus(t, c, "ready")
if st.Coach.Proposal == nil || st.Coach.Proposal.NextAction != "Do X" || st.Coach.Proposal.TimeboxSecs != 1500 {
t.Fatalf("ready proposal wrong: %+v", st.Coach)
}
}
func TestRequestCoachError(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
c.SetCoach(&fakeCoach{err: errors.New("backend down")})
if err := c.RequestCoach("x"); err != nil {
t.Fatalf("request: %v", err)
}
st := waitCoachStatus(t, c, "error")
if st.Coach.Error == "" {
t.Fatal("error status should carry a message")
}
}
func TestRequestCoachUnavailable(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
if err := c.RequestCoach("x"); err != nil {
t.Fatalf("nil coach must degrade, not error: %v", err)
}
st := c.State()
if st.Coach == nil || st.Coach.Status != "error" {
t.Fatalf("want error status for nil coach, got %+v", st.Coach)
}
}
func TestRequestCoachWrongState(t *testing.T) {
c, _ := newTestController(t)
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
if err := c.RequestCoach("x"); err == nil {
t.Fatal("coaching from Locked should error")
}
if c.State().Coach != nil {
t.Fatal("no coach view outside planning")
}
}
func TestRequestCoachStaleResultDiscarded(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
slow := &fakeCoach{prop: ai.Proposal{NextAction: "OLD", SuccessCondition: "old", TimeboxSecs: 60}, gate: make(chan struct{})}
c.SetCoach(slow)
_ = c.RequestCoach("first") // gen1 pending, goroutine blocks on gate
fast := &fakeCoach{prop: ai.Proposal{NextAction: "NEW", SuccessCondition: "new", TimeboxSecs: 120}}
c.SetCoach(fast)
_ = c.RequestCoach("second") // gen2 -> ready NEW
st := waitCoachStatus(t, c, "ready")
if st.Coach.Proposal.NextAction != "NEW" {
t.Fatalf("expected NEW, got %+v", st.Coach.Proposal)
}
close(slow.gate) // release gen1; it must be discarded
time.Sleep(50 * time.Millisecond)
if got := c.State().Coach.Proposal.NextAction; got != "NEW" {
t.Fatalf("stale gen1 overwrote state: %q", got)
}
}
func TestOnTaskRequiresActive(t *testing.T) {
c, _ := newTestController(t)
if err := c.OnTask(); !errors.Is(err, ErrNotActive) {
t.Fatalf("OnTask outside Active should error, got %v", err)
}
if err := c.Refocus(); !errors.Is(err, ErrNotActive) {
t.Fatalf("Refocus outside Active should error, got %v", err)
}
}
func TestStartCommitmentPersistsAllowedClasses(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil {
t.Fatalf("start: %v", err)
}
second, err := New(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := second.AllowedClassesForTest(); len(got) != 1 || got[0] != "code" {
t.Fatalf("allowed classes not restored: %#v", got)
}
}
func TestOnTaskAppendsCurrentClass(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
_ = c.StartManualCommitment("a", "b", 25*time.Minute, nil)
c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}})
if err := c.OnTask(); err != nil {
t.Fatalf("ontask: %v", err)
}
got := c.AllowedClassesForTest()
if len(got) != 1 || got[0] != "slack" {
t.Fatalf("OnTask should append current class, got %#v", got)
}
st := c.State()
if st.Drift == nil || st.Drift.Status != "ontask" {
t.Fatalf("OnTask should set drift ontask, got %+v", st.Drift)
}
}
type fakeJudge struct {
verdict ai.Verdict
err error
gate chan struct{}
calls int32
}
func (f *fakeJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) {
atomic.AddInt32(&f.calls, 1)
if f.gate != nil {
<-f.gate
}
return f.verdict, f.err
}
func waitDriftStatus(t *testing.T, c *Controller, want string) State {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
st := c.State()
if st.Drift != nil && st.Drift.Status == want {
return st
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("drift status never reached %q (last: %+v)", want, c.State().Drift)
return State{}
}
func startActive(t *testing.T, c *Controller, allowed []string) {
t.Helper()
if err := c.EnterPlanning(); err != nil {
t.Fatalf("planning: %v", err)
}
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil {
t.Fatalf("start: %v", err)
}
}
func obs(class, title string) evidence.WindowSnapshot {
return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}}
}
func TestLocalMatchIsOnTaskWithoutJudge(t *testing.T) {
c, _ := newTestController(t)
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("Code", "main.go"))
st := c.State()
if st.Drift == nil || st.Drift.Status != "ontask" {
t.Fatalf("local match should be on-task, got %+v", st.Drift)
}
if atomic.LoadInt32(&fj.calls) != 0 {
t.Fatalf("judge must not be called on a local match")
}
}
func TestUnmatchedWindowIsJudgedDrifting(t *testing.T) {
c, _ := newTestController(t)
c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "YouTube is off-task"}})
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
st := waitDriftStatus(t, c, "drifting")
if st.Drift.Reason != "YouTube is off-task" {
t.Fatalf("reason not surfaced: %+v", st.Drift)
}
}
func TestPerClassCacheAvoidsSecondJudge(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
waitDriftStatus(t, c, "drifting")
// Advance past the debounce window so a second judge call would be allowed:
// this isolates the per-class cache as the only reason the judge is skipped.
now = now.Add(2 * driftDebounce)
c.RecordWindow(obs("firefox", "Reddit")) // same class, should hit cache
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fj.calls); got != 1 {
t.Fatalf("cached class should not re-judge, calls=%d", got)
}
}
func TestDebounceLimitsJudgeCalls(t *testing.T) {
c, _ := newTestController(t)
now := time.Now()
c.SetClock(func() time.Time { return now })
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "a")) // launches one (blocked on gate)
c.RecordWindow(obs("chrome", "b")) // within debounce window -> suppressed
time.Sleep(50 * time.Millisecond)
if got := atomic.LoadInt32(&fj.calls); got != 1 {
t.Fatalf("debounce should allow one judge call, calls=%d", got)
}
close(fj.gate)
}
func TestStaleJudgmentDiscardedAfterEnd(t *testing.T) {
c, _ := newTestController(t)
fj := &fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}, gate: make(chan struct{})}
c.SetDriftJudge(fj)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube")) // launches, blocks on gate
waitDriftStatus(t, c, "pending")
_ = c.Complete()
_ = c.End()
close(fj.gate) // release stale goroutine; must be discarded
time.Sleep(50 * time.Millisecond)
if c.State().RuntimeState != domain.RuntimeLocked {
t.Fatalf("should be Locked")
}
}
func TestNilJudgeLeavesUnmatchedIdle(t *testing.T) {
c, _ := newTestController(t)
startActive(t, c, []string{"code"})
c.RecordWindow(obs("firefox", "YouTube"))
st := c.State()
if st.Drift == nil || st.Drift.Status != "idle" {
t.Fatalf("nil judge should leave unmatched idle, got %+v", st.Drift)
}
}
func TestRestoredActiveSessionCanBeJudged(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
first, err := New(path)
if err != nil {
t.Fatalf("new: %v", err)
}
_ = first.EnterPlanning()
if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}); err != nil {
t.Fatalf("start: %v", err)
}
second, err := New(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
second.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}})
second.RecordWindow(obs("firefox", "YouTube")) // must not panic on nil judgedClasses map
waitDriftStatus(t, second, "drifting")
}
func TestLeavingPlanningClearsCoach(t *testing.T) {
c, _ := newTestController(t)
_ = c.EnterPlanning()
c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}})
_ = c.RequestCoach("x")
waitCoachStatus(t, c, "ready")
if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil {
t.Fatalf("start: %v", err)
}
if c.State().Coach != nil {
t.Fatal("coach view must be gone once Active")
}
}
+123
View File
@@ -0,0 +1,123 @@
// Package settings persists the daemon's user-editable configuration as a single
// JSON file (~/.keel/settings.json), parallel to the store snapshot. It is a
// leaf type package: it imports nothing else in the app so any layer may depend
// on the Settings value without pulling in adapters.
package settings
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
)
// ErrInvalidBackend marks a settings value whose ai_backend is not a known
// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400
// without importing the ai package.
var ErrInvalidBackend = errors.New("settings: invalid ai backend")
// ErrInvalidAmbientMode marks a settings value whose ambient_mode is not a
// known mode. The applier returns it (wrapped) so the HTTP layer can map it to
// 400 without importing the ambient package.
var ErrInvalidAmbientMode = errors.New("settings: invalid ambient mode")
// Ambient mode dial values.
const (
AmbientOff = "off"
AmbientStatus = "status"
AmbientNotify = "notify"
)
// ValidAmbientMode reports whether m is a known ambient mode constant.
func ValidAmbientMode(m string) bool {
switch m {
case AmbientOff, AmbientStatus, AmbientNotify:
return true
}
return false
}
// Settings is the user-editable configuration. Field names mirror the env vars
// they replace: KEEL_AI_BACKEND, KEEL_MARVIN_CMD, KEEL_KNOWLEDGE_FILE, KEEL_AW_URL,
// KEEL_AMBIENT_MODE, KEEL_AMBIENT_CADENCE_SECS.
type Settings struct {
AIBackend string `json:"ai_backend"`
MarvinCmd string `json:"marvin_cmd"`
KnowledgePath string `json:"knowledge_path"`
AWURL string `json:"aw_url"`
AmbientMode string `json:"ambient_mode"`
AmbientCadenceSecs int `json:"ambient_cadence_secs"`
}
// DefaultPath returns ~/.keel/settings.json.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".keel", "settings.json"), nil
}
// Load reads a settings file. A missing file is an error; callers treat that as
// "first run" and seed instead.
func Load(path string) (Settings, error) {
data, err := os.ReadFile(path)
if err != nil {
return Settings{}, err
}
var s Settings
if err := json.Unmarshal(data, &s); err != nil {
return Settings{}, err
}
return s, nil
}
// Save writes settings atomically (temp file + rename), creating the directory
// if needed. Mirrors store.Save.
func Save(path string, s Settings) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// SeedFromEnv builds a Settings from the KEEL_* env vars, applying
// the same defaults the daemon used before the settings file existed. An unset
// AI backend defaults to "claude" (matching ai.NewBackend("")).
func SeedFromEnv() Settings {
backend := os.Getenv("KEEL_AI_BACKEND")
if backend == "" {
backend = "claude"
}
awURL := os.Getenv("KEEL_AW_URL")
if awURL == "" {
awURL = "http://localhost:5600"
}
ambientMode := os.Getenv("KEEL_AMBIENT_MODE")
if ambientMode == "" {
ambientMode = AmbientNotify
}
ambientCadenceSecs := 300
if v := os.Getenv("KEEL_AMBIENT_CADENCE_SECS"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
ambientCadenceSecs = n
}
}
return Settings{
AIBackend: backend,
MarvinCmd: os.Getenv("KEEL_MARVIN_CMD"),
KnowledgePath: os.Getenv("KEEL_KNOWLEDGE_FILE"),
AWURL: awURL,
AmbientMode: ambientMode,
AmbientCadenceSecs: ambientCadenceSecs,
}
}
+112
View File
@@ -0,0 +1,112 @@
package settings
import (
"os"
"path/filepath"
"testing"
)
func TestSaveLoadRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "settings.json")
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600"}
if err := Save(path, want); err != nil {
t.Fatalf("save: %v", err)
}
got, err := Load(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if got != want {
t.Errorf("round trip = %+v, want %+v", got, want)
}
}
func TestLoadMissingFileIsError(t *testing.T) {
_, err := Load(filepath.Join(t.TempDir(), "nope.json"))
if err == nil {
t.Fatal("want error loading missing file, got nil")
}
}
func TestSeedFromEnvReadsVars(t *testing.T) {
t.Setenv("KEEL_AI_BACKEND", "codex")
t.Setenv("KEEL_MARVIN_CMD", "uv run am")
t.Setenv("KEEL_KNOWLEDGE_FILE", "/tmp/k.md")
t.Setenv("KEEL_AW_URL", "http://aw.local:5600")
t.Setenv("KEEL_AMBIENT_MODE", "")
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
got := SeedFromEnv()
want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md", AWURL: "http://aw.local:5600", AmbientMode: AmbientNotify, AmbientCadenceSecs: 300}
if got != want {
t.Errorf("SeedFromEnv = %+v, want %+v", got, want)
}
}
func TestSeedFromEnvDefaultsBackend(t *testing.T) {
t.Setenv("KEEL_AI_BACKEND", "")
t.Setenv("KEEL_MARVIN_CMD", "")
t.Setenv("KEEL_KNOWLEDGE_FILE", "")
got := SeedFromEnv()
if got.AIBackend != "claude" {
t.Errorf("default backend = %q, want claude", got.AIBackend)
}
}
func TestSaveCreatesDir(t *testing.T) {
path := filepath.Join(t.TempDir(), "sub", "settings.json")
if err := Save(path, Settings{AIBackend: "claude"}); err != nil {
t.Fatalf("save: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("file not written: %v", err)
}
}
func TestSeedFromEnvAWURL(t *testing.T) {
t.Setenv("KEEL_AW_URL", "http://aw.example:9999")
if got := SeedFromEnv().AWURL; got != "http://aw.example:9999" {
t.Fatalf("AWURL = %q", got)
}
}
func TestSeedFromEnvAWURLDefault(t *testing.T) {
t.Setenv("KEEL_AW_URL", "")
if got := SeedFromEnv().AWURL; got != "http://localhost:5600" {
t.Fatalf("default AWURL = %q", got)
}
}
func TestValidAmbientMode(t *testing.T) {
for _, m := range []string{AmbientOff, AmbientStatus, AmbientNotify} {
if !ValidAmbientMode(m) {
t.Fatalf("%q should be valid", m)
}
}
if ValidAmbientMode("loud") {
t.Fatal("loud should be invalid")
}
if ValidAmbientMode("") {
t.Fatal("empty should be invalid (callers default before validating)")
}
}
func TestSeedFromEnvAmbientDefaults(t *testing.T) {
t.Setenv("KEEL_AMBIENT_MODE", "")
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "")
s := SeedFromEnv()
if s.AmbientMode != AmbientNotify {
t.Fatalf("ambient_mode default = %q, want %q", s.AmbientMode, AmbientNotify)
}
if s.AmbientCadenceSecs != 300 {
t.Fatalf("ambient_cadence_secs default = %d, want 300", s.AmbientCadenceSecs)
}
}
func TestSeedFromEnvAmbientFromEnv(t *testing.T) {
t.Setenv("KEEL_AMBIENT_MODE", "status")
t.Setenv("KEEL_AMBIENT_CADENCE_SECS", "120")
s := SeedFromEnv()
if s.AmbientMode != "status" || s.AmbientCadenceSecs != 120 {
t.Fatalf("env not honored: mode=%q cadence=%d", s.AmbientMode, s.AmbientCadenceSecs)
}
}
+196
View File
@@ -0,0 +1,196 @@
// Package statusfile mirrors the harness's runtime status into a single-line
// file (~/.keel_status) so an external consumer — a window-manager status
// bar — can display it with a plain file read.
package statusfile
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
)
// statusFileName is the file written under the user's home directory.
const statusFileName = ".keel_status"
// interval is how often the writer re-renders. Minute granularity is enough for
// a status bar, so a coarse tick keeps the write rate (and disk churn) low.
const interval = time.Minute
// DefaultPath resolves ~/.keel_status.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, statusFileName), nil
}
// Render produces the single status line for the harness envelope, dispatching
// on the active mode. An idle harness ("") renders the ambient coaching line as
// "⚠ <line>" when one is present, else "idle"; an unknown kind renders a generic
// fallback so a future mode degrades gracefully.
func Render(env mode.Envelope, ambientLine string, now time.Time) string {
switch env.ActiveMode {
case "":
if line := strings.TrimSpace(ambientLine); line != "" {
return "⚠ " + line
}
return "idle"
case "focus":
st, ok := env.Mode.(focus.State)
if !ok {
// Defensive: the envelope claims focus but carries an unexpected
// payload. Render a safe fallback instead of panicking on the assert.
return "focus"
}
return renderFocus(st, now)
case "offscreen":
return renderOffscreen(env.Mode)
default:
return env.ActiveMode
}
}
// renderOffscreen produces the off-screen status line from the mode's untyped
// View payload (a map[string]any read from the live in-process envelope). It
// shows the proposed next action when one is present, a warning glyph on error,
// and a "thinking…" placeholder otherwise. Any payload mismatch degrades to the
// placeholder rather than panicking.
func renderOffscreen(m any) string {
const thinking = "off-screen: thinking…"
view, ok := m.(map[string]any)
if !ok {
return thinking
}
status, _ := view["status"].(string)
switch status {
case "proposed":
na, _ := view["next_action"].(string)
if na = strings.TrimSpace(na); na != "" {
return "off-screen: " + na
}
return thinking
case "error":
return "off-screen: ⚠"
default:
return thinking
}
}
// renderFocus produces the focus status line. It is empty for Locked/idle focus
// states so a bar module can hide itself. Drift status strings ("drifting")
// match the JSON contract the web UI already consumes.
func renderFocus(st focus.State, now time.Time) string {
switch st.RuntimeState {
case domain.RuntimeActive:
timer := remaining(st, now)
switch {
case st.Drift != nil && st.Drift.Status == "drifting":
return "⚠ DRIFT " + timer
case st.Drift != nil && st.Drift.Nudge != "":
return "● " + timer + " ·?"
default:
return "● " + timer
}
case domain.RuntimePlanning:
return "◔ planning"
case domain.RuntimeReview:
return "✓ review"
default:
return ""
}
}
// remaining formats the minutes left until the commitment deadline, rounded up
// so a partial minute still reads as a minute and the count hits 0m only at the
// end. It returns 0m when no deadline is known.
func remaining(st focus.State, now time.Time) string {
if st.Commitment == nil || st.Commitment.DeadlineUnixSecs == 0 {
return "0m"
}
left := time.Unix(st.Commitment.DeadlineUnixSecs, 0).Sub(now)
if left < 0 {
left = 0
}
mins := int((left + time.Minute - 1) / time.Minute) // ceil
return fmt.Sprintf("%dm", mins)
}
// Writer periodically renders the harness envelope (plus the ambient line) and
// writes it to a file, rewriting only when the line changes.
type Writer struct {
path string
state func() mode.Envelope
ambient func() string
now func() time.Time
wake chan struct{}
last string
wrote bool
}
// NewWriter builds a Writer for path, reading the harness envelope via state and
// the ambient coaching line via ambient (either may be supplied; a nil ambient
// is treated as "" by write).
func NewWriter(path string, state func() mode.Envelope, ambient func() string) *Writer {
return &Writer{path: path, state: state, ambient: ambient, now: time.Now, wake: make(chan struct{}, 1)}
}
// Wake asks the writer to re-render now rather than at the next tick. It is the
// hook the harness's change notifications fire through, so drift transitions
// reach the status bar promptly instead of lagging the web UI by up to a tick.
// The signal is coalesced (buffered, size 1): a burst of changes collapses into
// a single re-render, and the actual write still happens on the Run goroutine,
// so concurrent callers never race on the file.
func (w *Writer) Wake() {
select {
case w.wake <- struct{}{}:
default: // a re-render is already pending
}
}
// Run writes the status file immediately, then re-renders on each wake or tick
// when the rendered line has changed. The tick still advances the minute
// countdown when nothing else changes. It removes the file on ctx cancellation
// so a stale status does not linger after shutdown.
func (w *Writer) Run(ctx context.Context) {
t := time.NewTicker(interval)
defer t.Stop()
w.write()
for {
select {
case <-ctx.Done():
_ = os.Remove(w.path)
return
case <-w.wake:
w.write()
case <-t.C:
w.write()
}
}
}
func (w *Writer) write() {
line := ""
if w.ambient != nil {
line = w.ambient()
}
rendered := Render(w.state(), line, w.now())
if w.wrote && rendered == w.last {
return
}
if err := os.WriteFile(w.path, []byte(rendered), 0o644); err != nil {
log.Printf("statusfile: write %s: %v", w.path, err)
return
}
w.last = rendered
w.wrote = true
}
+196
View File
@@ -0,0 +1,196 @@
package statusfile
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"keel/internal/mode"
"keel/internal/mode/focus"
"keel/internal/mode/focus/domain"
)
// focusEnv wraps a focus.State in the harness envelope the writer now consumes.
func focusEnv(st focus.State) mode.Envelope {
return mode.Envelope{ActiveMode: "focus", Mode: st}
}
// focusState builds a focus.State with the given runtime state.
func focusState(rt domain.RuntimeState) focus.State {
return focus.State{RuntimeState: rt}
}
func activeState(deadline int64, driftStatus, nudge string) focus.State {
st := focus.State{
RuntimeState: domain.RuntimeActive,
Commitment: &focus.CommitmentView{DeadlineUnixSecs: deadline},
}
if driftStatus != "" || nudge != "" {
st.Drift = &focus.DriftView{Status: driftStatus, Nudge: nudge}
}
return st
}
func TestRender(t *testing.T) {
now := time.Unix(1_000_000, 0)
in24m := now.Add(24 * time.Minute).Unix()
cases := []struct {
name string
env mode.Envelope
want string
}{
{"idle", mode.Envelope{}, "idle"},
{"locked focus", focusEnv(focusState(domain.RuntimeLocked)), ""},
{"empty focus runtime", focusEnv(focus.State{}), ""},
{"planning", focusEnv(focusState(domain.RuntimePlanning)), "◔ planning"},
{"review", focusEnv(focusState(domain.RuntimeReview)), "✓ review"},
{"active ontask", focusEnv(activeState(in24m, "ontask", "")), "● 24m"},
{"active no drift view", focusEnv(activeState(in24m, "", "")), "● 24m"},
{"active drifting", focusEnv(activeState(in24m, "drifting", "")), "⚠ DRIFT 24m"},
{"active nudge", focusEnv(activeState(in24m, "ontask", "wandered off")), "● 24m ·?"},
{"drift outranks nudge", focusEnv(activeState(in24m, "drifting", "wandered off")), "⚠ DRIFT 24m"},
{"active no deadline", focusEnv(focusState(domain.RuntimeActive)), "● 0m"},
{"active past deadline", focusEnv(activeState(now.Add(-5*time.Minute).Unix(), "ontask", "")), "● 0m"},
{"active partial minute rounds up", focusEnv(activeState(now.Add(10*time.Second).Unix(), "ontask", "")), "● 1m"},
{"unknown kind falls back to kind name", mode.Envelope{ActiveMode: "house"}, "house"},
{"focus with wrong payload falls back", mode.Envelope{ActiveMode: "focus", Mode: "bogus"}, "focus"},
{"offscreen proposed", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "call mum"}}, "off-screen: call mum"},
{"offscreen pending", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "pending"}}, "off-screen: thinking…"},
{"offscreen error", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "error", "error": "boom"}}, "off-screen: ⚠"},
{"offscreen proposed no action", mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed"}}, "off-screen: thinking…"},
{"offscreen wrong payload", mode.Envelope{ActiveMode: "offscreen", Mode: "bogus"}, "off-screen: thinking…"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Render(tc.env, "", now); got != tc.want {
t.Errorf("Render() = %q, want %q", got, tc.want)
}
})
}
}
func TestWriterWritesAndDedups(t *testing.T) {
path := filepath.Join(t.TempDir(), "status")
now := time.Unix(1_000_000, 0)
env := focusEnv(focusState(domain.RuntimePlanning))
w := NewWriter(path, func() mode.Envelope { return env }, nil)
w.now = func() time.Time { return now }
w.write()
if got := readFile(t, path); got != "◔ planning" {
t.Fatalf("first write = %q", got)
}
// Same state: file untouched. Detect by clearing it and confirming the
// deduped write does not recreate content.
if err := os.WriteFile(path, []byte("sentinel"), 0o644); err != nil {
t.Fatal(err)
}
w.write()
if got := readFile(t, path); got != "sentinel" {
t.Errorf("deduped write should not rewrite, got %q", got)
}
// Changed state: rewrites.
env = focusEnv(focusState(domain.RuntimeLocked))
w.write()
if got := readFile(t, path); got != "" {
t.Errorf("changed write = %q, want empty", got)
}
}
func TestWriterRemovesFileOnCancel(t *testing.T) {
path := filepath.Join(t.TempDir(), "status")
w := NewWriter(path, func() mode.Envelope {
return focusEnv(focusState(domain.RuntimePlanning))
}, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { w.Run(ctx); close(done) }()
// Wait for the immediate write.
waitFor(t, func() bool { _, err := os.Stat(path); return err == nil })
cancel()
<-done
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("file should be removed on cancel, stat err = %v", err)
}
}
// Wake makes the writer re-render on a state change instead of waiting for the
// coarse minute tick, so the status bar tracks drift transitions promptly
// instead of lagging the web UI by up to a minute.
func TestWriterWakeRendersPromptly(t *testing.T) {
path := filepath.Join(t.TempDir(), "status")
now := time.Unix(1_000_000, 0)
in10m := now.Add(10 * time.Minute).Unix()
var mu sync.Mutex
st := activeState(in10m, "ontask", "")
w := NewWriter(path, func() mode.Envelope { mu.Lock(); defer mu.Unlock(); return focusEnv(st) }, nil)
w.now = func() time.Time { return now }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
waitFor(t, func() bool { return fileEquals(path, "● 10m") })
mu.Lock()
st = activeState(in10m, "drifting", "")
mu.Unlock()
w.Wake()
waitFor(t, func() bool { return fileEquals(path, "⚠ DRIFT 10m") })
}
func TestRenderIdleShowsAmbientLine(t *testing.T) {
got := Render(mode.Envelope{ActiveMode: ""}, "deep in YouTube, no goal", time.Unix(0, 0))
if got != "⚠ deep in YouTube, no goal" {
t.Fatalf("idle+ambient render = %q", got)
}
}
func TestRenderIdleNoAmbientLineIsIdle(t *testing.T) {
if got := Render(mode.Envelope{ActiveMode: ""}, "", time.Unix(0, 0)); got != "idle" {
t.Fatalf("idle+no-ambient render = %q, want idle", got)
}
}
func TestRenderActiveModeIgnoresAmbientLine(t *testing.T) {
// A non-idle envelope renders its own mode, never the ambient line.
got := Render(mode.Envelope{ActiveMode: "offscreen", Mode: map[string]any{"status": "proposed", "next_action": "walk"}}, "ambient ignored", time.Unix(0, 0))
if got == "ambient ignored" || got == "⚠ ambient ignored" {
t.Fatalf("active mode must not render the ambient line, got %q", got)
}
}
func fileEquals(path, want string) bool {
b, err := os.ReadFile(path)
return err == nil && string(b) == want
}
func readFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("condition not met within timeout")
}
+19
View File
@@ -74,6 +74,25 @@ func readSummaries(path string) ([]SessionSummary, error) {
return out, sc.Err()
}
// RecentSessions returns up to n most-recent summaries from the audit chain in
// oldest-first order. A missing/empty chain or n <= 0 yields (nil, nil).
func RecentSessions(path string, n int) ([]SessionSummary, error) {
if n <= 0 {
return nil, nil
}
all, err := readSummaries(path)
if err != nil {
return nil, err
}
if len(all) == 0 {
return nil, nil
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, nil
}
// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns
// the next contiguous Seq, computes its Hash, and appends one JSON line with an
// fsync.
+42
View File
@@ -75,3 +75,45 @@ func TestTamperDetected(t *testing.T) {
t.Fatalf("tamper on line 2 should be reported, got %v", err)
}
}
func TestRecentSessionsReturnsLastNOldestFirst(t *testing.T) {
path := auditFixture(t)
for _, id := range []string{"a", "b", "c"} {
if err := AppendSession(path, sampleSummary("session-"+id)); err != nil {
t.Fatalf("append %s: %v", id, err)
}
}
got, err := RecentSessions(path, 2)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(got) != 2 {
t.Fatalf("want 2 summaries, got %d", len(got))
}
if got[0].SessionID != "session-b" || got[1].SessionID != "session-c" {
t.Fatalf("want [b c] oldest-first, got [%s %s]", got[0].SessionID, got[1].SessionID)
}
}
func TestRecentSessionsFewerThanN(t *testing.T) {
path := auditFixture(t)
_ = AppendSession(path, sampleSummary("session-a"))
got, err := RecentSessions(path, 5)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(got) != 1 || got[0].SessionID != "session-a" {
t.Fatalf("want [a], got %+v", got)
}
}
func TestRecentSessionsMissingOrZero(t *testing.T) {
if got, err := RecentSessions(auditFixture(t), 5); err != nil || got != nil {
t.Fatalf("missing chain: want (nil,nil), got (%+v,%v)", got, err)
}
path := auditFixture(t)
_ = AppendSession(path, sampleSummary("session-a"))
if got, err := RecentSessions(path, 0); err != nil || got != nil {
t.Fatalf("n=0: want (nil,nil), got (%+v,%v)", got, err)
}
}
+9 -3
View File
@@ -9,7 +9,7 @@ import (
"os"
"path/filepath"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
// Snapshot is the persisted current state of the daemon.
@@ -21,15 +21,21 @@ type Snapshot struct {
OutcomePending string `json:"outcome_pending,omitempty"`
AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"`
ReflectionStatus string `json:"reflection_status,omitempty"`
ReflectionRecap string `json:"reflection_recap,omitempty"`
CarryForward string `json:"carry_forward,omitempty"`
EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"`
}
// DefaultPath returns ~/.antidrift/state.json.
// DefaultPath returns ~/.keel/state.json.
func DefaultPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".antidrift", "state.json"), nil
return filepath.Join(home, ".keel", "state.json"), nil
}
// Load reads a snapshot. A missing file yields a default Locked snapshot.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"antidrift/internal/domain"
"keel/internal/mode/focus/domain"
)
func TestLoadMissingFileReturnsLocked(t *testing.T) {

Some files were not shown because too many files have changed in this diff Show More