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:
@@ -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 | M2–M3 |
|
||||
| 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. M0–M4 build the core plus the first two ports (activity, advisor) and the
|
||||
UI; M5–M8 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, **M0–M4** (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 M5–M8, 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 (M2–M3).
|
||||
- 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 M0–M8 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>
|
||||
Reference in New Issue
Block a user