Remove shipped specs and plans; history and code are the record

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 22:00:52 -04:00
parent b447432df0
commit 14ec2b6ea8
29 changed files with 0 additions and 19782 deletions
@@ -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.
@@ -1,262 +0,0 @@
# M3.5 — Semantic Nudge — Design
Date: 2026-05-31
## Purpose
M3 makes drift visible when the user switches to the *wrong application* — a
cheap local match owns the common case and the LLM judges the ambiguous ones,
producing an interruptive banner. But that machinery is structurally blind to a
second failure mode: the user sits in an **allowed application the whole time**
and still drifts — coding the wrong project in the editor, reading tangential
docs in an allowed browser. Local match is authoritative for on-task and caches
per window-class, so once a class is allowed the user is never re-judged while
in it. They can rabbit-hole for an hour and be called perfectly on-task.
M3.5 adds the third and final advisor role, **`Nudge`**, to close that gap. It
catches *"right app, wrong work"***semantic** drift within allowed apps —
with a soft, ambient, periodic check-in. It is the deliberate, narrow follow-on
that completes the original M3 roadmap entry ("drift interceptor + ambient
nudge").
## Scope
**In scope (M3.5):**
- A new `Nudge` AI role behind a leaf-preserving interface (`ai.Nudger`);
`Service` implements it via the same CLI backends as the coach and drift
judge. Signature takes primitives only: `Nudge(ctx, commitment string,
recentTitles []string) (string, error)`, returning an advisory sentence, or
`""` when the trajectory is still on-task.
- A small in-memory ring of the **last 10 distinct window titles** seen during
the active session — the trajectory signal the nudge judges against.
- The nudge wired into `evaluateDriftLocked`'s **local-match on-task branch
only**: it runs precisely where the drift judge stays silent, so the two are
mutually exclusive per observation and never overlap.
- Debounced to at most one nudge per `nudgeDebounce` (5 min); run in a
background goroutine; gated to Active sessions with ≥ 2 titles of history.
- Surfaced over SSE as a new `DriftView.Nudge` field; the active view renders a
visually distinct **soft "Heads up" tier** on the existing drift banner —
dismiss-only, no action buttons.
- Graceful degradation: a nil nudger leaves the system silent; everything else
works unchanged.
**Out of scope:**
- Any enforcement: the nudge informs and never forces — same as the drift
interceptor. Enforcement remains M8.
- Persisting the nudge message (ephemeral, like the drift verdict — recomputes
after restart).
- A server-side dismiss route: dismissal is client-side, keyed on the message
text, and auto-clears when the trajectory recovers or the session changes.
- Re-running the nudge on the *cached-on-task* or *judged-on-task* paths. The
nudge is gated strictly to the local-match-authoritative on-task path. (Once
"This is on task" appends a class to the allowed list, it becomes a local
match thereafter, so the realistic "I'm in my app" case is covered.)
- Changing the hard local rules. "Outlook during a focus session = violation"
stays exactly where it is in the interceptor; the nudge is purely the soft
semantic layer above it.
## Architecture
M3.5 extends the ports-and-adapters shape of M1/M2/M3 without adding any new
infrastructure. The `ai` package gains a third role (`Nudger`) but stays a
**leaf package**: like `Coach` and `DriftJudge`, `Nudger` takes primitive
strings, not `domain`/`evidence` types. `session.Controller` orchestrates the
debounced async nudge with the *exact same discipline* as the M3 drift judge —
debounce, on-task-stretch epoch guard, goroutine launched after releasing the
mutex, `notify()` only with the mutex released, never fabricate on error.
### The `ai.Nudger` role
```go
// Nudger judges whether recent activity within an allowed app still serves the
// commitment. 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)
}
```
`Service.Nudge` runs `buildNudgePrompt(commitment, recentTitles)` on the
backend and parses the result. The prompt asks for exactly:
```json
{"on_track": <true or false>, "message": "<one short sentence>"}
```
Parsing mirrors `parseVerdict` and reuses the shared `extractJSON` /
`ErrEmptyResponse` / `ErrNoJSON` helpers:
- `on_track: true` → return `""` (nothing to surface).
- `on_track: false` with a non-empty `message` → return the trimmed message.
- `on_track: false` with **no** message → treat as on-track (return `""`). A
concern with no words is unusable and would only add noise, so the parser
swallows it rather than erroring. (This differs from `parseVerdict`, which
rejects a reasonless drift as `ErrInvalidVerdict` because the drift banner is
interruptive; the nudge is ambient and silent-by-default, so the safe
degenerate is silence, not an error.)
- Empty / no-JSON / malformed output → the corresponding error, surfaced to the
caller, which logs and stays silent.
### Controller state
New fields on `Controller` (all reset per session in `resetDriftLocked`):
- `nudge ai.Nudger` — the injected judge; nil disables nudging.
- `recentTitles []string` — ring of the last 10 distinct titles this session.
- `nudgeMessage string` — the current soft advisory ("" = none).
- `lastNudgedAt time.Time` — debounce timestamp.
A soft nudge advisory belongs to one continuous on-task stretch in an allowed
app, so the nudge is guarded by an on-task-stretch epoch (`nudgeEpoch`) rather
than `driftGen` (which bumps on every drift-judgment launch). `nudgeEpoch`
advances on session reset (`resetDriftLocked`) and whenever an observation is
not a local on-task match — i.e. the stretch ended. A nudge captures the epoch
at launch and applies its result only if the epoch is unchanged, so a nudge
whose stretch has since ended (a drift episode or a session change) is discarded
instead of surfacing stale. Leaving the allowed app additionally clears any
already-set advisory. The net effect: the advisory auto-clears when the
trajectory changes or recovers, and a stale "Heads up" never resurfaces after a
drift episode.
New constants alongside the drift ones:
```go
nudgeDebounce = 5 * time.Minute
nudgeTimeout = 30 * time.Second
```
### Data flow
`RecordWindow` is unchanged in shape: it still captures a single `launch func()`
from `evaluateDriftLocked` and runs it via `go launch()` after unlocking. The
drift judge and the nudge are **mutually exclusive** per observation —
matched → maybe-nudge; unmatched → maybe-judge — so one closure return covers
both.
1. **Recent-titles ring.** While Active, `RecordWindow` appends the observed
title to `recentTitles` when it is non-empty and differs from the most recent
entry, capping the slice at 10 (drop oldest). This happens before drift
evaluation so the latest title is in view.
2. **Nudge branch.** In `evaluateDriftLocked` step 1, when `MatchesAllowed`
returns true, the controller sets `driftOnTask` as today and then evaluates
nudge eligibility:
- `c.nudge != nil`, runtime is Active, `len(c.recentTitles) >= 2`, and
`lastNudgedAt` is zero or `now.Sub(lastNudgedAt) >= nudgeDebounce`.
- If eligible: stamp `lastNudgedAt = now`, capture `epoch := c.nudgeEpoch`,
the commitment string (same `NextAction — SuccessCondition` form as the
drift judge), and a **copy** of `recentTitles`; return the nudge closure.
- If not eligible: return `nil` (unchanged behavior).
3. **Nudge closure** (runs in the goroutine):
- Calls `nudge.Nudge(ctx, commitment, titles)` under a `nudgeTimeout`
context.
- Re-acquires the lock; if `epoch != c.nudgeEpoch || c.runtimeState !=
RuntimeActive` → stale, return.
- On error → log, leave `nudgeMessage` unchanged (no fabrication), unlock,
return. (`lastNudgedAt` stays set, so a failed call does not immediately
retry.)
- On success → set `c.nudgeMessage = msg` (which may be `""`, clearing a
prior advisory once the trajectory recovers); unlock; `notify()`.
### Surfacing
`DriftView` gains one field:
```go
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Nudge string `json:"nudge,omitempty"`
}
```
The nudge is a **separate axis** from `Status`: it is populated precisely when
`Status == "ontask"`, so it cannot be folded into the status enum.
`stateLocked` sets `Nudge: c.nudgeMessage` whenever it builds the `DriftView`.
In `index.html`, `updateActiveDrift(drift)` renders, in priority order:
- `drift.status === "drifting"` → the existing hard banner (Back to task / This
is on task / End session). Unchanged.
- else if `drift.nudge` is non-empty → a **soft "Heads up" tier**: muted
styling clearly lower-stakes than the hard banner, the message text, and a
single **Dismiss** control. No Refocus/OnTask/Complete buttons.
- else → clear the region.
**Client-side dismiss.** Dismiss hides the soft tier and remembers the dismissed
message text in a module-level variable; the renderer skips re-showing that
exact text. When the nudge message changes (a new concern) or clears and later
returns, it shows again. No server round-trip, no new route.
### Wiring
`cmd/antidriftd/main.go`: the single `ai.Service` already satisfies `Coach` and
`DriftJudge`; add `ctrl.SetNudge(svc)` and update the startup log line to note
the third role.
## Persistence
Nothing new is persisted. `recentTitles`, `nudgeMessage`, and `lastNudgedAt` are
all in-memory session state, cleared by `resetDriftLocked` on session start and
on the Active-restore path (the same place that already resets drift state to
avoid stale interrupts after a restart).
## Error handling
- Backend/parse failure → logged, `nudgeMessage` untouched, system silent. The
nudge never fabricates a concern, mirroring the drift judge's "never fabricate
drift" rule.
- A reasonless concern from the model degrades to silence (see parsing).
- Stale results (the on-task stretch ended via a drift episode, or the session
ended/restarted mid-call) are discarded by the `nudgeEpoch` guard.
## Testing
**`internal/ai/nudge_test.go`** (stdlib `testing`, table-driven like
`verdict_test.go`):
- `on_track: true` → `""`, nil error.
- `on_track: false` + message → trimmed message.
- `on_track: false` + empty message → `""` (tolerant degrade).
- empty output → `ErrEmptyResponse`; no-brace output → `ErrNoJSON`; malformed
JSON → wrapped parse error.
- JSON embedded in surrounding prose → extracted (exercises `extractJSON`
reuse).
**`internal/session/session_test.go`** (extend, reuse the `fakeJudge`-style
harness with a `fakeNudger`: configurable message/err, an optional gate channel,
an atomic call counter):
- Nudge fires on the local-match on-task path once history ≥ 2 and debounce has
elapsed; sets `DriftView.Nudge`.
- Nudge does **not** fire on the unmatched (drift-judge) path — verify the
nudger sees zero calls when the window is off-app.
- Debounce limits nudges to one per `nudgeDebounce` (drive with `SetClock`).
- A nudger error leaves `nudgeMessage` empty (no fabrication) and does not crash.
- An `on_track` result clears a previously set `nudgeMessage`.
- A stale nudge (session ended before the call returns) is discarded — message
not applied.
- A nil nudger leaves the on-task path silent (no nudge, no panic).
- `recentTitles` records distinct titles and caps at 10.
**`internal/web/web_test.go`**: assert the state JSON carries the `nudge` field
when set (extend an existing active-state test rather than adding a route test —
there is no new route).
All tests pass under `go test -race ./...`; `go vet ./...` clean.
## File structure
| File | Change |
|------|--------|
| `internal/ai/nudge.go` | new: `Nudger` interface, `Service.Nudge`, `buildNudgePrompt`, `parseNudge` |
| `internal/ai/nudge_test.go` | new: parse/role tests |
| `internal/session/session.go` | nudge fields, constants, `recentTitles` ring in `RecordWindow`, nudge branch + closure in `evaluateDriftLocked`, `SetNudge`, `resetDriftLocked` additions, `DriftView.Nudge`, `stateLocked` wiring |
| `internal/session/session_test.go` | `fakeNudger` + nudge tests |
| `internal/web/web.go` | (only if a struct/tag touch is needed; likely none — `DriftView` is in `session`) |
| `internal/web/web_test.go` | assert `nudge` in state JSON |
| `internal/web/static/index.html` | soft "Heads up" tier in `updateActiveDrift` + CSS; client-side dismiss |
| `cmd/antidriftd/main.go` | `ctrl.SetNudge(svc)` + log line |
| `README.md` | M3.5 paragraph in Status |
@@ -1,134 +0,0 @@
# M4 — "Look good" Design
**Goal:** A real design pass on the web UI: a cockpit-style, state-aware HUD that
reads at a glance, with CSS/JS split out of the inline HTML for maintainability,
and a polished review recap. No behavior changes.
**Status:** Design approved 2026-05-31. Supersedes the utilitarian inline UI
shipped through M3.5.
---
## 1. Direction & Visual System
The UI is an **instrument panel you glance at** — a cockpit, not a document.
Dark, near-black cool-neutral base. State is carried by a **single state-driven
accent**: a CSS custom property `--accent` switched by a `data-state` attribute
on the `<main>` element. The accent colors the status band's top border and the
state pill, so the frame itself communicates where you are without reading text.
### State → accent mapping
| State | `data-state` | Accent |
|--------------------|--------------|--------------------|
| locked | `locked` | dim gray |
| planning | `planning` | blue |
| active · on-task | `active` | calm green / cyan |
| active · nudge | `nudge` | amber |
| active · drifting | `drift` | red |
| review | `review` | violet-neutral |
`data-state` is derived in the client render from `runtime_state` plus, when
active, the drift sub-status (`drifting``drift`; a present `nudge``nudge`;
otherwise `active`). This mirrors the precedence already used by the status-file
renderer (drift outranks nudge).
### Design tokens (CSS custom properties)
- Surfaces / text: `--bg`, `--panel`, `--line`, `--ink`, `--ink-dim`
- State accent: `--accent` (the only variable that changes with `data-state`)
- Fixed semantic colors: `--ok`, `--warn`, `--danger`
### Typography
- Timer: heavy weight, `font-variant-numeric: tabular-nums`.
- Evidence times: `ui-monospace` so the bucket columns align.
- Band headers / pills: small, uppercase, letter-spaced (keeps the existing pill
idiom from the current UI).
- Prose: `system-ui`.
## 2. Layout — Stacked HUD Bands
Every state composes the same **band primitive**: a row with a top divider and
consistent horizontal/vertical padding. Stacking bands produces the layered HUD
look. The active session follows the approved sketch:
```
ACTIVE · on task · 7 switches ← status band (accent border-top + pill)
24:18 write the spec section ← timer band
done when: draft saved ← task band
now code·spec ● | code 18:02 … ← evidence band
[ Complete ] ← action band
```
Drift and nudge are **not** a separate floating box. When the session drifts or
is nudged, the **status band itself** changes copy and `data-state` flips, so the
whole frame goes amber/red. The same controls render inside that band:
- Drift: `Back to task` (`/refocus`), `This is on task` (`/ontask`),
`End session` (`/complete`).
- Nudge: `Dismiss` (client-only, current behavior).
- Pending: a quiet "checking focus…" line.
## 3. Per-State Treatment
- **Locked:** one dim band, large `Start planning` button (`/planning`).
- **Planning:** an intent + `Sharpen` band, then field bands — Next action,
Success condition, Minutes, Allowed apps — with the blue accent. All existing
input ids (`#intent`, `#na`, `#sc`, `#mins`, `#apps`, `#start`,
`#coachStatus`) and the coach pre-fill behavior are untouched.
- **Active:** the HUD described in §2.
- **Review (polished, presentational only):** summary bands built from data the
state already carries — `next_action`, `success_condition`, the context-switch
count, and the per-window bucket recap (reusing the existing `evidence`
fields). **No new backend data** is introduced; richer session reflection is
M7's job. The `End` button (`/end`) remains.
## 4. Structure
Split the single inline file into three files under `internal/web/static/`:
- `index.html` — markup shell only (`<head>` links the stylesheet and script).
- `app.css` — the full visual system (tokens, bands, per-state rules).
- `app.js` — the render logic, **moved verbatim**: same `render()` function,
same partial-update paths (`updateActiveDrift`, `updatePlanningCoach`), same
element ids, same `EventSource('/events')` and POST endpoints. The only
additions are the band markup in the template strings and setting
`main.dataset.state` per render.
`web.go` currently serves only `/` via `c.FileFromFS`. Add routes so the two new
assets are served from the embedded `staticFS`:
- `GET /app.css``static/app.css`
- `GET /app.js``static/app.js`
No new Go dependencies, no JavaScript build step, no framework. The embedded
static directory and the conciseness/token-efficiency ethos of the Go rewrite
are preserved.
## 5. Behavior & Data Flow — Unchanged
Same SSE stream, same partial-update `render()` logic, same element ids, same
POST endpoints, same server-authoritative expiry timer. The redesign is markup +
CSS + asset routing only. This is precisely what keeps the existing
`web_test.go` (endpoint and state-JSON assertions, markup-agnostic) green.
## 6. Testing
- **Existing `web_test.go` stays green.** It asserts on endpoint status codes and
the state JSON, not on HTML markup, so the visual rework does not touch it.
- **New Go test:** `GET /app.css` and `GET /app.js` each return `200` with the
correct `Content-Type` (`text/css`, `text/javascript` / `application/javascript`).
Asserted against the router via `httptest`, stdlib `testing` only.
- **Manual visual checklist** across the six `data-state` values: locked,
planning, active (on-task), active (nudge), active (drift), review. There is no
JavaScript test harness in this Go project; the rendering is presentational and
verified by eye, consistent with the existing approach.
## 7. Out of Scope
- Micro-interactions / motion (timer easing, accent transitions, panel slide-in)
— explicitly excluded for M4; can be a later pass.
- Any new backend data or fields on the state payload.
- M7 reflection content (real session summary, time-on-task analytics). The M4
review screen is presentational recap of already-available data only.
@@ -1,148 +0,0 @@
# M5 — Tasks Port Design
**Goal:** Add the `tasks.Provider` port — answering "what should I be doing?" —
with an Amazing Marvin adapter that shells out to `am --json`. Today's tasks
surface on the planning screen; clicking one seeds the intent field, which flows
into the existing AI coach. Read-only, no writeback, graceful degradation.
**Status:** Design approved 2026-05-31. Implements the deferred `tasks` port
named in `2026-05-31-go-focus-os-design.md`.
---
## 1. Direction
The Tasks port is the third real port, after Activity (`evidence`) and Advisor
(`ai`). It follows the pattern M1 established: a small leaf-package interface, a
single CLI adapter, and a fake for tests. It returns primitives only, so it
imports nothing from `domain` or `session`.
Its one job is to answer "what should I be doing?" with the open tasks due today
or earlier. That answer surfaces where a work intention is born — the planning
screen — as a list of clickable task titles. Clicking a title drops it into the
intent field; from there the existing coach pipeline sharpens it into a
commitment, unchanged. The task is a **seed**, not a binding link: the session
is never tied to a task ID, and nothing is written back to Marvin.
## 2. The Port
New package `internal/tasks`, a leaf package like `ai`:
```go
// Task is one to-do item. Primitives only, so tasks stays a leaf package.
type Task struct {
ID string
Title string
Day string // "YYYY-MM-DD", or "" if unscheduled
}
// Provider answers "what should I be doing?" — the open tasks due today or
// earlier.
type Provider interface {
Today(ctx context.Context) ([]Task, error)
}
```
Files under `internal/tasks/`:
- `tasks.go` — the `Provider` interface and the `Task` value type.
- `marvin.go` — the Amazing Marvin adapter and the JSON parse function.
- `tasks_test.go` / `marvin_test.go` — parse tests and adapter tests with a
fake command runner.
## 3. The Marvin Adapter
The adapter shells out exactly as `ai.claudeBackend` does: `exec.CommandContext`
with stdout captured into a buffer and failures wrapped with stderr context
(the same shape as `ai.cmdError`). It runs `am --json` (no subcommand, which
lists open tasks scheduled for today or earlier), parses the JSON array, and
maps each element to a `Task`.
`am --json` emits an array of objects of this shape (from ampy's
`_serialize_task`):
```json
[{"id": "...", "title": "...", "parentId": "...", "day": "YYYY-MM-DD", "done": false}]
```
Only `id`, `title`, and `day` are carried into `Task`; `parentId` is ignored
(no hierarchy in M5). Any element with `done: true` is dropped defensively, even
though the default listing already returns only open tasks.
Parsing is a pure function `parse([]byte) ([]Task, error)` so it can be tested
directly against fixture strings. The shell-out wrapper holds the resolved
`cmd` and `args` and a runner func, so tests can inject a fake runner instead of
executing a real process.
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND`. The environment variable
`ANTIDRIFT_MARVIN_CMD` overrides the command; it is space-split so a value like
`uv run am` or an absolute path works. Unset or empty defaults to `am`. If `am`
cannot be found or fails at call time, `Today` returns an error and the
controller degrades to "no tasks panel" — manual planning still works. This is
the same degradation contract as the AI backend: misconfiguration never fails
startup.
## 4. Controller Wiring
The wiring mirrors the planning coach, which already fetches asynchronously and
guards against stale results.
- `SetTasks(p tasks.Provider)` injects the provider, like `SetCoach`. A nil
provider turns the feature off.
- New `Controller` fields: `tasks tasks.Provider`, `tasksStatus string`
(`idle` / `pending` / `ready` / `error`), `tasksList []tasks.Task`, and
`tasksGen int` (the generation counter).
- `EnterPlanning()` resets the tasks state and, when a provider is set, starts
an **asynchronous** `Today()` fetch in a goroutine — the same structure as
`RequestCoach`: bump `tasksGen`, set `pending`, `notify()`, then on completion
re-acquire the lock and discard the result if the generation is stale or the
runtime has left planning. Tasks are **never** fetched on the synchronous
`State()` path, which runs on every SSE broadcast.
- `State()` projects a `*TasksView{Status string, Tasks []TaskView}` **only
while planning**, alongside the existing `CoachView`. `TaskView` carries the
JSON-tagged `id`, `title`, and `day`.
No new runtime states, no new transitions, no change to the state machine.
## 5. Web / UI
No new endpoints. Tasks ride in the existing SSE state payload during planning.
The planning render in `app.js` gains a small "Today" band that lists task
titles as clickable chips. Clicking a chip sets the value of `#intent`
client-side; the user then reviews it and presses Sharpen, driving the existing
`/coach` flow. A `pending` status shows a quiet "loading tasks…" line; `error`
or an empty list renders nothing. The seed click is pure client wiring — it adds
no POST route and no new server behavior.
`main.go` gains a Marvin-adapter block parallel to the existing `ai` block: read
`ANTIDRIFT_MARVIN_CMD`, construct the adapter, call `ctrl.SetTasks(...)`, and
log one line. A construction failure logs "tasks disabled" and proceeds, never
fails startup.
## 6. Testing
- **`tasks` package:** table-driven `parse` tests — a valid array, an empty
array, malformed JSON, and `done`-filtering. An adapter test that injects a
fake runner returning canned stdout (and one returning an error) to confirm
the command path and error wrapping, without spawning a process.
- **`session` package:** with a fake `Provider`, assert the `tasksStatus`
transitions (`pending``ready`, and `pending``error` on failure) and that
`State().Tasks` reflects the fetched list while planning. A nil provider
yields no `TasksView`. Leaving planning before the fetch returns discards the
stale result (generation guard).
- **`web` package:** the existing `web_test.go` stays green (it is
markup-agnostic). Add one assertion that planning-state JSON carries the tasks
when a provider is set.
- stdlib `testing` only (no testify); `go test -race ./...` stays clean; `tasks`
stays a leaf package (imports nothing from `domain` / `session` / `evidence`).
## 7. Out of Scope
- **Writeback** — marking a task done when a session completes. Deferred per the
master design ("outcome writeback … beyond the M5 tasks port").
- Projects, categories, and task hierarchy (`parentId` is dropped).
- Binding a session to a task ID. The seed is fire-and-forget text.
- Due times, labels, estimates, and other Marvin fields.
- A manual "refresh tasks" control — the fetch on entering planning is enough
for M5.
@@ -1,209 +0,0 @@
# Faithful reflection — on/off-task split — design
**Date:** 2026-06-01
**Status:** Approved (brainstorming), pending implementation plan
## Problem
When a session ends, `buildReflectionFinishedLocked` (`internal/session/roles.go`)
hands the AI reviewer the commitment, outcome, context-switch count, and the top
few time buckets as bare `class · title: Nm` lines. Nothing in that block tells
the reviewer which of those buckets were *off-task*. If 20 minutes of "firefox"
was doom-scrolling, the reviewer cannot see it and writes a charitable recap.
The daemon already knows, minute to minute, whether the active window is on- or
off-task: `evaluateDriftLocked` maintains a live `driftStatus` of `ontask` /
`drifting` / `idle` / `pending`. But that signal is never attached to the time
buckets — buckets are pure `(class, title) → duration`. This design carries the
live drift signal into the time accounting so reflection can report on-task vs
off-task honestly.
## Decisions (locked during brainstorming)
1. **Faithful, per-segment classification** — tag each slice of time with the
live `driftStatus` at the moment it is credited, accumulating on-task /
off-task / unclassified durations. Not reconstructed from end-state (which
would retro-taint earlier on-task time and is only class-level, not
tab-level).
2. **Split top lists in the reflection block** — lead with on-task / off-task /
unclassified minute totals, then a top-N on-task list and a top-N off-task
list, each capped at `reflectionTopBuckets`.
3. **In-memory only; no log schema change** — the split lives in live
`EvidenceStats`. After a mid-session daemon restart, pre-restart time replays
as `unclassified` (its drift status is no longer known). Honest degrade —
never falsely on-task. The on-disk focus log format is unchanged.
## Architecture
### The key insight: credit-time status is already correct
In `RecordWindow` (`internal/session/drift.go`), the call order per observation is:
```
applyEvent(now, snap) // credits the JUST-ENDED segment to its bucket
recordTitleLocked(...)
evaluateDriftLocked(now, snap) // reclassifies driftStatus for the NEW window
```
`applyEvent` credits the segment that just ended *before* `evaluateDriftLocked`
reclassifies. So at the instant a segment is credited, `c.driftStatus` still
holds *that segment's* classification. We read it directly — no extra
bookkeeping, no separate timeline.
This holds at the only two credit sites:
- `applyEvent` (`internal/session/stats.go`) — credits the prior segment when a
new observation arrives.
- The end-of-session flush (`internal/session/session.go:280-281`) — credits the
final open segment on the way into Review; `driftStatus` there is the current
(last) window's classification. Correct.
## Data structures (`internal/session/stats.go`)
`EvidenceStats` gains two per-bucket maps and one scalar. The existing `Buckets`
map (total time per bucket) is unchanged — it still feeds the live evidence
panel (`views.go`) and the persisted history summary (`store`), both untouched.
The split is purely additive.
```go
type EvidenceStats struct {
SessionID string
StartedUnix int64
Buckets map[bucketKey]time.Duration // total 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, total only
SwitchCount int
Current evidence.WindowSnapshot
lastFocusAt time.Time
lastKey bucketKey
hasLast bool
}
```
`OnTask` and `OffTask` are keyed per bucket because the reflection block lists
them by name. `unclassified` is a scalar because it is only ever shown as a total
(no list). The three split maps/scalar are allocated wherever `Buckets` is
allocated today: `StartManualCommitment` (`session.go:248-251`) and both
`replayStats` branches (`stats.go:45-56`).
### Crediting helper
A single helper centralizes crediting so both credit sites stay in sync and the
status→bucket mapping lives in one place:
```go
// creditLocked credits duration d to bucket k: always to the total, and to the
// on/off/unclassified split per the live drift status (the classification of the
// segment being credited). Caller holds mu.
func (c *Controller) 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
}
}
```
Status→bucket mapping:
| `driftStatus` | bucket |
|-----------------|---------------|
| `driftOnTask` | OnTask[k] |
| `driftDrifting` | OffTask[k] |
| `driftIdle` | unclassified |
| `driftPending` | unclassified |
`applyEvent`'s credit line and the end-of-session flush both call
`creditLocked` instead of writing `Buckets[...]` directly.
**Restart degrade:** `replayStats` calls `applyEvent` while `driftStatus` is at
its reset value (`driftIdle`, set by `resetDriftLocked`), so all replayed
pre-restart time routes to `unclassified`. Honest — never falsely on-task.
Invariant: for every bucket `k`, `Buckets[k] == OnTask[k] + OffTask[k] + (its
unclassified portion)`. The unclassified portion is not tracked per bucket, only
in aggregate, because it is never displayed per bucket.
## Rendering (`internal/session/roles.go`, `buildReflectionFinishedLocked`)
Compute the totals by summing the maps; `unclassified` is the scalar. Reuse the
existing `bucketViews` (`views.go:182`), which already sorts a
`map[bucketKey]time.Duration` descending by seconds, for each top-N list, capped
at `reflectionTopBuckets` (currently 3). A bucket worked both on- and off-task
(the user refocused) appears in both lists — its on-portion in one, off-portion
in the other. Faithful.
Rendered block (totals always shown; a list is omitted when empty):
```
Next action: <na>
Success condition: <sc>
Outcome: completed
On-task 35m / Off-task 22m / Unclassified 3m
Context switches: 14
On-task:
- code · roles.go: 30m
- term · go test: 5m
Off-task:
- firefox · r/news: 12m
- discord · #random: 7m
```
A fully on-task session shows the totals line and the On-task list, and omits the
`Off-task:` block entirely (and vice versa). The minute values use the same
`Seconds/60` integer-minute rendering already used for buckets.
## Error handling / degrade
- **No drift judge wired:** every off-task-candidate segment stays `driftIdle`
(see `evaluateDriftLocked` step 3), so all such time is `unclassified` rather
than off-task. The reflection block then shows on-task + unclassified totals,
which is honest: without a judge we genuinely do not know.
- **Sensor unavailable:** the segment is bucketed under the existing
`unavailableTitle` key (`keyFor`, `stats.go:62-67`) and split per the
then-current `driftStatus` like any other segment. No special case.
- **Mid-session restart:** pre-restart time → `unclassified` (above).
- **`stats == nil`:** `buildReflectionFinishedLocked` already guards `c.stats !=
nil` before rendering buckets; the split rendering sits inside that same guard.
## Testing
- **`creditLocked` unit test** (`stats_test.go` or `session_test.go`): with a
fixed `bucketKey` and duration, assert each `driftStatus` value routes to the
correct map/scalar (`driftOnTask`→`OnTask`, `driftDrifting`→`OffTask`,
`driftIdle`/`driftPending`→`unclassified`).
- **End-to-end reflection block test** (`session_test.go`): with a fake clock and
a fake `ai.DriftJudge`, drive `RecordWindow` through a sequence where some
segments match the allowlist (on-task) and some are judged not-on-task
(off-task), then transition to Review and assert `buildReflectionFinishedLocked`
output contains the correct `On-task`/`Off-task`/`Unclassified` minute totals,
the `On-task:` and `Off-task:` headers, and the expected named off-task bucket
line.
- **Empty-list omission test:** a fully on-task session renders no `Off-task:`
block.
## Files touched
- `internal/session/stats.go` — `EvidenceStats` split fields; allocate them;
`creditLocked` helper; route `applyEvent` through it.
- `internal/session/session.go` — allocate split maps in
`StartManualCommitment`; route the end-of-session flush through `creditLocked`.
- `internal/session/roles.go` — `buildReflectionFinishedLocked` renders totals +
split top lists.
- `internal/session/session_test.go` / `stats_test.go` — tests above.
## Out of scope (YAGNI)
- Persisting the split to the focus log or the audit/history summary (no
cross-restart reconstruction; history charitable-ness is a separate concern).
- Per-bucket unclassified breakdown (only the aggregate is shown).
- Any change to the live evidence panel, the drift/nudge pipeline, or the
reviewer prompt contract beyond the finished-session block text.
- Surfacing the split in the web UI (this design targets the reviewer input
only).
@@ -1,272 +0,0 @@
# M6 — Knowledge Port Design
**Goal:** Add the `knowledge.Source` port — answering "who am I; what are my
priorities?" — with a single-config-file adapter (`~/.antidrift/knowledge.md`).
The profile text is loaded on entering planning and threaded into the AI
**coach** prompt as grounding, so "sharpen this intent" reflects who the user is
and what matters to them. A subtle planning-screen indicator shows whether the
profile loaded and from where, and lets the user point at a different file.
Read-only, graceful degradation.
**Status:** Design draft 2026-06-01. Implements the deferred `knowledge` port
named in `2026-05-31-go-focus-os-design.md`.
---
## 1. Direction
The Knowledge port is the fourth real port, after Activity (`evidence`),
Advisor (`ai`), and Tasks (`tasks`). It follows the same pattern: a small
leaf-package interface, a single adapter, and a fake for tests. It returns
primitives only, so it imports nothing from `domain` or `session`.
Its one job is to answer "who am I; what are my priorities?" — the standing
context that does not change session to session (role, current projects, what
counts as important, how the user likes to work). Where the Tasks port answers
*what should I be doing right now*, the Knowledge port answers *what kind of
person, with what priorities, is doing it*. That context exists to make the
advisor's judgment less generic.
**Coach-only grounding (this milestone).** The profile feeds exactly one
advisor role: the planning **coach**. Planning is the moment a vague intent is
turned into a concrete commitment, it happens at most a few times a day, and the
coach call already runs off the hot path — so grounding it is the highest-value,
lowest-cost place to start. The live drift judge and the ambient nudge are
deliberately left ungrounded in M6: they run on the hot path (debounced/cached
per window, every few minutes) and adding profile text to those prompts would
raise their token cost for marginal benefit. Extending grounding to those roles
is a clean follow-up once M6 proves the wiring (see Out of Scope).
The profile is **grounding, not instruction**: it informs the coach's proposal
but never forces a transition, exactly as the architecture's cortex layer
requires. Nothing is written back; the file is read-only.
## 2. The Port
New package `internal/knowledge`, a leaf package like `tasks` and `ai`:
```go
// 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)
}
```
The `path` parameter (rather than the adapter owning a single fixed path) keeps
the adapter stateless and lets the controller own the *selected* path — which
the UI can change at runtime — without a mutable field or a type assertion. An
empty `path` falls back to the adapter's configured default, and the resolved
location comes back in `Profile.Path` for the indicator to display.
Files under `internal/knowledge/`:
- `knowledge.go` — the `Source` interface and the `Profile` value type.
- `file.go` — the `FileSource` adapter (reads one file) and the small
truncation helper.
- `file_test.go` — adapter tests against temp files: present, absent, explicit
path override, oversize truncation, default-path resolution.
## 3. The File Adapter
`FileSource` reads one Markdown/plain-text file and returns its contents as the
profile. It is the knowledge analogue of the Marvin adapter, minus the
sub-process: a thin, testable wrapper around a single file read.
```go
type FileSource struct {
defaultPath string // used when Load is called with path == ""
}
func NewFileSource(defaultPath string) *FileSource
func (s *FileSource) Load(ctx context.Context, path string) (knowledge.Profile, error)
```
Behaviour:
- **Path resolution.** `path` if non-empty, else `s.defaultPath`, else the
built-in default `~/.antidrift/knowledge.md`. `~` is expanded. The resolved
absolute path is returned in `Profile.Path` regardless of outcome, so the
indicator can always show *where it looked*.
- **Missing file** (`os.IsNotExist`) → `Profile{Path: resolved}` with empty
`Text` and **no error**. This is the expected steady state for a user who has
not written a profile; it must not look like a failure.
- **Read error** (permissions, is-a-directory, I/O) → wrapped error, same
`fmt.Errorf("knowledge: ...: %w", err)` shape the other adapters use.
- **Size cap.** The text is capped at `maxProfileBytes = 6 KiB` to bound the
coach prompt's token cost, truncated on a UTF-8 rune boundary with a trailing
`\n…(truncated)` marker. A profile that long is already an outlier; the cap is
a guard, not a feature.
- **Whitespace-only** file → treated as empty `Text` (absent), so a file of
blank lines does not produce a meaningless grounding block.
**Configuration.** Mirrors `ANTIDRIFT_AI_BACKEND` / `ANTIDRIFT_MARVIN_CMD`. The
environment variable `ANTIDRIFT_KNOWLEDGE_FILE` sets the default path; unset or
empty falls back to `~/.antidrift/knowledge.md`. This is the **durable** way to
choose the file. The UI selector (§5) is a convenient **session-only** override
on top of it. A missing file or read error never fails startup — the daemon
logs one line and proceeds ungrounded.
## 4. Controller Wiring
The wiring mirrors the planning coach and the tasks fetch: an async load on
entering planning, generation-guarded against stale results, projected into
`State` only while planning. The one new seam is that the loaded text is also
**cached for the coach to consume**, since grounding flows into the coach call.
- `SetKnowledge(s knowledge.Source)` injects the source, like `SetTasks`. A nil
source turns the feature off (no indicator, ungrounded coach).
- New `Controller` fields, alongside the tasks fields:
`knowledge knowledge.Source`, `knowledgeStatus string`
(`idle`/`pending`/`ready`/`absent`/`error`), `knowledgeText string` (the
cached grounding the coach reads), `knowledgePath string` (the currently
selected path — `""` means the adapter default), `knowledgeChars int`, and
`knowledgeGen int` (the generation counter).
- `EnterPlanning()` calls `startKnowledgeFetchLocked()` right after
`startTasksFetchLocked()`: bump `knowledgeGen`, set `pending`, launch a
goroutine that calls `Load(ctx, c.knowledgePath)`, then on completion
re-acquire the lock and **discard if the generation is stale or the runtime
has left planning**. On success it sets `knowledgeStatus` to `ready` (or
`absent` when `Text == ""`), caches `knowledgeText`/`knowledgePath`/
`knowledgeChars`, and `notify()`s. Knowledge is **never** loaded on the
synchronous `State()` path.
- `State()` projects a `*KnowledgeView` **only while planning**, beside the
existing `CoachView` and `TasksView`. The view carries status, the resolved
path, and a character count — **not the profile text** (it stays server-side;
the browser never needs the body, and keeping it off the wire avoids leaking
personal context into the SSE payload and the broadcaster).
```go
// 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 are surfaced.
type KnowledgeView struct {
Status string `json:"status"` // idle|pending|ready|absent|error
Path string `json:"path,omitempty"` // resolved source path, for display + the selector
Chars int `json:"chars,omitempty"`
}
```
**Threading grounding into the coach.** `RequestCoach` captures
`grounding := c.knowledgeText` under the lock (next to `coach := c.coach`) and
passes it to the coach call. If the knowledge fetch is still in flight when the
user presses Sharpen, `knowledgeText` is simply empty for that one call and the
coach runs ungrounded — the same graceful-degradation contract as a missing
file. No awaiting, no blocking.
This requires a **signature change** to the `ai.Coach` interface — the one
non-additive change in M6:
```go
type Coach interface {
Coach(ctx context.Context, intent, grounding string) (Proposal, error)
}
```
`Service.Coach` passes `grounding` to `buildPrompt(intent, grounding)`, which
prepends an `## About the user` section **only when grounding is non-empty**, so
an ungrounded call produces a byte-for-byte unchanged prompt. The ripple is
small and mechanical: the impl in `ai/coach.go`, the call site in
`session.go`, and the coach tests/fakes that implement the interface. `DriftJudge`
and `Nudger` are untouched.
**Explicit file selection.** `SetKnowledgePath(path string)` stores the selected
path and, while planning, kicks off a fresh `startKnowledgeFetchLocked()` so the
indicator and cached grounding update immediately. It is session-only — not
persisted to the snapshot — so a restart returns to the
`ANTIDRIFT_KNOWLEDGE_FILE`/default. (Persisting the selection is a snapshot-schema
change deferred to a later milestone; the env var is the durable knob for now.)
No new runtime states, no new transitions, no change to the state machine, no
change to persisted snapshot shape.
## 5. Web / UI
The profile load rides in the existing SSE state payload during planning, beside
coach and tasks. M6 adds **one** small POST route — the file selector — which is
the single deliberate server write in this milestone.
- **Indicator.** The planning render gains a quiet line under the intent band
(near `coachStatus`): `ready``grounded by <basename(path)>`; `absent`
`no profile (~/.antidrift/knowledge.md)`; `error``profile unreadable`;
`pending``loading profile…`; nil source / `idle` → nothing. It is
ambient and non-blocking, matching the tasks "loading…" treatment — never a
button to fight.
- **Selector.** A small "change" affordance next to the indicator reveals an
input pre-filled with the resolved path; submitting POSTs
`/knowledge/path` with a `path` field, which calls `ctrl.SetKnowledgePath`
and re-loads. This is the *only* new endpoint. It mutates session-only config,
not commitment state, so it sits outside the state machine. Submitting an
empty path resets to the default. The control is intentionally minimal; if it
proves more than needed, it can ship behind the env var alone (the indicator
is the core; the selector is the "maybe").
- `main.go` gains a knowledge-adapter block parallel to the `tasks` block: read
`ANTIDRIFT_KNOWLEDGE_FILE`, construct `knowledge.NewFileSource(...)`, call
`ctrl.SetKnowledge(...)`, and log one line. Construction never fails; a bad
path only surfaces (as `absent`/`error`) at load time.
## 6. Testing
- **`knowledge` package:** adapter tests against temp files — a present file
(text + resolved path returned), an absent file (empty `Text`, no error,
path still reported), an explicit `path` override beating the default, an
oversize file truncated on a rune boundary with the marker, a whitespace-only
file treated as absent, and a permission/read error wrapped. `~` expansion
covered with a synthesized home. stdlib `testing` only; no process spawned.
- **`ai` package:** `buildPrompt` includes the `## About the user` block when
grounding is non-empty and is byte-identical to the pre-M6 prompt when
grounding is empty (a table test pins both). Existing coach/drift/nudge tests
updated for the new `Coach` signature (grounding `""`), staying green.
- **`session` package:** with a fake `Source`, assert `knowledgeStatus`
transitions (`pending``ready`, `pending``absent` on empty text,
`pending``error` on failure) and that `State().Knowledge` reflects them
while planning and is absent otherwise / with a nil source. Assert
`RequestCoach` passes the cached `knowledgeText` to a recording fake coach,
and passes `""` when the fetch has not completed. Assert the generation guard
discards a load that returns after leaving planning, and that
`SetKnowledgePath` re-fetches. A nil source yields no `KnowledgeView` and an
ungrounded coach.
- **`web` package:** existing tests stay green (markup-agnostic). Add one
assertion that planning-state JSON carries the knowledge object (status +
path, no text) when a source is set, and one that `POST /knowledge/path`
updates the selected path and triggers a re-load.
- `go vet ./... && go test -race ./...` stays clean; `knowledge` stays a leaf
package (imports only `context` + stdlib `os`/`io`/`path`/`strings`/`unicode`
— nothing from `domain`/`session`/`evidence`/`ai`/`web`).
## 7. Out of Scope
- **Grounding the drift judge and nudge.** Coach-only in M6. Extending profile
grounding to the hot-path roles is a deliberate follow-up, gated on whether
the token cost is worth it.
- **PKM-directory and CLI adapters.** M6 ships exactly one file adapter. The
`path`-parameter port shape leaves room for a directory or `am`-style CLI
adapter later without an interface change, but we do not build or abstract for
them now (YAGNI).
- **Persisting the selected path** across restarts (a snapshot-schema change).
The env var is the durable knob; the UI override is session-only.
- **Live file watching / auto-reload.** The profile is re-read on each entry to
planning (and on explicit reselection); no inotify, no polling.
- **Editing the profile from the UI**, structured profile fields (parsing the
Markdown into sections), or per-project knowledge. The file is an opaque
grounding blob.
- **Shipping the profile text to the browser.** Only presence, path, and size
cross the wire.
@@ -1,194 +0,0 @@
# M7 — Reflection: Design
**Status:** approved
**Date:** 2026-06-01
**Milestone:** M7 — Reflection (the deferred AI **reviewer** role, promoted into
the main loop)
## Purpose
Close the loop. Through M6 the system has three live AI roles — Coach,
DriftJudge, Nudge — but nothing looks *back*. M7 adds the fourth role, the
**Reviewer**: when a session ends, it reflects on what just happened, read
against your recent sessions, and produces two short lines —
- a **recap**, shown on the Review screen (how the session went), and
- a **carry-forward**, which grounds the coach the next time you plan (what to
do differently).
This is the "Focus OS Reflection" step from the roadmap. It makes the loop
self-reinforcing: each session's takeaway sharpens the next session's plan.
The whole feature is one cheap async call per session, never blocking, and
degrades gracefully — if the AI backend is off or slow, Review/End/Planning
behave exactly as they do today.
## Design constraints
Two non-negotiables shaped every decision below:
- **Efficient.** One LLM call per session, fired once on entering Review. The
"recent sessions" context is a local file read, not an LLM cost. The prompt
carries the finished session plus a few *compact* prior summaries (outcome +
top buckets, never raw event logs), so it stays small. The result is computed
once and persisted; the next planning cycle reads it from disk and does **not**
re-run the reviewer.
- **Low friction.** It runs automatically (no "request reflection" button). It is
**never blocking** — the **End** button works immediately whether or not the
reviewer has returned. The carry-forward is **auto-applied** as coach grounding
next time; there is no approve/dismiss step.
## The new AI role (`ai` package)
A leaf role that mirrors Coach/DriftJudge/Nudge: it takes only primitives and
imports neither `store` nor `session`. The controller is responsible for turning
session data into the strings this role consumes.
```go
// Reflection is the reviewer's output: two short, single-line fields.
type Reflection struct {
Recap string // backward-looking, ≤1 short line — shown on Review
CarryForward string // forward-looking, ≤1 short line — grounds the next
// coach and is shown on the next Planning screen
}
// Review reflects on a just-finished session, read against recent history.
// finished: a compact description of the session that just ended.
// history: a compact description of the last few prior sessions ("" if none).
func (b *Backend) Review(ctx context.Context, finished, history string) (Reflection, error)
```
- A new prompt builder composes a Reviewer prompt from `finished` and `history`,
instructing the model to return **at most one short line per field** so output
stays bounded.
- A parser extracts the two lines from the backend output, following the
existing role-parsing pattern in the `ai` package.
- **Graceful fallback:** any error, empty output, or unparseable result yields a
zero `Reflection{}` (both fields ""), which the rest of the system treats as
"no reflection available." `Review` never panics and never blocks.
The prompt is built so that an empty `history` (the first-ever session) still
produces a sensible recap from `finished` alone.
## Orchestration (`session.Controller`)
The controller owns all orchestration, reusing the established async +
generation-counter + graceful-degradation pattern already used for the coach,
tasks, and knowledge fetches.
### Fetch on entering Review
`enterReview` (reached from both `Complete``completed` and `Expire`
`expired`) fires `startReflectionFetchLocked()`:
1. Increment a `reflectionGen` counter and capture it for this fetch.
2. Build `finished` from the **in-memory** frozen stats of the session that just
ended: next action, success condition, outcome, switch count, and the top app
time buckets.
3. Build `history` by reading the **last 5 prior** `SessionSummary` records from
`audit.jsonl`. The just-finished session is **not** in the chain yet — it is
appended only at `End` — so there is no double-counting.
4. In a goroutine, call `reviewer.Review(ctx, finished, history)` under a
timeout. On return, re-acquire the lock; if `reflectionGen` still matches the
captured value, cache `reflectionRecap` and set `carryForward`
(**latest-wins**); otherwise discard the result as stale. Then notify.
The generation guard ensures a slow review from a superseded session can never
overwrite a newer one: the most recent *completed* review wins.
### Grounding the next coach — no interface change
`grounding` is already a free-form string parameter on `ai.Coach` (added in M6).
M7 needs **no** change to the `ai.Coach` signature: in `RequestCoach` the
controller composes the existing knowledge profile text **and** the current
`carryForward` into that one `grounding` string (profile block, then a short
"Last session:" line). M7 is fully additive to the AI interface.
`carryForward` is latest-wins and survives `End` (it is not cleared with the
commitment/stats), so it is present when the next Planning begins.
### State projection
The State view gains a small reflection projection so the browser can render it:
```go
type ReflectionView struct {
Status string // "idle" | "pending" | "ready" | "absent"
Recap string // shown on Review
CarryForward string // shown on Planning
}
```
- On **Review**, the view carries `Status` + `Recap`.
- On **Planning**, the view carries the `CarryForward` line.
Unlike the M6 *profile* (large and private, deliberately kept off the wire), the
reflection lines are short and **exist to be displayed**, so they are
intentionally included in the State payload sent to the browser.
## Persistence
Snapshot-only, latest-wins. The persisted snapshot JSON gains `reflectionRecap`,
`carryForward`, and a small `reflectionStatus` enum (idle/pending/ready/absent).
There are **no** changes to `audit.jsonl`, no new files, and no new on-disk
format. The permanent, hash-chained `SessionSummary` is untouched.
One small additive reader is needed on the store:
```go
// RecentSessions returns up to n most-recent summaries from the audit chain,
// most-recent first (or oldest-first — fixed by the plan), [] if the log is
// absent or empty.
func RecentSessions(path string, n int) ([]SessionSummary, error)
```
Today's `readSummaries` is unexported; `RecentSessions` exposes a bounded slice
of it for the controller to format into `history`.
## UI (`web` static assets)
- **Review screen:** the `Recap` rendered as a subtle line (nudge-band style),
with `pending` and `absent` states. The **End** button works immediately
regardless of reflection status.
- **Planning screen:** the `CarryForward` rendered as a quiet one-liner
("Last time: …"), mirroring the M6 knowledge indicator. No buttons, no added
clicks anywhere.
## Daemon wiring (`cmd/antidriftd/main.go`)
The reviewer backend is wired into the controller (`ctrl.SetReviewer(...)`),
gated on AI-backend availability exactly like the other roles. With no backend
configured, the reviewer is simply absent and reflection silently does nothing.
## Error handling / graceful degradation
- Backend off, error, empty output, unparseable result, or no prior history →
nothing is shown; Review, End, and Planning behave exactly as today.
- The reviewer **never blocks a transition**. `End` does not wait for it.
- The generation counter discards late results from a superseded review.
## Testing
- **`ai`:** the Reviewer prompt includes both `finished` and `history`; the
parser extracts two lines; error/blank/unparseable input yields an empty
`Reflection`.
- **`session`:** reflection is fetched on `enterReview`; the result is cached and
rides the snapshot; a stale (superseded-generation) result is discarded; the
`carryForward` composes into the next coach's `grounding`; everything degrades
gracefully when no reviewer is set; `RecentSessions` returns the last *n*
summaries in the expected order.
- **`web`:** the Review payload carries the `Recap`; the Planning payload carries
the `CarryForward`; reflection text is intentionally present on the wire.
## Out of scope (this milestone)
- **A durable reflection history** (`reflections.jsonl`). Nothing in the loop
needs it — the next coach only needs the latest carry-forward — and the
permanent `SessionSummary` still records outcome/buckets/switches for every
session. Promoting to a durable log later would be an additive change.
- **Changing the `ai.Coach` signature.** Grounding is already a free-form string.
- **Refactoring `session.go`.** It is ~1054 lines and M7 adds another per-role
async-fetch block; the repetition across the coach/tasks/knowledge/reviewer
fetchers is a fair future consolidation target, but extracting it now would
destabilize four working roles for no functional gain. M7 follows the
established per-role pattern for consistency and reviewability.
@@ -1,262 +0,0 @@
# M8 (Tier A) — Window-minimize Enforcement: Design
**Status:** approved
**Date:** 2026-06-01
**Milestone:** M8 — Enforcement & gate, **Tier A**: the unprivileged
`enforce.Guard` port and its window-minimize adapter
## Purpose
Make drift finally *cost something*. Through M7 the system tracks, advises, and
reflects, but drift is purely advisory: `domain.EnforcementLevel`
(observe/warn/block/locked) is defined but **never acted on**, and the drift
judge's verdict only changes what the browser shows. M8 turns "track and advise"
into "you don't drift in the first place."
Tier A is the first, gentlest, **unprivileged** slice: when the drift judge
confirms the active window is off-task **and** the session opted into
enforcement, a new **Guard** minimizes that window, pushing the user back toward
an allowed context. It activates the dormant `EnforcementLevel` and establishes
the `enforce.Guard` port that the later, privileged tiers reuse.
It runs entirely in the user's X11 session (no root), follows the port pattern
M1 established, degrades gracefully (no X11 / no Guard / Wayland → exactly
today's behavior), and never blocks a state transition.
## Scope
M8 spans three privilege tiers, each its own spec → plan → build cycle:
- **Tier A (this spec):** window-minimize. Unprivileged X11 adapter. Low risk.
- **Tier B (later):** network blocking via nftables/DNS. Needs root.
- **Tier C (later):** the privileged entry gate — guardian process, root-owned
IPC, break-glass, gating machine usability on a declared intention. The
heaviest step, deliberately last (the original Stage 2 threat boundary).
This spec covers **only Tier A**. B and C are out of scope here.
## Architecture shift from the legacy enforcement
The legacy Rust app was a TUI: `minimize_other(APP_TITLE)` kept *its own window*
foregrounded by minimizing everything else, and explicitly skipped the window
whose title matched `APP_TITLE`. The Go reimagining is a daemon + browser UI with
no single app window to force forward. So Tier A inverts the legacy meaning:
rather than minimize-everything-but-us, it **minimizes the active window when
that window is the confirmed-drifting one**. The drift pipeline already judges
the active window; enforcement simply acts on that judgment.
## The new port — `enforce.Guard`
A leaf port mirroring `evidence.Source`: the Guard is a dumb OS primitive that
performs an action when told to. **All policy — whether and when to enforce —
lives in the controller.** The Guard imports neither `session` nor `domain`.
```go
package enforce
import "context"
// Guard makes drift cost something at the OS level. Tier A: minimize the
// active window.
type Guard interface {
// MinimizeActive minimizes the currently-focused window. It is idempotent
// (minimizing an already-minimized window is a no-op) and best-effort: it
// returns an error for diagnostics, but the caller never blocks on it and
// treats failure as "enforcement did nothing this time."
MinimizeActive(ctx context.Context) error
}
```
Adapters, mirroring the `evidence` package's split:
- **`internal/enforce/x11.go`** (`//go:build linux`): resolves the active window
with `ewmh.ActiveWindowGet` and iconifies it via `jezek/xgbutil`
(`xwindow.Window.Iconify`, which sends the ICCCM `WM_CHANGE_STATE`
`IconicState` client message). Same dependency already in `go.mod` and used by
the evidence adapter. **No `xdotool` shell-out.** A fresh `xgbutil.NewConn()`
failure (no display, Wayland) yields a Guard whose `MinimizeActive` returns an
error every call — the controller logs and continues.
- **`internal/enforce/guard_other.go`** (`//go:build !linux`): a no-op Guard
whose `MinimizeActive` returns nil, exactly like `evidence/source_other.go`.
A package-level constructor `NewGuard() Guard` is selected by build tag, matching
`evidence.NewSource()`.
**Rejected alternative:** a policy-aware `Enforce(level, drifting bool, snap)`
Guard that decides internally whether to act. That pushes branching logic into
the platform-specific, hard-to-unit-test adapter and breaks the leaf pattern
`ai` and `evidence` establish. Keeping the Guard a pure primitive keeps all the
testable decision logic in the controller, where a fake Guard makes it trivial
to assert.
## Activation — the dormant level, switched on
`EnforcementLevel` already exists in `domain` but is set nowhere. Tier A plumbs
it through:
- **`StartManualCommitment` gains an `EnforcementLevel` parameter.** The web
handler reads it from the planning form. (The existing
`domain.NewManual`/`PolicySnapshot` already carry the field; this wires the
caller.)
- **Planning UI:** an **"Enforce focus"** toggle. On → `block`; off → `warn`
(today's advisory behavior). `observe` and `locked` are **not** surfaced in
Tier A — `locked` is the Tier C entry gate, and `observe` adds nothing over
`warn` for this milestone.
- **Effective levels in Tier A:** only `warn` (advisory, no minimize — current
behavior) and `block` (minimize on confirmed drift). The Guard acts **iff**
the level is `block`.
The chosen level **rides the snapshot** (latest-wins persistence) so it survives
a mid-session daemon restart, exactly like the commitment itself. Runtime drift
state remains unpersisted and recomputed after restart, unchanged from M3.
## Trigger plumbing (`session.Controller`)
Drift settles as confirmed (`driftStatus = drifting`, via `applyVerdictLocked`)
in two existing places:
1. **Synchronously** in `evaluateDriftLocked`, on a per-class cache hit.
2. **Asynchronously** inside the drift-judge closure, after the LLM returns.
A single helper composes the enforcement action so both paths stay DRY:
```go
// enforceActionLocked returns the minimize thunk when this observation should
// be enforced, else nil. Caller holds mu. The returned func performs blocking
// X11 I/O and MUST run after the lock is released.
func (c *Controller) 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("session: enforce minimize failed: %v", err)
}
}
}
```
- **`RecordWindow`** already runs the optional judge `launch()` in a goroutine
after unlocking. It additionally captures `enforceActionLocked()` under the
lock and runs it in a goroutine after unlock (covering the synchronous
cached-drift path).
- **The judge closure**, after `applyVerdictLocked`, likewise captures and runs
the enforcement thunk after it releases `c.mu` (covering the async path).
Because the action fires on **every** confirmed-drift observation while at
`block`, re-raising the window while still off-task minimizes it again — the
"repeated while drifting" behavior. `MinimizeActive` is idempotent, so a
redundant call on an already-minimized window is harmless.
No extra runtime state is stored for the UI: the drift projection **derives**
the `Enforced` flag from the level and drift status (see State projection), so it
is true exactly in the conditions under which the minimize thunk fires.
### Why off-lock, and the small race we accept
`MinimizeActive` is an X11 round-trip; running it under `c.mu` would block all
controller state for the duration. It runs after unlock, following the M2
`RequestCoach` discipline already used by the coach, tasks, knowledge, reviewer,
and drift-judge fetches. Between observing drift and the minimize landing, the
user could Alt-Tab to an allowed window, which would then be the one minimized.
This window is sub-millisecond-to-millisecond; the legacy code had the same
property; we accept it.
## State projection
The existing `DriftView` (active-only) gains one field so the browser can
explain enforcement:
```go
type DriftView struct {
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Nudge string `json:"nudge,omitempty"`
Enforced bool `json:"enforced,omitempty"` // a minimize fired this drift episode
}
```
`Enforced` is derived as `level == block && driftStatus == drifting` — no stored
field. It is runtime-only (not persisted), consistent with the rest of the drift
projection.
## Persistence
Snapshot-only, latest-wins. The persisted snapshot JSON gains the chosen
`EnforcementLevel` (so a restart mid-session keeps enforcing). There are **no**
changes to `audit.jsonl`, no new files, and no new on-disk format. The
hash-chained `SessionSummary` is untouched. (Recording per-session enforcement
counts in the permanent summary is a possible future addition, out of scope
here.)
## UI (`web` static assets)
- **Planning screen:** an **"Enforce focus"** toggle (checkbox), mirroring the
quiet style of the M6/M7 indicators. Checked → the commit posts `block`;
unchecked → `warn`. A one-line hint explains it ("Minimize off-task windows
when you drift.").
- **Active screen:** the existing M3 drift band gains a short line —
**"Off-task window minimized."** — rendered when `drift.enforced` is true.
Reuses the band; no new component. The **End**/**Refocus** buttons are
unaffected and always work.
## Daemon wiring (`cmd/antidriftd/main.go`)
Construct the Guard with `enforce.NewGuard()` and inject it with
`ctrl.SetGuard(g)`, alongside the other adapters. On a platform without the X11
adapter (or with no display), the no-op / erroring Guard means enforcement
silently does nothing. The startup log line notes enforcement availability.
## Error handling / graceful degradation
- No Guard wired, no X11 / Wayland, or `MinimizeActive` error → nothing is
enforced; Active, drift, Refocus, and End behave exactly as today. Errors are
logged, never surfaced to the user.
- The Guard **never blocks a transition**. Minimize runs off-lock in a
goroutine under a short timeout.
- At `warn` (toggle off) the Guard is never called — identical to today's
advisory behavior.
## Known limitation (accepted, by design)
Unlike the legacy TUI — which protected its own window by a known title — the Go
dashboard lives in a **browser tab with no distinct window**. If the user is
*actively viewing the dashboard* in a browser that is not in their allowed
classes, that browser is the active window and may be minimized when drift is
confirmed. Mitigations: the user adds their browser to allowed classes, and the
SSE-backed state is current the moment the dashboard is reopened. We document
this rather than build unreliable title-based self-protection; a robust solution
belongs to a later tier if it proves necessary.
## Testing
- **`enforce`:** the no-op adapter's `MinimizeActive` returns nil. (The X11
adapter is integration-tested behind a build tag / display guard like
`evidence/x11_integration_test.go`, not in unit tests.)
- **`session`:** with a `fakeGuard` recording `MinimizeActive` calls —
- minimize fires on confirmed drift at `block`, via **both** the per-class
cached path and the async judge path;
- minimize does **not** fire at `warn`, with no Guard wired, or while on-task;
- the `Enforced` flag appears in the projection precisely while drifting at
`block`;
- the chosen `EnforcementLevel` survives a snapshot round-trip (restart).
- **`web`:** the planning form's enforce toggle posts `block`; the Review/Active
payload carries `drift.enforced`; the band note renders.
## Out of scope (this tier)
- **Tier B (nftables/DNS) and Tier C (entry gate).** Separate specs.
- **`observe`/`locked` levels in the planning UI.** `locked` is the Tier C gate;
`observe` is redundant with `warn` here.
- **Minimizing all non-allowed windows** (screen-clearing). Tier A acts on the
active drifting window only, matching the existing per-active-window drift
model. Whole-screen enforcement could return later.
- **Per-session enforcement counts in the permanent `SessionSummary`.** Additive
later if wanted.
- **Title-based self-protection of the dashboard** (see Known limitation).
- **Refactoring `session.go`.** Tier A adds one small per-observation hook
following the established pattern; the broader async-fetch consolidation
remains a future target.
@@ -1,201 +0,0 @@
# M9 — Tame `session.go`: Design
**Status:** approved
**Date:** 2026-06-01
**Milestone:** M9 — Maintainability: split the monolithic `session.go` and
consolidate the duplicated async-fetch boilerplate, with zero behavior change
## Purpose
The M0M8 feature arc left `session.Controller` carrying five responsibilities
in a single 1278-line file — by far the largest in the codebase (the next is
`web.go` at 243). Every milestone's design doc has flagged two specific debts:
the file is too big to hold in context at once, and the per-role async-fetch
block (capture generation → goroutine → re-lock → latest-wins) is copy-pasted
across coach, tasks, knowledge, and reflection.
M9 pays both down so the controller is easy to extend before any new feature
lands. It is a **pure maintainability milestone**: no new behavior, no API
change, no exported-symbol rename. Success is the existing test suite passing
**green-to-green under `-race`**, before and after.
## Scope
Two changes, both confined to `package session`:
1. **File split** — move declarations (no logic edits) out of the monolith into
focused files, each with one clear responsibility.
2. **Async-fetch consolidation** — extract the mechanical goroutine dance shared
by the four async fetches into one helper, while every real per-role
difference stays explicit at the call site.
Everything else — drift/stats/web/daemon logic, the deferred M8 Tiers B/C —
is untouched.
## The async-fetch helper
Today four methods (`RequestCoach`, `startTasksFetchLocked`,
`startKnowledgeFetchLocked`, `startReflectionFetchLocked`) repeat the same
goroutine skeleton: open a timeout context, perform the I/O with no lock held,
re-acquire `c.mu`, discard the result if a generation guard says it is stale,
otherwise record it and `notify`. The role-specific parts around that skeleton
genuinely differ and **must stay per-role**:
- the generation field (`coachGen` / `tasksGen` / `knowledgeGen` /
`reflectionGen`) and status enum;
- the stale guard — coach/tasks/knowledge check *gen mismatch **or** left
Planning*; reflection checks *gen only* (its carry-forward must survive `End`
before the reviewer returns);
- the apply logic — tasks/coach are two-branch; knowledge is three-branch and
writes `knowledgePath` back; reflection is two-branch and calls
`persistLocked`;
- pre-goroutine work — reflection reads `history` synchronously under the lock,
a happens-before requirement against `End`'s audit-chain append, which must be
preserved;
- `RequestCoach` manages its own lock and `notify`s the pending state before
launching; the `*Locked` variants are launched mid-transition from
`EnterPlanning` / `enterReview` while the caller still holds `c.mu`.
The helper therefore extracts **only** the mechanical dance and takes three
closures plus the timeout:
```go
// runFetchAsync launches a generation-guarded background fetch. The caller has
// captured its dependencies and (for the *Locked callers) holds c.mu; this
// method only spawns the goroutine. fetch performs the I/O with no lock held;
// stale reports whether to discard the result; apply records it under the
// re-acquired lock (and persists itself when the role requires it).
func (c *Controller) runFetchAsync(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)
c.mu.Lock()
if stale() {
c.mu.Unlock()
return
}
apply()
c.mu.Unlock()
c.notify()
}()
}
```
Each role keeps its own setup (clear cache, nil-provider short-circuit, `gen++`,
pending status, dep capture) and passes `fetch` / `stale` / `apply` closures
over its locals. Example (tasks):
```go
func (c *Controller) startTasksFetchLocked() {
c.tasksList = nil
if c.tasksProvider == nil {
c.tasksStatus = tasksIdle
return
}
c.tasksGen++
gen := c.tasksGen
c.tasksStatus = tasksPending
p := c.tasksProvider
var list []tasks.Task
var err error
c.runFetchAsync(tasksTimeout,
func(ctx context.Context) { list, err = p.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
}
})
}
```
`runFetchAsync` does not require the lock to be held (it only spawns the
goroutine, which re-acquires `c.mu` itself), so it is safe to call both from a
`*Locked` caller still inside a transition and from `RequestCoach` after it has
unlocked and notified.
**Rejected alternatives:**
- *Generic free function* `asyncFetch[T](c, timeout, fetch (ctx)(T,error),
stale, apply func(T,error))`. More type-safe — the result flows as a typed
value rather than a captured closure var — but Go methods cannot be generic,
so it must be a package-level function, and the per-role branches still live
in `apply`. The closure-method form is the smaller, lock-idiomatic diff.
- *Struct-per-role value* encapsulating `gen` + status + timeout. The most
structure but the most churn; four small roles do not justify the machinery
(YAGNI).
- *Unifying the four `gen` int fields* into one shared counter type. Pure churn
for no payoff; out of scope.
## File decomposition
All files remain `package session`. **No exported symbol moves out of the
package, is renamed, or changes signature** — only the file a declaration lives
in changes.
| File | Responsibility | Declarations |
| ---- | -------------- | ------------ |
| `session.go` | core controller + lifecycle | `Controller` struct, `New`, `SetClock`/`SetOnChange`/`notify`, `State`/`Deadline`, `persistLocked`, the lifecycle transitions (`EnterPlanning`, `StartManualCommitment`, `Complete`/`Expire`/`enterReview`, `End`, `buildSummaryLocked`), `ErrNotPlanning`/`ErrNotActive` |
| `views.go` | UI projection (pure data shaping) | the 11 `*View` types, the `State` type, `stateLocked`, `bucketViews` |
| `roles.go` | AI roles + the async-fetch helper | `runFetchAsync`; coach (`SetCoach`, `resetCoachLocked`, `composedGroundingLocked`, `RequestCoach`, `coachErrorMessage`); tasks (`SetTasks`, `startTasksFetchLocked`); knowledge (`SetKnowledge`, `SetKnowledgePath`, `startKnowledgeFetchLocked`); reflection (`SetReviewer`, `startReflectionFetchLocked`, `buildReflectionFinishedLocked`, `buildReflectionHistory`); the coach/tasks/knowledge/reflection timeout + status consts |
| `drift.go` | Active-state drift/nudge/enforcement | `RecordWindow`, `evaluateDriftLocked`, `maybeNudgeLocked`, `enforceActionLocked`, `applyVerdictLocked`, `resetDriftLocked`, `OnTask`, `Refocus`, `recordTitleLocked`, `commitmentLineLocked`, `Set{DriftJudge,Guard,Nudge}`, the drift/nudge/enforce consts |
| `stats.go` | per-session evidence accounting | `EvidenceStats`, `bucketKey`, `applyEvent`, `replayStats`, `keyFor`, `focusEvent`, `snapFromEvent` |
The `*ForTest` accessors (`AllowedClassesForTest`, `EnforcementLevelForTest`,
`recentTitlesForTest`) stay in regular `.go` files (not `_test.go`) grouped with
the cluster they expose, because `internal/web/web_test.go` reaches some of them
across the package boundary; a `_test.go` placement would be invisible to that
package and break the build. The plan confirms each accessor's call sites before
choosing its file.
Splitting into *sub-packages* is explicitly rejected: every method mutates one
`Controller` behind one `sync.Mutex`, so sub-packages would force that private
state to be exported. One package across several files is the idiomatic Go shape
and keeps the locking invariant intact.
## Sequencing & safety
The discipline for a refactor of the controller is behavior preservation proven
by the current tests:
- **Phase 1 — file split (zero logic change).** Move declarations into the new
files. `go build ./...` + `go vet ./...` + `go test -race ./...` green. Lowest
risk, done first so the structure exists before any logic moves. One small
commit per file extracted.
- **Phase 2 — consolidation.** Add `runFetchAsync`; migrate the four roles to it
**one at a time**, each its own commit, the full `-race` suite green between
each migration. Performing this after the split means each migration is a
clean diff inside `roles.go` rather than inside the old monolith.
At no point is the build or the suite left red. The split being first means a
mistake there is caught before any semantically-meaningful change is layered on.
## Testing
No new behavior means no new behavioral tests are *required*; the contract is
green-to-green under `-race`. The plan first **audits** that the existing suite
covers each async role's:
- stale-generation discard,
- the not-Planning completion gate (coach/tasks/knowledge),
- reflection's gen-only guard plus its `persistLocked` on completion,
- knowledge's three-branch apply and `knowledgePath` write-back.
A characterization test is added **only where the audit finds a real gap** — so
the consolidation cannot silently change a path the suite never exercised.
Otherwise the existing `session_test.go` and `web_test.go` are the safety net,
run after every commit.
## Out of scope
- Any behavior change, API/signature change, or exported-symbol rename.
- Unifying the four `gen` int fields into a shared type.
- Touching drift/stats/web/daemon **logic** (only moving declarations).
- Splitting `session` into sub-packages.
- M8 Tiers B (network blocking) and C (privileged entry gate) — separate
milestones.
@@ -1,203 +0,0 @@
# Settings file + settings page — design
**Date:** 2026-06-01
**Status:** Approved (brainstorming), pending implementation plan
## Problem
The three configurable inputs — AI backend, Marvin tasks command, and knowledge
profile path — are read once from `ANTIDRIFT_*` environment variables at daemon
startup (`cmd/antidriftd/main.go`). Changing any of them means editing a launch
command and restarting. The user wants to edit them at runtime from the web UI,
backed by a persisted settings file, and to pick the knowledge profile with a
proper file picker rather than typing an absolute path.
A partial precedent already exists: `POST /knowledge/path``SetKnowledgePath`
re-points the profile live while planning, but the choice is session-only and
not persisted.
## Decisions (locked during brainstorming)
1. **Live apply** — saving a setting re-wires the running daemon immediately; no
restart. (Knowledge path already works this way.)
2. **Server-side browse endpoint** for the file picker — a browser `<input
type=file>` yields sandboxed file *contents*, not a server-side *path* the
daemon can re-read, so we expose a directory-listing endpoint and render our
own navigable picker.
3. **Env seeds first run, then file wins** — on first start with no settings
file, seed it from current env vars (or built-in defaults); thereafter the
settings file is the sole source of truth and env vars are ignored.
4. **Scope: just the three settings** — AI backend, Marvin command, knowledge
path. No port / default timebox / default enforcement in this version.
5. **Gear toggle on the main card** — a header gear icon toggles a settings
overlay in the existing single-page, SSE-driven UI; no new route.
## Architecture
### Key layering move: inject an "applier" closure
`web` must NOT import `ai` / `tasks` / `knowledge`. Re-wiring adapters is a
composition concern, not session state, so it does not belong on the controller
either. Instead `main.go` (the composition root) builds:
```go
applyFn := func(s settings.Settings) error { /* construct adapters, call ctrl setters */ }
```
and injects it into the server, exactly as `SetCoach` / `SetTasks` already inject
behavior. The HTTP handler shrinks to: validate → save file → call applier →
broadcast. `web` depends only on the `settings.Settings` type and the injected
closure.
Dependency direction: `web → settings` (type only) and `web → applyFn` (injected).
`main → {settings, ai, tasks, knowledge, session, web}`. No cycles.
### New package: `internal/settings`
A leaf type package, importing nothing else in the app.
```go
type Settings struct {
AIBackend string `json:"ai_backend"`
MarvinCmd string `json:"marvin_cmd"`
KnowledgePath string `json:"knowledge_path"`
}
func DefaultPath() (string, error) // ~/.antidrift/settings.json
func Load(path string) (Settings, error)
func Save(path string, s Settings) error // atomic temp+rename, mirrors store
func SeedFromEnv() Settings // reads ANTIDRIFT_AI_BACKEND/_MARVIN_CMD/_KNOWLEDGE_FILE
```
Settings file (`~/.antidrift/settings.json`, next to `state.json`):
```json
{
"ai_backend": "claude",
"marvin_cmd": "uv run --project /home/felixm/dev/ampy am --json --config /home/felixm/dev/ampy/config.json",
"knowledge_path": "/home/felixm/.antidrift/knowledge.md"
}
```
### Startup wiring (`main.go`)
Replace the three inline `os.Getenv(...)` wirings with:
1. Resolve `settings.DefaultPath()`.
2. If the file is absent: `s := settings.SeedFromEnv()` (filling built-in
defaults where env is unset), then `settings.Save(path, s)`.
If present: `s, _ := settings.Load(path)` and env is ignored.
3. `applyFn(s)` performs the wiring main does today:
- `b, err := ai.NewBackend(s.AIBackend)`; on error log + skip AI (as today);
else `svc := ai.NewService(b)` and call `SetCoach` / `SetDriftJudge` /
`SetNudge` / `SetReviewer`.
- `ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd))`.
- Knowledge unified: base source `ctrl.SetKnowledge(knowledge.NewFileSource(""))`
once, then `ctrl.SetKnowledgePath(s.KnowledgePath)` to drive the actual
path. Startup and live-edit then take the identical code path.
4. Hand `applyFn`, the loaded `s`, and the settings file path to the server.
### HTTP endpoints (`web`)
Added in a new `internal/web/settings_handlers.go` to keep `web.go` focused. The
server gains mutex-guarded fields: current `settings.Settings`, the settings file
path, and the injected `applyFn`.
- `GET /settings` → current settings as JSON.
- `POST /settings` → body is the full settings object. The handler calls
`applyFn(s)` to validate-and-apply atomically: `applyFn` checks the backend
first and returns `settings.ErrInvalidBackend` BEFORE mutating any controller
state on a bad value. On that error → 400, nothing saved, nothing applied,
prior wiring intact. On success the handler then persists: `settings.Save` →
update in-memory copy → broadcast → 200 with the saved settings. Marvin
command and knowledge path are free-form and always accepted (bad values
degrade gracefully exactly as today).
- `GET /fs/browse?dir=<abspath>` → `{ "dir": "...", "parent": "...",
"entries": [ {"name","path","is_dir"} ] }`. Lists subdirectories plus `.md`
files. If `dir` is empty/missing, open at the directory of the current
knowledge path, else `$HOME`. Rejects a nonexistent/unreadable dir with 400.
No filesystem jail beyond OS permissions — localhost-only, single-user daemon,
same trust boundary as the rest of the UI.
**Validation placement (decided):** the backend-name check lives in the injected
`applyFn`, which returns a typed `settings.ErrInvalidBackend` (or similar) BEFORE
mutating any controller state. The handler maps that error to 400 and skips the
save. This keeps `web` free of an `ai` import; `applyFn` (built in `main`) owns
all adapter knowledge including what counts as a valid backend.
### Redundant endpoint removed
`POST /knowledge/path` becomes redundant (knowledge path now flows through
`/settings`). Fold it in and delete the route + `handleKnowledgePath`. The
existing `app.js` "change profile" affordance is replaced by the settings
overlay's knowledge field.
### UI (`app.js` / `app.css`)
- A gear button in the card header toggles a settings overlay over the current
card. No routing; reachable from any runtime state.
- On open, `GET /settings` populates three controls:
- AI backend: `<select>` with claude / codex.
- Marvin command: text field.
- Knowledge path: text field + **Browse…** button.
- **Browse…** opens a lightweight modal driven by `GET /fs/browse`: click a
folder to descend, `..` to go up, click a `.md` file to select it back into
the path field.
- **Save** posts the full object to `/settings`. A 400 renders the error inline
and keeps the overlay open. On success the overlay closes; the SSE stream
delivers refreshed state.
## Data flow (save)
```
gear → overlay → edit fields → Save
→ POST /settings {ai_backend, marvin_cmd, knowledge_path}
→ handler: applyFn(s) validates backend first, mutates only on full success
invalid → 400 (no apply, no save)
valid → applyFn applied (SetCoach/…, SetTasks, SetKnowledgePath)
→ settings.Save(file) → server.current = s → broadcast → 200
→ overlay closes, SSE pushes new state
```
## Error handling
- Invalid AI backend: 400, nothing persisted or applied; prior wiring intact.
- Unreadable/missing settings file at startup: treat as first run (seed + save);
if the save itself fails, log and continue with the in-memory seed (daemon
still starts, matching the existing degrade-not-fail posture).
- Bad Marvin command / missing knowledge file: accepted and saved; degrade at
fetch/load time exactly as today (no tasks panel / ungrounded coach).
- `/fs/browse` on a bad dir: 400 with a short reason; the modal shows it and
stays on the last good directory.
## Testing
- `internal/settings`: Load/Save round-trip; `DefaultPath`; `SeedFromEnv`
reads the three env vars; first-run path (file absent → seed written).
- `internal/web`:
- `GET /fs/browse` lists entries, filters to dirs + `.md`, exposes correct
`parent`, and returns 400 for a nonexistent dir.
- `POST /settings` with an invalid backend → 400, applier NOT called, file
unchanged (assert via a fake applier returning `ErrInvalidBackend`).
- `POST /settings` with valid input → applier called once with the parsed
settings and the file written (fake applier records its argument).
- The real `applyFn` stays thin in `main` (composition root); its constituent
constructors (`ai.NewBackend`, `tasks.NewMarvin`, `knowledge.NewFileSource`,
`SetKnowledgePath`) are already covered by their own packages.
## Files touched
- `internal/settings/settings.go` (new) + `settings_test.go`
- `cmd/antidriftd/main.go` (wiring + applyFn)
- `internal/web/settings_handlers.go` (new) + tests
- `internal/web/web.go` (register routes, server fields, drop `/knowledge/path`)
- `internal/web/static/app.js`, `internal/web/static/app.css` (gear, overlay,
browse modal)
## Out of scope (YAGNI)
- Port, default timebox, default enforcement level as settings.
- Multiple knowledge profiles / profile management.
- Any auth on the browse endpoint beyond the existing localhost binding.
</content>
</invoke>