diff --git a/docs/superpowers/plans/2026-05-25-commitment-kernel-stage1.md b/docs/superpowers/plans/2026-05-25-commitment-kernel-stage1.md deleted file mode 100644 index c61c1d7..0000000 --- a/docs/superpowers/plans/2026-05-25-commitment-kernel-stage1.md +++ /dev/null @@ -1,1933 +0,0 @@ -# Commitment Kernel Stage 1 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build Stage 1a-1c of the Commitment OS: commitment schema, state machines, local event log, current TUI integration, evidence degradation, transition prompts, and Tier 0 violation friction. - -**Architecture:** Extract the commitment kernel into focused Rust modules under `src/`, keep the current Ratatui app as the first UI client, and preserve a clean boundary for the future privileged guardian by introducing `PolicySnapshot` now. Stage 1 does not perform privileged enforcement; it observes, records, prompts, minimizes where the current app already can, and makes degraded evidence visible. - -**Tech Stack:** Rust 2021, Ratatui, Crossterm, Serde/Serde JSON, std filesystem APIs, existing X11 `xdotool` adapter, unit tests through `cargo test`. - ---- - -## Scope - -This plan implements Stage 1a, Stage 1b, and Stage 1c from `docs/superpowers/specs/2026-05-25-commitment-os-design.md`. - -It does not implement systemd services, guardian IPC, nftables/DNS blocking, root-owned config, delayed admin, ActivityWatch API integration, Wayland adapters, command/process enforcement, planner storage, or camera/presence sensing. - -## File Structure - -- Create `src/lib.rs`: library entry point for testable modules. -- Create `src/domain.rs`: commitment, policy, runtime state, commitment state, transition reason, evidence health, and validation types. -- Create `src/state_machine.rs`: legal runtime and commitment transition functions with guard errors. -- Create `src/context.rs`: allowed-context matching for domains, windows, repos, and commands. -- Create `src/event_log.rs`: append-only JSONL event log with hash chaining. -- Create `src/session.rs`: session controller that creates commitments, policies, events, and review records independent of Ratatui. -- Modify `src/window/mod.rs`: expose a `WindowSnapshot` type and `get_snapshot()`. -- Modify `src/window/linux.rs`: return title, class, and evidence health; keep existing minimize behavior. -- Modify `src/window/windows.rs`: return title and degraded class information on Windows. -- Modify `src/constants.rs`: add paths and UI labels for commitment mode, transitions, violations, and degraded evidence. -- Modify `src/main.rs`: use the session controller and show Stage 1 commitment/transition/violation states in the existing TUI. -- Modify `Cargo.toml`: add `sha2` and `hex` for event hashing. -- Tests live beside modules with `#[cfg(test)]` so no separate test harness is required. - -## Task 1: Add Library Boundary And Dependencies - -**Files:** -- Modify: `Cargo.toml` -- Create: `src/lib.rs` -- Modify: `src/main.rs` - -- [ ] **Step 1: Add hashing dependencies** - -Edit `Cargo.toml`: - -```toml -[dependencies] -anyhow = "1.0.86" -ratatui = "0.27.0" -regex = "1.10.5" -shellexpand = "3.1.0" -serde = { version = "1.0", features = ["derive", "rc"] } -serde_json = "1.0" -sha2 = "0.10.8" -hex = "0.4.3" -``` - -- [ ] **Step 2: Create the library module list** - -Create `src/lib.rs`: - -```rust -pub mod context; -pub mod domain; -pub mod event_log; -pub mod session; -pub mod state_machine; -pub mod window; -``` - -- [ ] **Step 3: Update the binary to use the library window module** - -In `src/main.rs`, remove: - -```rust -mod window; -``` - -and add: - -```rust -use antidrift::window; -``` - -Keep `mod constants;` in `main.rs` for now. Constants can move later when the UI is split out. - -- [ ] **Step 4: Verify the existing app still builds** - -Run: - -```bash -cargo test -``` - -Expected: build succeeds and existing behavior is unchanged. - -- [ ] **Step 5: Commit** - -```bash -git add Cargo.toml src/lib.rs src/main.rs -git commit -m "Introduce library boundary" -``` - -## Task 2: Add Commitment And Policy Domain Types - -**Files:** -- Create: `src/domain.rs` - -- [ ] **Step 1: Write domain type tests** - -Create `src/domain.rs` with this test module first: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[test] - fn commitment_requires_concrete_action_and_success_condition() { - let err = Commitment::new_manual("", "tests pass", Duration::from_secs(1500)) - .expect_err("empty next action must be rejected"); - assert_eq!(err, CommitmentValidationError::MissingNextAction); - - let err = Commitment::new_manual("Refactor state", "", Duration::from_secs(1500)) - .expect_err("empty success condition must be rejected"); - assert_eq!(err, CommitmentValidationError::MissingSuccessCondition); - } - - #[test] - fn commitment_requires_nonzero_timebox() { - let err = Commitment::new_manual("Refactor state", "tests pass", Duration::ZERO) - .expect_err("zero timebox must be rejected"); - assert_eq!(err, CommitmentValidationError::MissingTimebox); - } - - #[test] - fn policy_snapshot_carries_runtime_contract() { - let commitment = Commitment::new_manual( - "Implement domain types", - "domain tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - let policy = PolicySnapshot::for_runtime( - commitment.id.clone(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - - assert_eq!(policy.commitment_id, commitment.id); - assert_eq!(policy.runtime_state, RuntimeState::Active); - assert_eq!(policy.enforcement_level, EnforcementLevel::Warn); - assert_eq!(policy.schema_version, 1); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: - -```bash -cargo test domain -``` - -Expected: compile failure because the domain types do not exist yet. - -- [ ] **Step 3: Add minimal domain implementation** - -Add this above the tests in `src/domain.rs`: - -```rust -use serde::{Deserialize, Serialize}; -use std::fmt; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -pub const POLICY_SCHEMA_VERSION: u16 = 1; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum CommitmentSource { - Manual, - Planner, - Recurring, - Recovery, - Template, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum CommitmentState { - Draft, - Active, - Paused, - Completed, - Abandoned, - Violated, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum RuntimeState { - Locked, - Planning, - Active, - Transition, - Review, - AdminOverride, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum EnforcementLevel { - Observe, - Warn, - Block, - Locked, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum EvidenceHealth { - Available, - Degraded(String), - Unavailable(String), -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct AllowedContext { - pub window_classes: Vec, - pub window_title_substrings: Vec, - pub domains: Vec, - pub repos: Vec, - pub commands: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct Commitment { - pub id: String, - pub created_at_unix_secs: u64, - pub source: CommitmentSource, - pub project_id: Option, - pub template_id: Option, - pub next_action: String, - pub success_condition: String, - pub timebox_secs: u64, - pub transition_policy_id: Option, - pub state: CommitmentState, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct PolicySnapshot { - pub id: String, - pub commitment_id: String, - pub schema_version: u16, - pub created_at_unix_secs: u64, - pub runtime_state: RuntimeState, - pub enforcement_level: EnforcementLevel, - pub allowed_context: AllowedContext, - pub required_monitors: Vec, - pub violation_actions: Vec, - pub expires_at_unix_secs: Option, - pub generated_by_agent_version: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum CommitmentValidationError { - MissingNextAction, - MissingSuccessCondition, - MissingTimebox, -} - -impl fmt::Display for CommitmentValidationError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - CommitmentValidationError::MissingNextAction => write!(f, "next action is required"), - CommitmentValidationError::MissingSuccessCondition => { - write!(f, "success condition is required") - } - CommitmentValidationError::MissingTimebox => write!(f, "timebox must be nonzero"), - } - } -} - -impl Commitment { - pub fn new_manual( - next_action: impl Into, - success_condition: impl Into, - timebox: Duration, - ) -> Result { - let next_action = next_action.into().trim().to_string(); - let success_condition = success_condition.into().trim().to_string(); - - if next_action.is_empty() { - return Err(CommitmentValidationError::MissingNextAction); - } - if success_condition.is_empty() { - return Err(CommitmentValidationError::MissingSuccessCondition); - } - if timebox.is_zero() { - return Err(CommitmentValidationError::MissingTimebox); - } - - let created_at_unix_secs = unix_secs_now(); - Ok(Self { - id: format!("commitment-{created_at_unix_secs}"), - created_at_unix_secs, - source: CommitmentSource::Manual, - project_id: None, - template_id: None, - next_action, - success_condition, - timebox_secs: timebox.as_secs(), - transition_policy_id: None, - state: CommitmentState::Draft, - }) - } -} - -impl PolicySnapshot { - pub fn for_runtime( - commitment_id: String, - runtime_state: RuntimeState, - enforcement_level: EnforcementLevel, - allowed_context: AllowedContext, - ) -> Self { - let created_at_unix_secs = unix_secs_now(); - Self { - id: format!("policy-{created_at_unix_secs}"), - commitment_id, - schema_version: POLICY_SCHEMA_VERSION, - created_at_unix_secs, - runtime_state, - enforcement_level, - allowed_context, - required_monitors: Vec::new(), - violation_actions: Vec::new(), - expires_at_unix_secs: None, - generated_by_agent_version: env!("CARGO_PKG_VERSION").to_string(), - } - } -} - -pub fn unix_secs_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() -} -``` - -- [ ] **Step 4: Verify domain tests pass** - -Run: - -```bash -cargo test domain -``` - -Expected: the three domain tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/domain.rs -git commit -m "Add commitment domain types" -``` - -## Task 3: Add Runtime And Commitment State Machines - -**Files:** -- Create: `src/state_machine.rs` - -- [ ] **Step 1: Write failing state-machine tests** - -Create `src/state_machine.rs` with this test module first: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::{CommitmentState, RuntimeState}; - - #[test] - fn planning_can_only_activate_with_valid_policy() { - assert_eq!( - transition_runtime(RuntimeState::Planning, RuntimeAction::Activate { policy_accepted: true }), - Ok(RuntimeState::Active) - ); - assert_eq!( - transition_runtime(RuntimeState::Planning, RuntimeAction::Activate { policy_accepted: false }), - Err(TransitionError::PolicyRejected) - ); - } - - #[test] - fn active_can_enter_transition_or_review_or_locked() { - assert_eq!( - transition_runtime(RuntimeState::Active, RuntimeAction::StartTransition), - Ok(RuntimeState::Transition) - ); - assert_eq!( - transition_runtime(RuntimeState::Active, RuntimeAction::CompleteForReview), - Ok(RuntimeState::Review) - ); - assert_eq!( - transition_runtime(RuntimeState::Active, RuntimeAction::SevereViolation), - Ok(RuntimeState::Locked) - ); - } - - #[test] - fn locked_cannot_directly_become_active() { - assert_eq!( - transition_runtime(RuntimeState::Locked, RuntimeAction::Activate { policy_accepted: true }), - Err(TransitionError::IllegalRuntimeTransition) - ); - } - - #[test] - fn commitment_violation_recovery_is_explicit() { - assert_eq!( - transition_commitment(CommitmentState::Violated, CommitmentAction::RecoverExplicitly), - Ok(CommitmentState::Active) - ); - assert_eq!( - transition_commitment(CommitmentState::Violated, CommitmentAction::ReturnFromPause), - Err(TransitionError::IllegalCommitmentTransition) - ); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test state_machine -``` - -Expected: compile failure because transition types/functions do not exist. - -- [ ] **Step 3: Add state-machine implementation** - -Add this above the tests in `src/state_machine.rs`: - -```rust -use crate::domain::{CommitmentState, RuntimeState}; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum RuntimeAction { - EnterPlanning, - CancelOrTimeout, - Activate { policy_accepted: bool }, - StartTransition, - CompleteForReview, - SevereViolation, - ReturnToActive, - SwitchTask, - EndTransitionForReview, - ContinuePlanning, - EndWorkPeriod, - EnterAdminOverride, - ExitAdminOverride(RuntimeState), -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommitmentAction { - Activate, - PauseForTransition, - ReturnFromPause, - Complete, - Abandon, - Violate, - RecoverExplicitly, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum TransitionError { - PolicyRejected, - IllegalRuntimeTransition, - IllegalCommitmentTransition, -} - -impl std::fmt::Display for TransitionError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - TransitionError::PolicyRejected => write!(f, "policy was rejected"), - TransitionError::IllegalRuntimeTransition => { - write!(f, "illegal runtime transition") - } - TransitionError::IllegalCommitmentTransition => { - write!(f, "illegal commitment transition") - } - } - } -} - -impl std::error::Error for TransitionError {} - -pub fn transition_runtime( - current: RuntimeState, - action: RuntimeAction, -) -> Result { - use RuntimeAction::*; - use RuntimeState::*; - - match (current, action) { - (Locked, EnterPlanning) => Ok(Planning), - (Planning, Activate { policy_accepted: true }) => Ok(Active), - (Planning, Activate { policy_accepted: false }) => Err(TransitionError::PolicyRejected), - (Planning, CancelOrTimeout) => Ok(Locked), - (Active, StartTransition) => Ok(Transition), - (Active, CompleteForReview) => Ok(Review), - (Active, SevereViolation) => Ok(Locked), - (Transition, ReturnToActive) => Ok(Active), - (Transition, SwitchTask) => Ok(Planning), - (Transition, EndTransitionForReview) => Ok(Review), - (Transition, CancelOrTimeout) => Ok(Locked), - (Review, ContinuePlanning) => Ok(Planning), - (Review, EndWorkPeriod) => Ok(Locked), - (_, EnterAdminOverride) => Ok(AdminOverride), - (AdminOverride, ExitAdminOverride(next)) => Ok(next), - _ => Err(TransitionError::IllegalRuntimeTransition), - } -} - -pub fn transition_commitment( - current: CommitmentState, - action: CommitmentAction, -) -> Result { - use CommitmentAction::*; - use CommitmentState::*; - - match (current, action) { - (Draft, Activate) => Ok(Active), - (Active, PauseForTransition) => Ok(Paused), - (Paused, ReturnFromPause) => Ok(Active), - (Active, Complete) => Ok(Completed), - (Active, Abandon) => Ok(Abandoned), - (Active, Violate) => Ok(Violated), - (Paused, Abandon) => Ok(Abandoned), - (Violated, RecoverExplicitly) => Ok(Active), - (Violated, Abandon) => Ok(Abandoned), - _ => Err(TransitionError::IllegalCommitmentTransition), - } -} -``` - -- [ ] **Step 4: Verify state-machine tests pass** - -Run: - -```bash -cargo test state_machine -``` - -Expected: all state-machine tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/state_machine.rs -git commit -m "Add commitment state machines" -``` - -## Task 4: Add Allowed Context Matching - -**Files:** -- Create: `src/context.rs` - -- [ ] **Step 1: Write failing context matching tests** - -Create `src/context.rs` with this test module first: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::AllowedContext; - - #[test] - fn domains_match_exact_or_subdomain() { - let context = AllowedContext { - domains: vec!["github.com".to_string()], - ..AllowedContext::default() - }; - - assert!(domain_allowed(&context, "github.com")); - assert!(domain_allowed(&context, "docs.github.com")); - assert!(!domain_allowed(&context, "evilgithub.com")); - } - - #[test] - fn window_classes_match_case_insensitively_after_normalization() { - let context = AllowedContext { - window_classes: vec!["code".to_string()], - ..AllowedContext::default() - }; - - assert!(window_class_allowed(&context, "Code")); - assert!(!window_class_allowed(&context, "firefox")); - } - - #[test] - fn titles_match_by_substring() { - let context = AllowedContext { - window_title_substrings: vec!["antidrift".to_string()], - ..AllowedContext::default() - }; - - assert!(window_title_allowed(&context, "Commitment OS - AntiDrift")); - assert!(!window_title_allowed(&context, "random video")); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test context -``` - -Expected: compile failure because matching functions do not exist. - -- [ ] **Step 3: Implement matching functions** - -Add this above the tests in `src/context.rs`: - -```rust -use crate::domain::AllowedContext; - -pub fn domain_allowed(context: &AllowedContext, candidate: &str) -> bool { - let candidate = candidate.trim().trim_end_matches('.').to_ascii_lowercase(); - context.domains.iter().any(|allowed| { - let allowed = allowed.trim().trim_end_matches('.').to_ascii_lowercase(); - candidate == allowed || candidate.ends_with(&format!(".{allowed}")) - }) -} - -pub fn window_class_allowed(context: &AllowedContext, candidate: &str) -> bool { - let candidate = candidate.trim().to_ascii_lowercase(); - context - .window_classes - .iter() - .any(|allowed| allowed.trim().to_ascii_lowercase() == candidate) -} - -pub fn window_title_allowed(context: &AllowedContext, candidate: &str) -> bool { - let candidate = candidate.to_ascii_lowercase(); - context - .window_title_substrings - .iter() - .any(|allowed| candidate.contains(&allowed.to_ascii_lowercase())) -} - -pub fn command_allowed(context: &AllowedContext, candidate: &str) -> bool { - let basename = candidate.rsplit('/').next().unwrap_or(candidate); - context.commands.iter().any(|allowed| allowed == basename) -} -``` - -- [ ] **Step 4: Verify context tests pass** - -Run: - -```bash -cargo test context -``` - -Expected: all context tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/context.rs -git commit -m "Add allowed context matching" -``` - -## Task 5: Add Append-Only Hash-Chained Event Log - -**Files:** -- Create: `src/event_log.rs` - -- [ ] **Step 1: Write failing event log tests** - -Create `src/event_log.rs` with this test module first: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::RuntimeState; - use std::fs; - - #[test] - fn appending_events_hash_chains_records() { - let path = std::env::temp_dir().join(format!( - "antidrift-event-log-{}.jsonl", - std::process::id() - )); - let _ = fs::remove_file(&path); - - let mut log = EventLog::open(&path).unwrap(); - let first = log - .append(EventType::RuntimeTransition, RuntimeState::Planning, "commitment-1", "{}") - .unwrap(); - let second = log - .append(EventType::PolicyApplied, RuntimeState::Active, "commitment-1", "{}") - .unwrap(); - - assert_eq!(first.sequence, 1); - assert_eq!(second.sequence, 2); - assert_eq!(second.previous_hash, first.hash); - assert_ne!(second.hash, first.hash); - - let content = fs::read_to_string(&path).unwrap(); - assert_eq!(content.lines().count(), 2); - let _ = fs::remove_file(&path); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test event_log -``` - -Expected: compile failure because `EventLog` and event types do not exist. - -- [ ] **Step 3: Implement event log** - -Add this above the tests in `src/event_log.rs`: - -```rust -use crate::domain::{unix_secs_now, RuntimeState, POLICY_SCHEMA_VERSION}; -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::{Path, PathBuf}; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum EventType { - CommitmentCreated, - RuntimeTransition, - CommitmentTransition, - PolicyApplied, - EvidenceObserved, - ViolationObserved, - TransitionStarted, - ReviewCompleted, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct EventRecord { - pub schema_version: u16, - pub sequence: u64, - pub timestamp_unix_secs: u64, - pub event_type: EventType, - pub commitment_id: String, - pub runtime_state: RuntimeState, - pub payload_json: String, - pub previous_hash: String, - pub hash: String, -} - -pub struct EventLog { - path: PathBuf, - next_sequence: u64, - previous_hash: String, -} - -impl EventLog { - pub fn open(path: impl AsRef) -> Result { - let path = path.as_ref().to_path_buf(); - let mut next_sequence = 1; - let mut previous_hash = String::new(); - - if path.exists() { - let content = fs::read_to_string(&path) - .with_context(|| format!("read event log {}", path.display()))?; - for line in content.lines().filter(|line| !line.trim().is_empty()) { - let record: EventRecord = serde_json::from_str(line) - .with_context(|| format!("parse event log line in {}", path.display()))?; - next_sequence = record.sequence + 1; - previous_hash = record.hash; - } - } - - Ok(Self { - path, - next_sequence, - previous_hash, - }) - } - - pub fn append( - &mut self, - event_type: EventType, - runtime_state: RuntimeState, - commitment_id: impl Into, - payload_json: impl Into, - ) -> Result { - let mut record = EventRecord { - schema_version: POLICY_SCHEMA_VERSION, - sequence: self.next_sequence, - timestamp_unix_secs: unix_secs_now(), - event_type, - commitment_id: commitment_id.into(), - runtime_state, - payload_json: payload_json.into(), - previous_hash: self.previous_hash.clone(), - hash: String::new(), - }; - record.hash = hash_record(&record)?; - - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent) - .with_context(|| format!("create event log directory {}", parent.display()))?; - } - - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(&self.path) - .with_context(|| format!("open event log {}", self.path.display()))?; - writeln!(file, "{}", serde_json::to_string(&record)?) - .with_context(|| format!("append event log {}", self.path.display()))?; - - self.next_sequence += 1; - self.previous_hash = record.hash.clone(); - Ok(record) - } -} - -fn hash_record(record: &EventRecord) -> Result { - let mut clone = record.clone(); - clone.hash.clear(); - let json = serde_json::to_vec(&clone)?; - let digest = Sha256::digest(json); - Ok(hex::encode(digest)) -} -``` - -- [ ] **Step 4: Verify event log tests pass** - -Run: - -```bash -cargo test event_log -``` - -Expected: event log test passes and writes/removes one temp file. - -- [ ] **Step 5: Commit** - -```bash -git add Cargo.toml src/event_log.rs -git commit -m "Add hash chained event log" -``` - -## Task 6: Add Session Controller - -**Files:** -- Create: `src/session.rs` - -- [ ] **Step 1: Write failing session controller tests** - -Create `src/session.rs` with this test module first: - -```rust -#[cfg(test)] -mod tests { - use super::*; - use crate::domain::{EnforcementLevel, RuntimeState}; - use std::fs; - use std::time::Duration; - - #[test] - fn starts_commitment_and_emits_policy() { - let path = std::env::temp_dir().join(format!( - "antidrift-session-{}.jsonl", - std::process::id() - )); - let _ = fs::remove_file(&path); - - let mut controller = SessionController::new(&path).unwrap(); - controller.enter_planning().unwrap(); - controller - .start_manual_commitment( - "Implement session controller", - "session tests pass", - Duration::from_secs(1500), - ) - .unwrap(); - - assert_eq!(controller.runtime_state(), RuntimeState::Active); - assert_eq!( - controller.active_policy().unwrap().enforcement_level, - EnforcementLevel::Warn - ); - - let _ = fs::remove_file(&path); - } - - #[test] - fn transition_requires_reason_and_return_target() { - let path = std::env::temp_dir().join(format!( - "antidrift-transition-{}.jsonl", - std::process::id() - )); - let _ = fs::remove_file(&path); - - let mut controller = SessionController::new(&path).unwrap(); - controller.enter_planning().unwrap(); - controller - .start_manual_commitment("Write docs", "draft is complete", Duration::from_secs(900)) - .unwrap(); - - assert!(controller.start_transition("", "same task", Duration::from_secs(60)).is_err()); - assert!(controller.start_transition("water", "", Duration::from_secs(60)).is_err()); - controller - .start_transition("water", "same task", Duration::from_secs(60)) - .unwrap(); - assert_eq!(controller.runtime_state(), RuntimeState::Transition); - - let _ = fs::remove_file(&path); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: - -```bash -cargo test session -``` - -Expected: compile failure because `SessionController` does not exist. - -- [ ] **Step 3: Implement session controller** - -Add this above the tests in `src/session.rs`: - -```rust -use crate::domain::{ - AllowedContext, Commitment, CommitmentState, EnforcementLevel, PolicySnapshot, RuntimeState, -}; -use crate::event_log::{EventLog, EventType}; -use crate::state_machine::{ - transition_commitment, transition_runtime, CommitmentAction, RuntimeAction, -}; -use anyhow::{anyhow, Result}; -use std::path::Path; -use std::time::Duration; - -pub struct SessionController { - runtime_state: RuntimeState, - active_commitment: Option, - active_policy: Option, - event_log: EventLog, -} - -impl SessionController { - pub fn new(event_log_path: impl AsRef) -> Result { - Ok(Self { - runtime_state: RuntimeState::Locked, - active_commitment: None, - active_policy: None, - event_log: EventLog::open(event_log_path)?, - }) - } - - pub fn runtime_state(&self) -> RuntimeState { - self.runtime_state - } - - pub fn active_policy(&self) -> Option<&PolicySnapshot> { - self.active_policy.as_ref() - } - - pub fn active_commitment(&self) -> Option<&Commitment> { - self.active_commitment.as_ref() - } - - pub fn enter_planning(&mut self) -> Result<()> { - self.runtime_state = - transition_runtime(self.runtime_state, RuntimeAction::EnterPlanning)?; - self.event_log.append( - EventType::RuntimeTransition, - self.runtime_state, - "", - r#"{"to":"planning"}"#, - )?; - Ok(()) - } - - pub fn start_manual_commitment( - &mut self, - next_action: impl Into, - success_condition: impl Into, - timebox: Duration, - ) -> Result<()> { - let mut commitment = Commitment::new_manual(next_action, success_condition, timebox)?; - commitment.state = transition_commitment(commitment.state, CommitmentAction::Activate)?; - let policy = PolicySnapshot::for_runtime( - commitment.id.clone(), - RuntimeState::Active, - EnforcementLevel::Warn, - AllowedContext::default(), - ); - self.runtime_state = transition_runtime( - self.runtime_state, - RuntimeAction::Activate { - policy_accepted: true, - }, - )?; - self.event_log.append( - EventType::CommitmentCreated, - self.runtime_state, - commitment.id.as_str(), - &serde_json::to_string(&commitment)?, - )?; - self.event_log.append( - EventType::PolicyApplied, - self.runtime_state, - commitment.id.as_str(), - &serde_json::to_string(&policy)?, - )?; - self.active_commitment = Some(commitment); - self.active_policy = Some(policy); - Ok(()) - } - - pub fn start_transition( - &mut self, - reason: impl Into, - return_target: impl Into, - expected_duration: Duration, - ) -> Result<()> { - let reason = reason.into().trim().to_string(); - let return_target = return_target.into().trim().to_string(); - if reason.is_empty() { - return Err(anyhow!("transition reason is required")); - } - if return_target.is_empty() { - return Err(anyhow!("transition return target is required")); - } - if expected_duration.is_zero() { - return Err(anyhow!("transition duration must be nonzero")); - } - - let commitment = self - .active_commitment - .as_mut() - .ok_or_else(|| anyhow!("active commitment is required"))?; - commitment.state = - transition_commitment(commitment.state.clone(), CommitmentAction::PauseForTransition)?; - self.runtime_state = - transition_runtime(self.runtime_state, RuntimeAction::StartTransition)?; - - let payload = serde_json::json!({ - "reason": reason, - "return_target": return_target, - "expected_duration_secs": expected_duration.as_secs() - }); - self.event_log.append( - EventType::TransitionStarted, - self.runtime_state, - commitment.id.as_str(), - &payload.to_string(), - )?; - Ok(()) - } -} -``` - -- [ ] **Step 4: Verify session tests pass** - -Run: - -```bash -cargo test session -``` - -Expected: session controller tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add src/session.rs -git commit -m "Add session controller" -``` - -## Task 7: Expand Window Evidence Snapshots - -**Files:** -- Modify: `src/window/mod.rs` -- Modify: `src/window/linux.rs` -- Modify: `src/window/windows.rs` - -- [ ] **Step 1: Add shared window snapshot type** - -Modify `src/window/mod.rs`: - -```rust -use crate::domain::EvidenceHealth; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct WindowSnapshot { - pub title: String, - pub class: Option, - pub health: EvidenceHealth, -} - -impl WindowSnapshot { - pub fn unavailable(reason: impl Into) -> Self { - Self { - title: "none".to_string(), - class: None, - health: EvidenceHealth::Unavailable(reason.into()), - } - } -} - -#[cfg(target_os = "windows")] -mod windows; -#[cfg(target_os = "windows")] -pub use windows::*; - -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "linux")] -pub use linux::*; -``` - -- [ ] **Step 2: Update Linux window adapter** - -In `src/window/linux.rs`, change `WindowInfo` to keep class public inside the module and add `get_snapshot()`: - -```rust -use crate::domain::EvidenceHealth; -use crate::window::WindowSnapshot; -use regex::Regex; -use std::{process::Command, process::Output, str}; - -pub fn get_title_clean() -> String { - get_snapshot().title -} - -pub fn get_snapshot() -> WindowSnapshot { - let info = get_window_info(); - let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap(); - WindowSnapshot { - title: re.replace_all(&info.title, "").to_string(), - class: if info.class == "none" { None } else { Some(info.class) }, - health: if info.wid.is_empty() { - EvidenceHealth::Unavailable("xdotool active window unavailable".to_string()) - } else { - EvidenceHealth::Available - }, - } -} -``` - -Keep the existing `minimize_other`, `run`, and `get_window_info` logic, but rename `_class` to `class` in the `WindowInfo` struct and constructor. - -- [ ] **Step 3: Update Windows window adapter** - -In `src/window/windows.rs`, add `get_snapshot()`: - -```rust -use crate::domain::EvidenceHealth; -use crate::window::WindowSnapshot; -use regex::Regex; -use std::{ffi::OsString, os::windows::ffi::OsStringExt}; -use winapi::shared::windef::HWND; -use winapi::um::winuser::{GetForegroundWindow, GetWindowTextW, ShowWindow, SW_MINIMIZE}; - -pub fn get_title_clean() -> String { - get_snapshot().title -} - -pub fn get_snapshot() -> WindowSnapshot { - let title = get_window_info().title; - let re = Regex::new(r"-?\d+([:.]\d+)+%?").unwrap(); - WindowSnapshot { - title: re.replace_all(&title, "").to_string(), - class: None, - health: EvidenceHealth::Degraded("window class unavailable on current Windows adapter".to_string()), - } -} -``` - -Keep the existing `minimize_other` and `get_window_info` logic. - -- [ ] **Step 4: Verify build** - -Run: - -```bash -cargo test -``` - -Expected: all current tests pass on Linux. Windows cross-check is compile-only when run on Windows later. - -- [ ] **Step 5: Commit** - -```bash -git add src/window/mod.rs src/window/linux.rs src/window/windows.rs -git commit -m "Expose window evidence snapshots" -``` - -## Task 8: Wire Session Controller Into Current TUI - -**Files:** -- Modify: `src/constants.rs` -- Modify: `src/main.rs` - -- [ ] **Step 1: Add constants** - -Add to `src/constants.rs`: - -```rust -pub const EVENT_LOG_FILE: &str = "~/.antidrift_events.jsonl"; -pub const SUCCESS_CONDITION_TITLE: &str = "Success Condition"; -pub const DEGRADED_EVIDENCE_TITLE: &str = "Evidence"; -pub const STATUS_LOCKED: &str = "πŸ”’ antidrift locked"; -pub const STATUS_PLANNING: &str = "🧭 antidrift planning"; -pub const STATUS_TRANSITION: &str = "↔ antidrift transition"; -pub const ENTER_SUCCESS_CONDITION: &str = "Provide success condition! "; -``` - -- [ ] **Step 2: Extend app state for success condition and controller** - -In `src/main.rs`, add imports: - -```rust -use antidrift::domain::EvidenceHealth; -use antidrift::session::SessionController; -``` - -Add a `InputSuccessCondition` state: - -```rust -enum State { - InputIntention, - InputSuccessCondition, - InputDuration, - InProgress, - Paused, - End, - ShouldQuit, -} -``` - -Add fields to `App`: - -```rust -user_success_condition: String, -session_controller: SessionController, -evidence_health: EvidenceHealth, -``` - -Change `App::new()` to return `Result` and initialize the controller: - -```rust -fn new() -> Result { - let window_snapshot = window::get_snapshot(); - let event_log_path = shellexpand::tilde(constants::EVENT_LOG_FILE).to_string(); - let mut session_controller = SessionController::new(event_log_path)?; - session_controller.enter_planning()?; - - Ok(App { - state: State::InputIntention, - user_intention: String::new(), - user_success_condition: String::new(), - user_duration_str: constants::DEFAULT_DURATION.to_string(), - user_duration: Duration::ZERO, - current_window_title: window_snapshot.title.into(), - current_window_time: Instant::now(), - session_start: Instant::now(), - session_stats: HashMap::new(), - session_remaining: Duration::ZERO, - session_ratings: Vec::new(), - session_ratings_index: 0, - session_results: Vec::new(), - last_tick_50ms: Instant::now(), - last_tick_1s: Instant::now(), - session_controller, - evidence_health: window_snapshot.health, - }) -} -``` - -Change main initialization: - -```rust -let mut app = App::new()?; -``` - -- [ ] **Step 3: Start commitments through controller** - -Update `to_in_progress`: - -```rust -fn to_in_progress(&mut self) { - if self.user_intention.is_empty() - || self.user_success_condition.is_empty() - || self.user_duration == Duration::ZERO - { - return; - } - - if self - .session_controller - .start_manual_commitment( - self.user_intention.clone(), - self.user_success_condition.clone(), - self.user_duration, - ) - .is_err() - { - return; - } - - self.state = State::InProgress; - self.current_window_time = Instant::now(); - self.session_start = self.current_window_time; - self.session_stats = HashMap::new(); - self.session_ratings_index = 0; -} -``` - -- [ ] **Step 4: Add input handling for success condition** - -In `handle_events`, change InputIntention Enter/Tab to move to `InputSuccessCondition`, add this match arm, and make Tab move through the three fields: - -```rust -State::InputSuccessCondition => match key.code { - KeyCode::Enter => app.state = State::InputDuration, - KeyCode::Tab => app.state = State::InputDuration, - KeyCode::Backspace => { - let _ = app.user_success_condition.pop(); - } - KeyCode::Char(c) => { - app.user_success_condition.push(c); - } - _ => {} -}, -``` - -In the `InputDuration` arm, change Tab to: - -```rust -KeyCode::Tab => app.state = State::InputIntention, -``` - -- [ ] **Step 5: Render success condition and evidence health** - -Add a new layout row for success condition. Replace the layout constraints with: - -```rust -let layout = Layout::vertical([ - Constraint::Min(3), - Constraint::Min(3), - Constraint::Min(3), - Constraint::Percentage(100), - Constraint::Min(3), -]); -let [ - layout_intention, - layout_success, - layout_duration, - layout_titles, - layout_status, -] = layout.areas(frame.size()); -``` - -Render success condition: - -```rust -let border_type_success = if app.state == State::InputSuccessCondition { - BorderType::Thick -} else { - BorderType::Plain -}; -frame.render_widget( - Paragraph::new(Line::from(Span::raw(&app.user_success_condition))).block( - Block::bordered() - .border_type(border_type_success) - .title(constants::SUCCESS_CONDITION_TITLE), - ), - layout_success, -); -``` - -Add missing success condition warning beside the existing validation spans: - -```rust -if app.user_success_condition.is_empty() { - spans.push(Span::styled( - constants::ENTER_SUCCESS_CONDITION, - Style::new().fg(Color::LightRed), - )); -} -``` - -- [ ] **Step 6: Verify manually and with tests** - -Run: - -```bash -cargo test -cargo run -``` - -Expected: - -- tests pass; -- app starts in planning/input mode; -- UI asks for intention, success condition, and duration; -- session starts only when all three are present; -- `~/.antidrift_events.jsonl` receives commitment and policy events. - -- [ ] **Step 7: Commit** - -```bash -git add src/constants.rs src/main.rs -git commit -m "Wire commitments into session UI" -``` - -## Task 9: Add Explicit Transition Prompt - -**Files:** -- Modify: `src/main.rs` -- Modify: `src/constants.rs` - -- [ ] **Step 1: Add transition constants** - -Add to `src/constants.rs`: - -```rust -pub const TRANSITION_REASON_TITLE: &str = "Transition Reason"; -pub const TRANSITION_RETURN_TITLE: &str = "Return Target"; -pub const TRANSITION_DURATION_TITLE: &str = "Transition Minutes"; -pub const PROVIDE_TRANSITION_REASON: &str = "Provide transition reason! "; -pub const PROVIDE_TRANSITION_RETURN: &str = "Provide return target! "; -``` - -- [ ] **Step 2: Add transition input states and fields** - -In `src/main.rs`, extend `State`: - -```rust -InputTransitionReason, -InputTransitionReturn, -InputTransitionDuration, -``` - -Add fields to `App`: - -```rust -transition_reason: String, -transition_return_target: String, -transition_duration_str: String, -``` - -Initialize them: - -```rust -transition_reason: String::new(), -transition_return_target: String::new(), -transition_duration_str: "2".to_string(), -``` - -- [ ] **Step 3: Route pause key into transition prompt** - -In the `State::InProgress` key handling, replace direct pause: - -```rust -KeyCode::Char('p') => { - app.state = State::InputTransitionReason; - app.transition_reason.clear(); - app.transition_return_target = app.user_intention.clone(); - app.transition_duration_str = "2".to_string(); -} -``` - -- [ ] **Step 4: Add transition input handling** - -Add match arms: - -```rust -State::InputTransitionReason => match key.code { - KeyCode::Enter | KeyCode::Tab => app.state = State::InputTransitionReturn, - KeyCode::Backspace => { - let _ = app.transition_reason.pop(); - } - KeyCode::Char(c) => app.transition_reason.push(c), - _ => {} -}, -State::InputTransitionReturn => match key.code { - KeyCode::Enter | KeyCode::Tab => app.state = State::InputTransitionDuration, - KeyCode::Backspace => { - let _ = app.transition_return_target.pop(); - } - KeyCode::Char(c) => app.transition_return_target.push(c), - _ => {} -}, -State::InputTransitionDuration => match key.code { - KeyCode::Enter => app.start_transition_if_valid(), - KeyCode::Tab => app.state = State::InputTransitionReason, - KeyCode::Backspace => { - let _ = app.transition_duration_str.pop(); - } - KeyCode::Char(c) if c.is_ascii_digit() => app.transition_duration_str.push(c), - _ => {} -}, -``` - -- [ ] **Step 5: Add helper to start transition** - -Add to `impl App`: - -```rust -fn start_transition_if_valid(&mut self) { - let Ok(minutes) = self.transition_duration_str.parse::() else { - return; - }; - if minutes == 0 || self.transition_reason.is_empty() || self.transition_return_target.is_empty() - { - return; - } - if self - .session_controller - .start_transition( - self.transition_reason.clone(), - self.transition_return_target.clone(), - Duration::from_secs(minutes * 60), - ) - .is_ok() - { - self.state = State::Paused; - } -} -``` - -- [ ] **Step 6: Render transition inputs** - -When `app.state` is one of the transition input states, render the top fields as transition reason, return target, and duration using the existing three-row input pattern. The status row should include `PROVIDE_TRANSITION_REASON` or `PROVIDE_TRANSITION_RETURN` when missing. - -Use the same border-thick selection pattern already used for intention/duration: - -```rust -let is_transition_input = matches!( - app.state, - State::InputTransitionReason | State::InputTransitionReturn | State::InputTransitionDuration -); -``` - -If `is_transition_input`, the first three input rows should show transition fields instead of commitment fields. - -- [ ] **Step 7: Verify transition behavior** - -Run: - -```bash -cargo test -cargo run -``` - -Expected: - -- pressing `p` during an active session opens transition reason input; -- transition cannot start without reason, return target, and positive duration; -- successful transition enters the existing paused state; -- event log records a `TransitionStarted` event. - -- [ ] **Step 8: Commit** - -```bash -git add src/constants.rs src/main.rs -git commit -m "Require explicit transition prompts" -``` - -## Task 10: Add Tier 0 Violation Friction For Unknown Windows - -**Files:** -- Modify: `src/session.rs` -- Modify: `src/main.rs` -- Modify: `src/constants.rs` - -- [ ] **Step 1: Add violation constants** - -Add to `src/constants.rs`: - -```rust -pub const VIOLATION_TITLE: &str = "Violation"; -pub const VIOLATION_REASON_TITLE: &str = "Dismissal Reason"; -pub const VIOLATION_STATUS: &str = "Unknown context detected. Enter a reason to continue."; -``` - -- [ ] **Step 2: Add violation event method** - -In `src/session.rs`, add: - -```rust -pub fn record_violation( - &mut self, - reason: impl Into, - payload_json: impl Into, -) -> Result<()> { - let payload_json = payload_json.into(); - let commitment_id = self - .active_commitment - .as_ref() - .map(|commitment| commitment.id.clone()) - .unwrap_or_default(); - let payload = serde_json::json!({ - "reason": reason.into(), - "evidence": payload_json - }); - self.event_log.append( - EventType::ViolationObserved, - self.runtime_state, - commitment_id, - &payload.to_string(), - )?; - Ok(()) -} -``` - -- [ ] **Step 3: Add violation UI state and fields** - -In `src/main.rs`, add `ViolationPrompt` to `State` and fields: - -```rust -violation_message: String, -violation_dismissal_reason: String, -``` - -Initialize: - -```rust -violation_message: String::new(), -violation_dismissal_reason: String::new(), -``` - -- [ ] **Step 4: Detect unknown active window during active sessions** - -In `tick_50ms`, after `update_session_stats(self)` in `State::InProgress`, add: - -```rust -if self.should_prompt_for_unknown_window() { - self.violation_message = format!("Unknown window: {}", self.current_window_title); - self.violation_dismissal_reason.clear(); - self.state = State::ViolationPrompt; -} -``` - -Add helper: - -```rust -fn should_prompt_for_unknown_window(&self) -> bool { - let Some(policy) = self.session_controller.active_policy() else { - return false; - }; - let title = self.current_window_title.as_ref(); - if policy.allowed_context.window_title_substrings.is_empty() - && policy.allowed_context.window_classes.is_empty() - { - return false; - } - !antidrift::context::window_title_allowed(&policy.allowed_context, title) -} -``` - -This is intentionally title-based for Stage 1 because class data is not yet stored in `session_stats`. - -- [ ] **Step 5: Add dismissal handling** - -In `handle_events`, add: - -```rust -State::ViolationPrompt => match key.code { - KeyCode::Enter => { - if !app.violation_dismissal_reason.trim().is_empty() { - let _ = app.session_controller.record_violation( - app.violation_dismissal_reason.clone(), - app.violation_message.clone(), - ); - app.state = State::InProgress; - } - } - KeyCode::Backspace => { - let _ = app.violation_dismissal_reason.pop(); - } - KeyCode::Char(c) => app.violation_dismissal_reason.push(c), - _ => {} -}, -``` - -- [ ] **Step 6: Render violation prompt as a full-screen friction view** - -At the top of `ui(frame, app)`, before normal layout rendering, add: - -```rust -if app.state == State::ViolationPrompt { - let area = frame.size(); - let text = vec![ - Line::from(Span::styled(constants::VIOLATION_STATUS, Style::new().fg(Color::LightRed))), - Line::from(Span::raw(&app.violation_message)), - Line::from(Span::raw("")), - Line::from(Span::raw(&app.violation_dismissal_reason)), - ]; - frame.render_widget( - Paragraph::new(text).block( - Block::bordered() - .border_type(BorderType::Thick) - .title(constants::VIOLATION_TITLE), - ), - area, - ); - return; -} -``` - -- [ ] **Step 7: Verify violation friction** - -Temporarily start a commitment with a non-empty `allowed_context.window_title_substrings` in `SessionController::start_manual_commitment`, run the app, switch to an unknown window, and confirm the prompt appears. Remove the temporary allowlist before committing unless it is exposed through UI in this task. - -Run: - -```bash -cargo test -cargo run -``` - -Expected: - -- tests pass; -- violation prompt blocks the normal UI until a dismissal reason is entered; -- event log records `ViolationObserved`. - -- [ ] **Step 8: Commit** - -```bash -git add src/session.rs src/main.rs src/constants.rs -git commit -m "Add tier zero violation friction" -``` - -## Task 11: Add Degraded Evidence Reporting - -**Files:** -- Modify: `src/main.rs` -- Modify: `src/session.rs` -- Modify: `src/event_log.rs` - -- [ ] **Step 1: Record evidence observations** - -In `src/session.rs`, add: - -```rust -pub fn record_evidence( - &mut self, - payload_json: impl Into, -) -> Result<()> { - let commitment_id = self - .active_commitment - .as_ref() - .map(|commitment| commitment.id.clone()) - .unwrap_or_default(); - self.event_log.append( - EventType::EvidenceObserved, - self.runtime_state, - commitment_id, - payload_json.into(), - )?; - Ok(()) -} -``` - -- [ ] **Step 2: Update session stats to retain evidence health** - -In `update_session_stats`, replace `window::get_title_clean()` with: - -```rust -let snapshot = window::get_snapshot(); -app.evidence_health = snapshot.health.clone(); -let window_title = if app.state == State::Paused { - constants::PAUSED.to_string().into() -} else { - snapshot.title.into() -}; -``` - -After updating `session_ratings`, record degraded evidence: - -```rust -match &app.evidence_health { - EvidenceHealth::Available => {} - EvidenceHealth::Degraded(reason) | EvidenceHealth::Unavailable(reason) => { - let payload = serde_json::json!({ "window_evidence": reason }).to_string(); - let _ = app.session_controller.record_evidence(payload); - } -} -``` - -- [ ] **Step 3: Render evidence health** - -In the status span construction, add: - -```rust -match &app.evidence_health { - EvidenceHealth::Available => {} - EvidenceHealth::Degraded(reason) => spans.push(Span::styled( - format!("Evidence degraded: {} ", reason), - Style::new().fg(Color::LightYellow), - )), - EvidenceHealth::Unavailable(reason) => spans.push(Span::styled( - format!("Evidence unavailable: {} ", reason), - Style::new().fg(Color::LightRed), - )), -} -``` - -- [ ] **Step 4: Verify degraded evidence behavior** - -Run: - -```bash -cargo test -``` - -Expected: all tests pass. - -Manual check: - -```bash -cargo run -``` - -Expected: if `xdotool` works, no evidence warning is shown. If active-window evidence fails, the status line shows degraded/unavailable evidence instead of silently pretending full observability. - -- [ ] **Step 5: Commit** - -```bash -git add src/main.rs src/session.rs src/event_log.rs -git commit -m "Surface degraded evidence" -``` - -## Task 12: Final Stage 1 Verification And Documentation - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Update README with Stage 1 behavior** - -Add this section to `README.md`: - -```markdown -## Commitment OS Stage 1 - -AntiDrift now treats a work session as a commitment: next action, success -condition, timebox, evidence, transition prompts, and review. Stage 1 is still -user-space friction, not privileged enforcement. - -The local event log is written to `~/.antidrift_events.jsonl`. It records -commitment creation, policy snapshots, runtime transitions, evidence health, -transition starts, and violation dismissals. The log is append-only and -hash-chained for tamper evidence, but it is not yet protected by a privileged -guardian. - -Current Linux active-window evidence depends on `xdotool` and is strongest on -X11. Wayland support is reported as degraded unless a compositor-specific -adapter is added later. -``` - -- [ ] **Step 2: Run full verification** - -Run: - -```bash -cargo test -cargo run -``` - -Expected: - -- tests pass; -- app starts; -- commitment requires next action, success condition, and duration; -- pause requires explicit transition reason and return target; -- evidence degradation is visible; -- event log is written; -- review flow still saves session history to `~/.antidrift_history.jsonl`. - -- [ ] **Step 3: Inspect git diff** - -Run: - -```bash -git diff --stat -git diff --check -``` - -Expected: - -- diff contains only Stage 1 implementation and README changes; -- `git diff --check` reports no whitespace errors. - -- [ ] **Step 4: Commit** - -```bash -git add README.md -git commit -m "Document commitment stage one" -``` - -## Self-Review Notes - -- Spec coverage: this plan covers Stage 1a, Stage 1b, and Stage 1c. It intentionally excludes guardian, IPC, domain blocking, delayed admin, planner integration, and presence sensing. -- Threat model: Stage 1 addresses Tier 0 with friction and logs, while keeping Tier 1+ for Stage 2. -- State machines: runtime and commitment state machines are implemented and tested before TUI integration. -- Policy contract: `PolicySnapshot` exists before privileged enforcement so Stage 2 can consume the same contract. -- Evidence: X11/window evidence remains adapter-based and degraded evidence is surfaced. -- Testing: every domain module has unit tests; final verification includes `cargo test` and manual `cargo run`. diff --git a/docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md b/docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md deleted file mode 100644 index 6d1bac1..0000000 --- a/docs/superpowers/plans/2026-05-31-m0-walking-skeleton.md +++ /dev/null @@ -1,1793 +0,0 @@ -# M0 Walking Skeleton Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stand up the Go `antidriftd` daemon that serves a local web UI and drives one manual commitment through Locked β†’ Planning β†’ Active β†’ Review β†’ Locked, persisting a snapshot that survives restart. - -**Architecture:** A single Go process holds runtime state in memory behind a mutex (`session.Controller`), persists a JSON snapshot on every change (`store`), and serves a vanilla-JS browser UI over Gin with a Server-Sent Events (SSE) stream for live state. Domain types and the pure state machine are direct ports of the existing Rust. Timebox expiry is server-authoritative via a `time.Timer`. - -**Tech Stack:** Go 1.26, Gin (`github.com/gin-gonic/gin`), `github.com/google/uuid` (UUIDv7), Go standard library (`net/http`, `encoding/json`, `os`, `sync`, `time`, `embed`), `go test`. - ---- - -## Scope - -Implements M0 from `docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md`. - -Excludes (later milestones): AI, active-window tracking, hash-chained audit log, allowed-context matching, violation friction, transition/admin-override flows beyond the M0 subset, visual polish. - -## File Structure - -- `go.mod` β€” module `antidrift`. -- `legacy/` β€” the existing Rust sources, moved aside for reference. -- `cmd/antidriftd/main.go` β€” entrypoint: load snapshot, build server, re-arm/expire timer, open browser, serve. -- `internal/domain/domain.go` β€” `Commitment`, `PolicySnapshot`, `AllowedContext`, state/enforcement enums, validation. (`domain_test.go`) -- `internal/statemachine/statemachine.go` β€” pure `TransitionRuntime` / `TransitionCommitment`. (`statemachine_test.go`) -- `internal/store/store.go` β€” `Snapshot`, `Load`, `Save`, `DefaultPath`. (`store_test.go`) -- `internal/session/session.go` β€” `Controller` (in-memory state + persistence), `State` projection. (`session_test.go`) -- `internal/web/broadcaster.go` β€” SSE fan-out `Broadcaster`. (`broadcaster_test.go`) -- `internal/web/web.go` β€” `Server`, routes, handlers, expiry timer. (`web_test.go`) -- `internal/web/static/index.html` β€” single-page UI (HTML + CSS + vanilla JS). - ---- - -## Task 1: Repository And Module Setup - -**Files:** -- Create: `go.mod` -- Create: `cmd/antidriftd/main.go` -- Move: `Cargo.toml`, `Cargo.lock`, `src/`, `antidrift.desktop` β†’ `legacy/` -- Modify: `.gitignore` - -- [ ] **Step 1: Move the Rust sources into `legacy/`** - -Run: - -```bash -mkdir -p legacy -git mv Cargo.toml Cargo.lock src antidrift.desktop legacy/ -rm -rf target -``` - -Expected: `legacy/` now contains `Cargo.toml`, `Cargo.lock`, `src/`, `antidrift.desktop`. `docs/`, `README.md`, `LICENSE` stay at the root. - -- [ ] **Step 2: Initialize the Go module** - -Run: - -```bash -go mod init antidrift -``` - -Expected: `go.mod` created with `module antidrift` and a `go 1.26` line. - -- [ ] **Step 3: Add Go entries to `.gitignore`** - -Append to `.gitignore`: - -```gitignore -# Go -/antidriftd -*.test -``` - -- [ ] **Step 4: Create a minimal entrypoint so the module builds** - -Create `cmd/antidriftd/main.go`: - -```go -package main - -import "fmt" - -func main() { - fmt.Println("antidriftd") -} -``` - -- [ ] **Step 5: Verify the module builds** - -Run: - -```bash -go build ./... -``` - -Expected: builds with no output. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "Set up Go module and move Rust to legacy" -``` - -## Task 2: Domain Types - -**Files:** -- Create: `internal/domain/domain.go` -- Test: `internal/domain/domain_test.go` - -- [ ] **Step 1: Add the uuid dependency** - -Run: - -```bash -go get github.com/google/uuid@latest -``` - -Expected: `go.mod` / `go.sum` updated with `github.com/google/uuid`. - -- [ ] **Step 2: Write the failing domain tests** - -Create `internal/domain/domain_test.go`: - -```go -package domain - -import ( - "encoding/json" - "strings" - "testing" - "time" -) - -func TestNewManualRejectsEmptyNextAction(t *testing.T) { - _, err := NewManual("", "tests pass", 25*time.Minute) - if err != ErrMissingNextAction { - t.Fatalf("want ErrMissingNextAction, got %v", err) - } -} - -func TestNewManualRejectsEmptySuccessCondition(t *testing.T) { - _, err := NewManual("Refactor state", " ", 25*time.Minute) - if err != ErrMissingSuccessCondition { - t.Fatalf("want ErrMissingSuccessCondition, got %v", err) - } -} - -func TestNewManualRejectsZeroTimebox(t *testing.T) { - _, err := NewManual("Refactor state", "tests pass", 0) - if err != ErrMissingTimebox { - t.Fatalf("want ErrMissingTimebox, got %v", err) - } -} - -func TestNewManualPopulatesDraftCommitment(t *testing.T) { - c, err := NewManual(" Port domain ", "domain tests pass", 25*time.Minute) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if c.NextAction != "Port domain" { - t.Errorf("next action not trimmed: %q", c.NextAction) - } - if c.SuccessCondition != "domain tests pass" { - t.Errorf("success condition wrong: %q", c.SuccessCondition) - } - if c.TimeboxSecs != 1500 { - t.Errorf("timebox secs wrong: %d", c.TimeboxSecs) - } - if c.State != CommitmentDraft { - t.Errorf("state should be draft, got %s", c.State) - } - if c.Source != SourceManual { - t.Errorf("source should be manual, got %s", c.Source) - } - if !strings.HasPrefix(c.ID, "commitment-") { - t.Errorf("id should be prefixed, got %s", c.ID) - } -} - -func TestCommitmentSerializesSnakeCaseEnums(t *testing.T) { - c, _ := NewManual("Port domain", "domain tests pass", 25*time.Minute) - data, err := json.Marshal(c) - if err != nil { - t.Fatalf("marshal failed: %v", err) - } - s := string(data) - if !strings.Contains(s, `"source":"manual"`) { - t.Errorf("missing snake_case source in %s", s) - } - if !strings.Contains(s, `"state":"draft"`) { - t.Errorf("missing snake_case state in %s", s) - } -} -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: - -```bash -go test ./internal/domain/ -``` - -Expected: compile failure β€” `NewManual`, `ErrMissing*`, and the constants are undefined. - -- [ ] **Step 4: Implement the domain types** - -Create `internal/domain/domain.go`: - -```go -// Package domain holds the core commitment types and validation, ported from -// the original Rust implementation. These are pure data types with no I/O. -package domain - -import ( - "errors" - "strings" - "time" - - "github.com/google/uuid" -) - -const PolicySchemaVersion = 1 - -type CommitmentSource string - -const ( - SourceManual CommitmentSource = "manual" - SourcePlanner CommitmentSource = "planner" - SourceRecurring CommitmentSource = "recurring" - SourceRecovery CommitmentSource = "recovery" - SourceTemplate CommitmentSource = "template" -) - -type CommitmentState string - -const ( - CommitmentDraft CommitmentState = "draft" - CommitmentActive CommitmentState = "active" - CommitmentPaused CommitmentState = "paused" - CommitmentCompleted CommitmentState = "completed" - CommitmentAbandoned CommitmentState = "abandoned" - CommitmentViolated CommitmentState = "violated" -) - -type RuntimeState string - -const ( - RuntimeLocked RuntimeState = "locked" - RuntimePlanning RuntimeState = "planning" - RuntimeActive RuntimeState = "active" - RuntimeTransition RuntimeState = "transition" - RuntimeReview RuntimeState = "review" - RuntimeAdminOverride RuntimeState = "admin_override" -) - -type EnforcementLevel string - -const ( - EnforcementObserve EnforcementLevel = "observe" - EnforcementWarn EnforcementLevel = "warn" - EnforcementBlock EnforcementLevel = "block" - EnforcementLocked EnforcementLevel = "locked" -) - -// AllowedContext is carried by PolicySnapshot. Matching logic arrives in a -// later milestone; M0 only stores it. -type AllowedContext struct { - WindowClasses []string `json:"window_classes"` - WindowTitleSubstrings []string `json:"window_title_substrings"` - Domains []string `json:"domains"` - Repos []string `json:"repos"` - Commands []string `json:"commands"` -} - -type Commitment struct { - ID string `json:"id"` - CreatedAtUnixSecs int64 `json:"created_at_unix_secs"` - Source CommitmentSource `json:"source"` - NextAction string `json:"next_action"` - SuccessCondition string `json:"success_condition"` - TimeboxSecs int64 `json:"timebox_secs"` - State CommitmentState `json:"state"` -} - -type PolicySnapshot struct { - ID string `json:"id"` - CommitmentID string `json:"commitment_id"` - SchemaVersion int `json:"schema_version"` - CreatedAtUnixSecs int64 `json:"created_at_unix_secs"` - RuntimeState RuntimeState `json:"runtime_state"` - EnforcementLevel EnforcementLevel `json:"enforcement_level"` - AllowedContext AllowedContext `json:"allowed_context"` -} - -var ( - ErrMissingNextAction = errors.New("next action is required") - ErrMissingSuccessCondition = errors.New("success condition is required") - ErrMissingTimebox = errors.New("timebox must be nonzero") -) - -// NewManual builds a validated draft commitment from user input. -func NewManual(nextAction, successCondition string, timebox time.Duration) (Commitment, error) { - nextAction = strings.TrimSpace(nextAction) - successCondition = strings.TrimSpace(successCondition) - if nextAction == "" { - return Commitment{}, ErrMissingNextAction - } - if successCondition == "" { - return Commitment{}, ErrMissingSuccessCondition - } - if timebox <= 0 { - return Commitment{}, ErrMissingTimebox - } - return Commitment{ - ID: "commitment-" + uuid.Must(uuid.NewV7()).String(), - CreatedAtUnixSecs: time.Now().Unix(), - Source: SourceManual, - NextAction: nextAction, - SuccessCondition: successCondition, - TimeboxSecs: int64(timebox.Seconds()), - State: CommitmentDraft, - }, nil -} - -// ForRuntime builds a minimal accepted policy snapshot for a commitment. -func ForRuntime(commitmentID string, runtime RuntimeState, level EnforcementLevel) PolicySnapshot { - return PolicySnapshot{ - ID: "policy-" + uuid.Must(uuid.NewV7()).String(), - CommitmentID: commitmentID, - SchemaVersion: PolicySchemaVersion, - CreatedAtUnixSecs: time.Now().Unix(), - RuntimeState: runtime, - EnforcementLevel: level, - AllowedContext: AllowedContext{}, - } -} -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: - -```bash -go test ./internal/domain/ -``` - -Expected: PASS (all five tests). - -- [ ] **Step 6: Commit** - -```bash -git add internal/domain/ go.mod go.sum -git commit -m "Add domain types and validation" -``` - -## Task 3: State Machine - -**Files:** -- Create: `internal/statemachine/statemachine.go` -- Test: `internal/statemachine/statemachine_test.go` - -- [ ] **Step 1: Write the failing state-machine tests** - -Create `internal/statemachine/statemachine_test.go`: - -```go -package statemachine - -import ( - "testing" - - "antidrift/internal/domain" -) - -func TestPlanningActivatesOnlyWithAcceptedPolicy(t *testing.T) { - got, err := TransitionRuntime(domain.RuntimePlanning, ActivateAccepted) - if err != nil || got != domain.RuntimeActive { - t.Fatalf("accepted: got %s err %v", got, err) - } - _, err = TransitionRuntime(domain.RuntimePlanning, ActivateRejected) - if err == nil { - t.Fatalf("rejected policy should error") - } -} - -func TestActiveCanCompleteForReview(t *testing.T) { - got, err := TransitionRuntime(domain.RuntimeActive, CompleteForReview) - if err != nil || got != domain.RuntimeReview { - t.Fatalf("got %s err %v", got, err) - } -} - -func TestReviewEndsToLocked(t *testing.T) { - got, err := TransitionRuntime(domain.RuntimeReview, EndWorkPeriod) - if err != nil || got != domain.RuntimeLocked { - t.Fatalf("got %s err %v", got, err) - } -} - -func TestLockedCannotBecomeActiveDirectly(t *testing.T) { - _, err := TransitionRuntime(domain.RuntimeLocked, ActivateAccepted) - if err == nil { - t.Fatalf("locked->active must be illegal") - } -} - -func TestCommitmentDraftActivatesAndViolatedRecovers(t *testing.T) { - got, err := TransitionCommitment(domain.CommitmentDraft, CommitmentActivate) - if err != nil || got != domain.CommitmentActive { - t.Fatalf("draft->active: got %s err %v", got, err) - } - got, err = TransitionCommitment(domain.CommitmentViolated, RecoverExplicitly) - if err != nil || got != domain.CommitmentActive { - t.Fatalf("violated->active: got %s err %v", got, err) - } - _, err = TransitionCommitment(domain.CommitmentViolated, ReturnFromPause) - if err == nil { - t.Fatalf("violated->returnFromPause must be illegal") - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: - -```bash -go test ./internal/statemachine/ -``` - -Expected: compile failure β€” transition functions and action constants are undefined. - -- [ ] **Step 3: Implement the state machine** - -Create `internal/statemachine/statemachine.go`: - -```go -// Package statemachine holds the pure runtime and commitment transition -// functions ported from the Rust implementation. No I/O, no shared state. -package statemachine - -import ( - "fmt" - - "antidrift/internal/domain" -) - -// RuntimeAction enumerates runtime transitions. Activate's policy acceptance -// and admin-override exit targets from the Rust enum are flattened into -// distinct actions so the action type stays a simple value. -type RuntimeAction string - -const ( - EnterPlanning RuntimeAction = "enter_planning" - CancelOrTimeout RuntimeAction = "cancel_or_timeout" - ActivateAccepted RuntimeAction = "activate_accepted" - ActivateRejected RuntimeAction = "activate_rejected" - StartTransition RuntimeAction = "start_transition" - CompleteForReview RuntimeAction = "complete_for_review" - SevereViolation RuntimeAction = "severe_violation" - ReturnToActive RuntimeAction = "return_to_active" - SwitchTask RuntimeAction = "switch_task" - EndTransitionForReview RuntimeAction = "end_transition_for_review" - ContinuePlanning RuntimeAction = "continue_planning" - EndWorkPeriod RuntimeAction = "end_work_period" - EnterAdminOverride RuntimeAction = "enter_admin_override" - ExitAdminOverrideToLocked RuntimeAction = "exit_admin_override_to_locked" - ExitAdminOverrideToPlanning RuntimeAction = "exit_admin_override_to_planning" -) - -type CommitmentAction string - -const ( - CommitmentActivate CommitmentAction = "activate" - PauseForTransition CommitmentAction = "pause_for_transition" - ReturnFromPause CommitmentAction = "return_from_pause" - Complete CommitmentAction = "complete" - Abandon CommitmentAction = "abandon" - Violate CommitmentAction = "violate" - RecoverExplicitly CommitmentAction = "recover_explicitly" -) - -// IllegalTransitionError reports a rejected transition. -type IllegalTransitionError struct { - From string - Action string -} - -func (e IllegalTransitionError) Error() string { - return fmt.Sprintf("illegal transition from %s via %s", e.From, e.Action) -} - -// PolicyRejectedError reports an activation attempted with an unaccepted policy. -type PolicyRejectedError struct{} - -func (PolicyRejectedError) Error() string { return "policy was rejected" } - -func TransitionRuntime(current domain.RuntimeState, action RuntimeAction) (domain.RuntimeState, error) { - type key struct { - s domain.RuntimeState - a RuntimeAction - } - table := map[key]domain.RuntimeState{ - {domain.RuntimeLocked, EnterPlanning}: domain.RuntimePlanning, - {domain.RuntimePlanning, ActivateAccepted}: domain.RuntimeActive, - {domain.RuntimePlanning, CancelOrTimeout}: domain.RuntimeLocked, - {domain.RuntimeActive, StartTransition}: domain.RuntimeTransition, - {domain.RuntimeActive, CompleteForReview}: domain.RuntimeReview, - {domain.RuntimeActive, SevereViolation}: domain.RuntimeLocked, - {domain.RuntimeTransition, ReturnToActive}: domain.RuntimeActive, - {domain.RuntimeTransition, SwitchTask}: domain.RuntimePlanning, - {domain.RuntimeTransition, EndTransitionForReview}: domain.RuntimeReview, - {domain.RuntimeTransition, CancelOrTimeout}: domain.RuntimeLocked, - {domain.RuntimeReview, ContinuePlanning}: domain.RuntimePlanning, - {domain.RuntimeReview, EndWorkPeriod}: domain.RuntimeLocked, - {domain.RuntimeAdminOverride, ExitAdminOverrideToLocked}: domain.RuntimeLocked, - {domain.RuntimeAdminOverride, ExitAdminOverrideToPlanning}: domain.RuntimePlanning, - } - - if action == ActivateRejected && current == domain.RuntimePlanning { - return current, PolicyRejectedError{} - } - if action == EnterAdminOverride { - return domain.RuntimeAdminOverride, nil - } - if next, ok := table[key{current, action}]; ok { - return next, nil - } - return current, IllegalTransitionError{From: string(current), Action: string(action)} -} - -func TransitionCommitment(current domain.CommitmentState, action CommitmentAction) (domain.CommitmentState, error) { - type key struct { - s domain.CommitmentState - a CommitmentAction - } - table := map[key]domain.CommitmentState{ - {domain.CommitmentDraft, CommitmentActivate}: domain.CommitmentActive, - {domain.CommitmentActive, PauseForTransition}: domain.CommitmentPaused, - {domain.CommitmentPaused, ReturnFromPause}: domain.CommitmentActive, - {domain.CommitmentActive, Complete}: domain.CommitmentCompleted, - {domain.CommitmentActive, Abandon}: domain.CommitmentAbandoned, - {domain.CommitmentActive, Violate}: domain.CommitmentViolated, - {domain.CommitmentPaused, Abandon}: domain.CommitmentAbandoned, - {domain.CommitmentViolated, RecoverExplicitly}: domain.CommitmentActive, - {domain.CommitmentViolated, Abandon}: domain.CommitmentAbandoned, - } - if next, ok := table[key{current, action}]; ok { - return next, nil - } - return current, IllegalTransitionError{From: string(current), Action: string(action)} -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: - -```bash -go test ./internal/statemachine/ -``` - -Expected: PASS (all transition tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/statemachine/ -git commit -m "Add runtime and commitment state machines" -``` - -## Task 4: Snapshot Store - -**Files:** -- Create: `internal/store/store.go` -- Test: `internal/store/store_test.go` - -- [ ] **Step 1: Write the failing store tests** - -Create `internal/store/store_test.go`: - -```go -package store - -import ( - "path/filepath" - "testing" - - "antidrift/internal/domain" -) - -func TestLoadMissingFileReturnsLocked(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.json") - s, err := Load(path) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if s.RuntimeState != domain.RuntimeLocked { - t.Errorf("missing file should be Locked, got %s", s.RuntimeState) - } - if s.Commitment != nil { - t.Errorf("missing file should have nil commitment") - } -} - -func TestSaveThenLoadRoundTrips(t *testing.T) { - path := filepath.Join(t.TempDir(), "nested", "state.json") - c, _ := domain.NewManual("Port store", "store tests pass", 1500) - c.State = domain.CommitmentActive - want := Snapshot{ - RuntimeState: domain.RuntimeActive, - Commitment: &c, - DeadlineUnixSecs: 1748725200, - } - if err := Save(path, want); err != nil { - t.Fatalf("save failed: %v", err) - } - got, err := Load(path) - if err != nil { - t.Fatalf("load failed: %v", err) - } - if got.RuntimeState != want.RuntimeState { - t.Errorf("runtime state: got %s want %s", got.RuntimeState, want.RuntimeState) - } - if got.Commitment == nil || got.Commitment.NextAction != "Port store" { - t.Errorf("commitment did not round-trip: %+v", got.Commitment) - } - if got.DeadlineUnixSecs != want.DeadlineUnixSecs { - t.Errorf("deadline: got %d want %d", got.DeadlineUnixSecs, want.DeadlineUnixSecs) - } -} -``` - -Note: `domain.NewManual` takes a `time.Duration`; `1500` here is 1500 nanoseconds but the value is irrelevant to the store test because the test overrides `State` and only reads `NextAction`. To avoid a lint/type concern, call it as `domain.NewManual("Port store", "store tests pass", 25*60*1e9)` is wrong; instead import `time` and pass `25*time.Minute`. Use this exact call in the test: `domain.NewManual("Port store", "store tests pass", 25*time.Minute)` and add `"time"` to the imports. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: - -```bash -go test ./internal/store/ -``` - -Expected: compile failure β€” `Snapshot`, `Load`, `Save` undefined. - -- [ ] **Step 3: Implement the store** - -Create `internal/store/store.go`: - -```go -// Package store persists and restores the daemon's current state as a single -// JSON snapshot. It is the M0 source of durability; the hash-chained audit log -// arrives in a later milestone. -package store - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - - "antidrift/internal/domain" -) - -// Snapshot is the persisted current state of the daemon. -type Snapshot struct { - RuntimeState domain.RuntimeState `json:"runtime_state"` - Commitment *domain.Commitment `json:"commitment,omitempty"` - DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"` -} - -// DefaultPath returns ~/.antidrift/state.json. -func DefaultPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".antidrift", "state.json"), nil -} - -// Load reads a snapshot. A missing file yields a default Locked snapshot. -func Load(path string) (Snapshot, error) { - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return Snapshot{RuntimeState: domain.RuntimeLocked}, nil - } - if err != nil { - return Snapshot{}, err - } - var s Snapshot - if err := json.Unmarshal(data, &s); err != nil { - return Snapshot{}, err - } - return s, nil -} - -// Save writes a snapshot atomically (temp file + rename), creating the -// directory if needed. -func Save(path string, s Snapshot) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, path) -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: - -```bash -go test ./internal/store/ -``` - -Expected: PASS (both tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/store/ -git commit -m "Add snapshot store" -``` - -## Task 5: Session Controller - -**Files:** -- Create: `internal/session/session.go` -- Test: `internal/session/session_test.go` - -- [ ] **Step 1: Write the failing session tests** - -Create `internal/session/session_test.go`: - -```go -package session - -import ( - "path/filepath" - "testing" - "time" - - "antidrift/internal/domain" -) - -func newTestController(t *testing.T) (*Controller, string) { - t.Helper() - path := filepath.Join(t.TempDir(), "state.json") - c, err := New(path) - if err != nil { - t.Fatalf("new controller: %v", err) - } - return c, path -} - -func TestHappyPathDrivesStates(t *testing.T) { - c, _ := newTestController(t) - if c.State().RuntimeState != domain.RuntimeLocked { - t.Fatalf("should start Locked") - } - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute); err != nil { - t.Fatalf("start commitment: %v", err) - } - st := c.State() - if st.RuntimeState != domain.RuntimeActive { - t.Fatalf("should be Active, got %s", st.RuntimeState) - } - if st.Commitment == nil || st.Commitment.NextAction != "Port session" { - t.Fatalf("active commitment missing: %+v", st.Commitment) - } - if st.Commitment.DeadlineUnixSecs <= time.Now().Unix() { - t.Fatalf("deadline should be in the future") - } - if err := c.Complete(); err != nil { - t.Fatalf("complete: %v", err) - } - if c.State().RuntimeState != domain.RuntimeReview { - t.Fatalf("should be Review after complete") - } - if err := c.End(); err != nil { - t.Fatalf("end: %v", err) - } - if c.State().RuntimeState != domain.RuntimeLocked { - t.Fatalf("should be Locked after end") - } -} - -func TestStartCommitmentRejectsInvalidInput(t *testing.T) { - c, _ := newTestController(t) - _ = c.EnterPlanning() - if err := c.StartManualCommitment("", "x", 25*time.Minute); err == nil { - t.Fatalf("empty next action should error") - } - if c.State().RuntimeState != domain.RuntimePlanning { - t.Fatalf("failed start must not change state, got %s", c.State().RuntimeState) - } -} - -func TestStateRestoresFromSnapshot(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.json") - first, err := New(path) - if err != nil { - t.Fatalf("new: %v", err) - } - _ = first.EnterPlanning() - _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute) - - second, err := New(path) - if err != nil { - t.Fatalf("reopen: %v", err) - } - st := second.State() - if st.RuntimeState != domain.RuntimeActive { - t.Fatalf("restored state should be Active, got %s", st.RuntimeState) - } - if st.Commitment == nil || st.Commitment.NextAction != "Persisted action" { - t.Fatalf("restored commitment missing: %+v", st.Commitment) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: - -```bash -go test ./internal/session/ -``` - -Expected: compile failure β€” `Controller`, `New`, and methods undefined. - -- [ ] **Step 3: Implement the session controller** - -Create `internal/session/session.go`: - -```go -// Package session owns the daemon's in-memory state of truth and persists a -// snapshot on every change. Transitions go through the pure statemachine. -package session - -import ( - "sync" - "time" - - "antidrift/internal/domain" - "antidrift/internal/statemachine" - "antidrift/internal/store" -) - -// Controller holds runtime state and the active commitment behind a mutex. -type Controller struct { - mu sync.Mutex - runtimeState domain.RuntimeState - commitment *domain.Commitment - deadline time.Time - snapshotPath string -} - -// CommitmentView is the UI projection of the active commitment. -type CommitmentView struct { - NextAction string `json:"next_action"` - SuccessCondition string `json:"success_condition"` - TimeboxSecs int64 `json:"timebox_secs"` - DeadlineUnixSecs int64 `json:"deadline_unix_secs"` -} - -// State is the broadcastable view of the controller. -type State struct { - RuntimeState domain.RuntimeState `json:"runtime_state"` - Commitment *CommitmentView `json:"commitment"` -} - -// New loads any persisted snapshot, or starts Locked. -func New(snapshotPath string) (*Controller, error) { - s, err := store.Load(snapshotPath) - if err != nil { - return nil, err - } - c := &Controller{ - runtimeState: s.RuntimeState, - commitment: s.Commitment, - snapshotPath: snapshotPath, - } - if s.DeadlineUnixSecs > 0 { - c.deadline = time.Unix(s.DeadlineUnixSecs, 0) - } - if c.runtimeState == "" { - c.runtimeState = domain.RuntimeLocked - } - return c, nil -} - -// State returns the current broadcastable state. Safe for concurrent use. -func (c *Controller) State() State { - c.mu.Lock() - defer c.mu.Unlock() - return c.stateLocked() -} - -// Deadline returns the active commitment deadline, or the zero time. -func (c *Controller) Deadline() time.Time { - c.mu.Lock() - defer c.mu.Unlock() - return c.deadline -} - -func (c *Controller) stateLocked() State { - st := State{RuntimeState: c.runtimeState} - if c.commitment != nil { - st.Commitment = &CommitmentView{ - NextAction: c.commitment.NextAction, - SuccessCondition: c.commitment.SuccessCondition, - TimeboxSecs: c.commitment.TimeboxSecs, - DeadlineUnixSecs: c.deadline.Unix(), - } - } - return st -} - -func (c *Controller) persistLocked() error { - snap := store.Snapshot{RuntimeState: c.runtimeState, Commitment: c.commitment} - if !c.deadline.IsZero() { - snap.DeadlineUnixSecs = c.deadline.Unix() - } - return store.Save(c.snapshotPath, snap) -} - -// EnterPlanning moves Locked -> Planning. -func (c *Controller) EnterPlanning() error { - c.mu.Lock() - defer c.mu.Unlock() - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning) - if err != nil { - return err - } - c.runtimeState = next - return c.persistLocked() -} - -// StartManualCommitment validates input, activates a new commitment, and moves -// Planning -> Active. -func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error { - c.mu.Lock() - defer c.mu.Unlock() - commitment, err := domain.NewManual(nextAction, successCondition, timebox) - if err != nil { - return err - } - commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate) - if err != nil { - return err - } - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted) - if err != nil { - return err - } - c.runtimeState = next - c.commitment = &commitment - c.deadline = time.Now().Add(timebox) - return c.persistLocked() -} - -// Complete moves Active -> Review and marks the commitment completed. -func (c *Controller) Complete() error { - c.mu.Lock() - defer c.mu.Unlock() - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) - if err != nil { - return err - } - c.runtimeState = next - if c.commitment != nil { - if s, e := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete); e == nil { - c.commitment.State = s - } - } - return c.persistLocked() -} - -// End moves Review -> Locked and clears the commitment. -func (c *Controller) End() error { - c.mu.Lock() - defer c.mu.Unlock() - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod) - if err != nil { - return err - } - c.runtimeState = next - c.commitment = nil - c.deadline = time.Time{} - return c.persistLocked() -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: - -```bash -go test ./internal/session/ -``` - -Expected: PASS (all three tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/session/ -git commit -m "Add session controller with snapshot persistence" -``` - -## Task 6: SSE Broadcaster - -**Files:** -- Create: `internal/web/broadcaster.go` -- Test: `internal/web/broadcaster_test.go` - -- [ ] **Step 1: Write the failing broadcaster test** - -Create `internal/web/broadcaster_test.go`: - -```go -package web - -import ( - "testing" - "time" -) - -func TestBroadcasterDeliversToSubscribers(t *testing.T) { - b := NewBroadcaster() - ch := b.Subscribe() - defer b.Unsubscribe(ch) - - b.Publish("hello") - - select { - case msg := <-ch: - if msg != "hello" { - t.Fatalf("got %q want hello", msg) - } - case <-time.After(time.Second): - t.Fatal("subscriber did not receive message") - } -} - -func TestBroadcasterDropsWhenSubscriberFull(t *testing.T) { - b := NewBroadcaster() - ch := b.Subscribe() - defer b.Unsubscribe(ch) - // Publishing many messages without reading must not block (slow client). - done := make(chan struct{}) - go func() { - for i := 0; i < 100; i++ { - b.Publish("x") - } - close(done) - }() - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("Publish blocked on a full subscriber") - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: - -```bash -go test ./internal/web/ -``` - -Expected: compile failure β€” `NewBroadcaster` undefined. - -- [ ] **Step 3: Implement the broadcaster** - -Create `internal/web/broadcaster.go`: - -```go -package web - -import "sync" - -// Broadcaster fans a string message out to all SSE subscribers. Slow -// subscribers drop messages rather than blocking publishers. -type Broadcaster struct { - mu sync.Mutex - subs map[chan string]struct{} -} - -func NewBroadcaster() *Broadcaster { - return &Broadcaster{subs: make(map[chan string]struct{})} -} - -func (b *Broadcaster) Subscribe() chan string { - ch := make(chan string, 8) - b.mu.Lock() - b.subs[ch] = struct{}{} - b.mu.Unlock() - return ch -} - -func (b *Broadcaster) Unsubscribe(ch chan string) { - b.mu.Lock() - if _, ok := b.subs[ch]; ok { - delete(b.subs, ch) - close(ch) - } - b.mu.Unlock() -} - -func (b *Broadcaster) Publish(msg string) { - b.mu.Lock() - defer b.mu.Unlock() - for ch := range b.subs { - select { - case ch <- msg: - default: // drop for slow subscriber - } - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: - -```bash -go test ./internal/web/ -run TestBroadcaster -``` - -Expected: PASS (both broadcaster tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/web/broadcaster.go internal/web/broadcaster_test.go -git commit -m "Add SSE broadcaster" -``` - -## Task 7: Web Server And Handlers - -**Files:** -- Create: `internal/web/web.go` -- Create: `internal/web/static/index.html` -- Test: `internal/web/web_test.go` - -- [ ] **Step 1: Create the static UI placeholder so embedding compiles** - -Create `internal/web/static/index.html` with a minimal valid page (it is replaced with the real UI in Step 7): - -```html -AntiDrift -``` - -- [ ] **Step 2: Write the failing web tests** - -Create `internal/web/web_test.go`: - -```go -package web - -import ( - "net/http" - "net/http/httptest" - "path/filepath" - "strings" - "testing" - - "antidrift/internal/domain" - "antidrift/internal/session" - - "github.com/gin-gonic/gin" -) - -func newTestServer(t *testing.T) *Server { - t.Helper() - gin.SetMode(gin.TestMode) - path := filepath.Join(t.TempDir(), "state.json") - ctrl, err := session.New(path) - if err != nil { - t.Fatalf("controller: %v", err) - } - return NewServer(ctrl) -} - -func post(t *testing.T, r http.Handler, path, body string) *httptest.ResponseRecorder { - t.Helper() - req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - return w -} - -func TestPlanningThenCommitmentReachesActive(t *testing.T) { - s := newTestServer(t) - r := s.Router() - - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { - t.Fatalf("/planning code %d", w.Code) - } - body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { - t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) - } - if s.ctrl.State().RuntimeState != domain.RuntimeActive { - t.Fatalf("expected Active, got %s", s.ctrl.State().RuntimeState) - } -} - -func TestCommitmentRejectsInvalidInput(t *testing.T) { - s := newTestServer(t) - r := s.Router() - _ = post(t, r, "/planning", "") - body := `{"next_action":"","success_condition":"x","timebox_secs":1500}` - w := post(t, r, "/commitment", body) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } -} - -func TestIllegalTransitionReturns409(t *testing.T) { - s := newTestServer(t) - r := s.Router() - // /complete from Locked is illegal. - w := post(t, r, "/complete", "") - if w.Code != http.StatusConflict { - t.Fatalf("expected 409, got %d", w.Code) - } -} -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: - -```bash -go test ./internal/web/ -run 'Test(Planning|Commitment|Illegal)' -``` - -Expected: compile failure β€” `Server`, `NewServer`, `Router` undefined. - -- [ ] **Step 4: Add the gin dependency** - -Run: - -```bash -go get github.com/gin-gonic/gin@latest -``` - -Expected: `go.mod` / `go.sum` updated. - -- [ ] **Step 5: Implement the server, handlers, and expiry timer** - -Create `internal/web/web.go`: - -```go -// Package web serves the local UI and an SSE state stream, and owns the -// server-authoritative timebox expiry timer. -package web - -import ( - "embed" - "encoding/json" - "errors" - "fmt" - "io/fs" - "net/http" - "sync" - "time" - - "antidrift/internal/session" - "antidrift/internal/statemachine" - - "github.com/gin-gonic/gin" -) - -//go:embed static/* -var staticFS embed.FS - -// Server wires the session controller to HTTP and SSE. -type Server struct { - ctrl *session.Controller - bcast *Broadcaster - - mu sync.Mutex - timer *time.Timer -} - -func NewServer(ctrl *session.Controller) *Server { - return &Server{ctrl: ctrl, bcast: NewBroadcaster()} -} - -func (s *Server) Router() *gin.Engine { - r := gin.New() - r.Use(gin.Recovery()) - - sub, _ := fs.Sub(staticFS, "static") - r.GET("/", func(c *gin.Context) { - c.FileFromFS("/", http.FS(sub)) - }) - r.GET("/events", s.handleEvents) - r.POST("/planning", s.handlePlanning) - r.POST("/commitment", s.handleCommitment) - r.POST("/complete", s.handleComplete) - r.POST("/end", s.handleEnd) - return r -} - -func (s *Server) stateJSON() string { - data, _ := json.Marshal(s.ctrl.State()) - return string(data) -} - -func (s *Server) broadcast() { - s.bcast.Publish(s.stateJSON()) -} - -// respond maps a controller error to an HTTP status, otherwise broadcasts and -// returns the new state. -func (s *Server) respond(c *gin.Context, err error) { - if err != nil { - var illegal statemachine.IllegalTransitionError - if errors.As(err, &illegal) { - c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - s.broadcast() - c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) -} - -func (s *Server) handlePlanning(c *gin.Context) { - s.cancelExpiry() - s.respond(c, s.ctrl.EnterPlanning()) -} - -type commitmentRequest struct { - NextAction string `json:"next_action"` - SuccessCondition string `json:"success_condition"` - TimeboxSecs int64 `json:"timebox_secs"` -} - -func (s *Server) handleCommitment(c *gin.Context) { - var req commitmentRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) - return - } - err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second) - if err == nil { - s.armExpiry() - } - s.respond(c, err) -} - -func (s *Server) handleComplete(c *gin.Context) { - s.cancelExpiry() - s.respond(c, s.ctrl.Complete()) -} - -func (s *Server) handleEnd(c *gin.Context) { - s.respond(c, s.ctrl.End()) -} - -func (s *Server) handleEvents(c *gin.Context) { - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - c.Status(http.StatusInternalServerError) - return - } - ch := s.bcast.Subscribe() - defer s.bcast.Unsubscribe(ch) - - fmt.Fprintf(c.Writer, "data: %s\n\n", s.stateJSON()) - flusher.Flush() - - for { - select { - case <-c.Request.Context().Done(): - return - case msg, open := <-ch: - if !open { - return - } - fmt.Fprintf(c.Writer, "data: %s\n\n", msg) - flusher.Flush() - } - } -} - -// armExpiry schedules an Active -> Review transition at the commitment deadline. -func (s *Server) armExpiry() { - deadline := s.ctrl.Deadline() - if deadline.IsZero() { - return - } - s.mu.Lock() - defer s.mu.Unlock() - if s.timer != nil { - s.timer.Stop() - } - d := time.Until(deadline) - if d < 0 { - d = 0 - } - s.timer = time.AfterFunc(d, func() { - if err := s.ctrl.Complete(); err == nil { - s.broadcast() - } - }) -} - -func (s *Server) cancelExpiry() { - s.mu.Lock() - defer s.mu.Unlock() - if s.timer != nil { - s.timer.Stop() - s.timer = nil - } -} - -// Init re-arms or expires a restored Active session at startup. -func (s *Server) Init() { - st := s.ctrl.State() - if st.RuntimeState != "active" { - return - } - if s.ctrl.Deadline().After(time.Now()) { - s.armExpiry() - return - } - if err := s.ctrl.Complete(); err == nil { - s.broadcast() - } -} -``` - -- [ ] **Step 6: Run the web tests to verify they pass** - -Run: - -```bash -go test ./internal/web/ -``` - -Expected: PASS (broadcaster tests + the three handler tests). - -- [ ] **Step 7: Replace the static placeholder with the real UI** - -Overwrite `internal/web/static/index.html`: - -```html - - - - - -AntiDrift - - - -
-

AntiDrift

-
connecting…
-
- - - -``` - -- [ ] **Step 8: Verify everything still builds and tests pass** - -Run: - -```bash -go test ./... -``` - -Expected: PASS across all packages. - -- [ ] **Step 9: Commit** - -```bash -git add internal/web/ go.mod go.sum -git commit -m "Add web server, SSE handlers, and UI" -``` - -## Task 8: Daemon Wiring - -**Files:** -- Modify: `cmd/antidriftd/main.go` - -- [ ] **Step 1: Implement the daemon entrypoint** - -Replace `cmd/antidriftd/main.go`: - -```go -// Command antidriftd is the AntiDrift focus daemon: it serves a local web UI -// and owns the commitment state machine. -package main - -import ( - "log" - "os/exec" - "runtime" - "time" - - "antidrift/internal/session" - "antidrift/internal/store" - "antidrift/internal/web" -) - -const addr = "localhost:7777" - -func main() { - path, err := store.DefaultPath() - if err != nil { - log.Fatalf("resolve snapshot path: %v", err) - } - ctrl, err := session.New(path) - if err != nil { - log.Fatalf("load session: %v", err) - } - - srv := web.NewServer(ctrl) - srv.Init() // re-arm or expire a restored Active session - - go openBrowser("http://" + addr) - - log.Printf("antidriftd listening on http://%s", addr) - if err := srv.Router().Run(addr); err != nil { - log.Fatalf("server: %v", err) - } -} - -// openBrowser best-effort launches the default browser after a short delay so -// the server is listening first. Failures are logged, not fatal. -func openBrowser(url string) { - time.Sleep(300 * time.Millisecond) - var cmd string - var args []string - switch runtime.GOOS { - case "linux": - cmd, args = "xdg-open", []string{url} - case "darwin": - cmd, args = "open", []string{url} - case "windows": - cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler", url} - default: - log.Printf("open browser manually: %s", url) - return - } - if err := exec.Command(cmd, args...).Start(); err != nil { - log.Printf("could not open browser (%v); visit %s", err, url) - } -} -``` - -- [ ] **Step 2: Build the daemon** - -Run: - -```bash -go build ./... -``` - -Expected: builds with no output. - -- [ ] **Step 3: Manual smoke test** - -Run: - -```bash -go run ./cmd/antidriftd -``` - -Expected: - -- log line `antidriftd listening on http://localhost:7777`; -- a browser opens to the page showing the Locked view; -- click "Start planning" β†’ fill next action, success condition, minutes β†’ "Start commitment" β†’ the Active view shows a counting-down timer; -- click "Complete" β†’ Review view β†’ "End" β†’ back to Locked; -- start a short commitment (1 minute) and let it run out β†’ it flips to Review automatically. - -Stop with Ctrl-C. - -- [ ] **Step 4: Verify restart restores state** - -Run: - -```bash -go run ./cmd/antidriftd -``` - -Start a commitment, note the action, then Ctrl-C and re-run: - -```bash -go run ./cmd/antidriftd -``` - -Expected: the Active view returns with the same next action and a still-counting timer (or it shows Review if the timebox elapsed while stopped). Confirm `~/.antidrift/state.json` exists. - -- [ ] **Step 5: Commit** - -```bash -git add cmd/antidriftd/main.go -git commit -m "Wire antidriftd daemon entrypoint" -``` - -## Task 9: Final Verification And README - -**Files:** -- Modify: `README.md` - -- [ ] **Step 1: Update the README** - -Replace the body of `README.md` with: - -```markdown -# AntiDrift - -A personal focus operating system: treat each work session as an explicit -commitment (next action, success condition, timebox), and make drift visible. - -This is the Go reimagining. The original Rust implementation is preserved under -`legacy/` for reference. See `docs/superpowers/specs/` for the design. - -## Run - -```bash -go run ./cmd/antidriftd -``` - -The daemon serves a local web UI at http://localhost:7777 and opens your -browser. State is persisted to `~/.antidrift/state.json`. - -## Test - -```bash -go test ./... -``` - -## Status - -M0 (walking skeleton): manual commitment lifecycle over a local web UI with -snapshot persistence. AI integration, active-window tracking, and the -tamper-evident audit log arrive in later milestones (see the roadmap in -`docs/superpowers/specs/2026-05-31-go-focus-os-design.md`). -``` - -- [ ] **Step 2: Run the full test suite** - -Run: - -```bash -go test ./... -``` - -Expected: PASS across `internal/domain`, `internal/statemachine`, `internal/store`, `internal/session`, `internal/web`. - -- [ ] **Step 3: Vet the code** - -Run: - -```bash -go vet ./... -``` - -Expected: no output (no issues). - -- [ ] **Step 4: Commit** - -```bash -git add README.md -git commit -m "Document M0 walking skeleton" -``` - -## Self-Review Notes - -- **Spec coverage:** domain port (Task 2), statemachine port (Task 3), snapshot store (Task 4), session controller with persistence (Task 5), SSE (Task 6), Gin server + HTTP surface + server-authoritative expiry + UI (Task 7), daemon with browser-open and restart restore (Task 8), repo/module setup with Rust moved to `legacy/` (Task 1). All M0 spec sections map to a task. -- **HTTP surface:** `/`, `/events`, `/planning`, `/commitment`, `/complete`, `/end` all implemented; invalid input β†’ 400, illegal transition β†’ 409, as specified. -- **State flow:** Locked β†’ Planning β†’ Active β†’ Review β†’ Locked exercised through the ported pure transition functions; full enums present for later milestones. -- **Out of scope honored:** no AI, no window tracking, no audit log, no allowed-context matching. -- **Type consistency:** `Controller`, `State`, `CommitmentView`, `Snapshot`, `Server`, `Broadcaster`, and the `statemachine` action constants (`CommitmentActivate`, `ActivateAccepted`, `CompleteForReview`, `EndWorkPeriod`) are referenced consistently across Tasks 3, 5, and 7. -``` diff --git a/docs/superpowers/plans/2026-05-31-m1-evidence-audit.md b/docs/superpowers/plans/2026-05-31-m1-evidence-audit.md deleted file mode 100644 index 4b86ff4..0000000 --- a/docs/superpowers/plans/2026-05-31-m1-evidence-audit.md +++ /dev/null @@ -1,1744 +0,0 @@ -# M1 β€” Evidence & Audit Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Give the daemon an active-window sensor, a two-tier evidence store (disposable per-session raw log + permanent hash-chained session summaries), live SSE updates of what the user is doing, and crash-recovery of in-session stats. - -**Architecture:** A new `evidence` port (`Source` interface + X11/xgbutil adapter + types + `ScrubTitle`) pushes `WindowSnapshot`s into `session.Controller`, which accumulates per-bucket time while `Active`, persists every focus change to a raw per-session JSONL, and seals each finished session into `audit.jsonl` as one SHA-256-chained `SessionSummary`. The controller gets an injectable clock and rebuilds stats from the raw log on restart. `web` surfaces the evidence projection over the existing SSE stream. - -**Tech Stack:** Go 1.26.3, Gin, `github.com/google/uuid` (UUIDv7), `github.com/jezek/xgb` + `github.com/jezek/xgbutil` (pure-Go X11), `crypto/sha256`, `encoding/json`. - ---- - -## Design decisions locked in (read before starting) - -These resolve ambiguities in the spec; implement exactly as written. - -1. **`onChange` fires only from `RecordWindow`.** It is the one state-change path that originates outside an HTTP handler (the evidence goroutine). HTTP user actions continue to broadcast via the existing `web.respond()`; the expiry timer keeps its explicit `broadcast()` after `Expire()`. This avoids a lock-reentry deadlock (`onChange β†’ broadcast β†’ ctrl.State() β†’ c.mu`). `RecordWindow` always releases the mutex *before* calling `notify()`. -2. **A seed `FocusEvent` is appended at session start.** `StartManualCommitment` writes one `FocusEvent` for the current window at the start instant, so the raw log alone fully reconstructs stats on crash (session start time = first event's timestamp). One private `applyEvent` routine serves both live tracking and replay. -3. **The injectable clock** (`c.clock func() time.Time`, default `time.Now`) is used for *every* timestamp and duration in the controller (deadline, `StartedUnix`, segment accumulation, `EndedUnix`). Tests set it via `SetClock`. -4. **Outcome ("completed" vs "expired")** is tracked by `c.outcomePending`, set when entering Review: user `Complete()` β†’ `"completed"`, timer/startup `Expire()` β†’ `"expired"`. The underlying commitment transition is identical for both (`statemachine.Complete`); only the audit `Outcome` field differs. Changing the commitment state machine is out of M1 scope. -5. **Store paths** are derived from the snapshot path's directory: `audit.jsonl` and `sessions/` live beside `state.json`. `New(path)`'s signature is unchanged; tests get isolation for free via `t.TempDir()`. - ---- - -## File Structure - -- Create `internal/evidence/evidence.go` β€” `EvidenceHealth`, `WindowSnapshot`, `Source`, `ScrubTitle`. -- Create `internal/evidence/evidence_test.go` β€” `ScrubTitle` table tests + a fake `Source`. -- Create `internal/evidence/x11.go` β€” `x11Source` + `NewSource()` (`//go:build linux`). -- Create `internal/evidence/source_other.go` β€” `NewSource()` fallback (`//go:build !linux`). -- Create `internal/evidence/x11_integration_test.go` β€” DISPLAY-gated live smoke test (`//go:build linux`). -- Create `internal/store/audit.go` β€” `BucketTotal`, `SessionSummary`, `AppendSession`, `VerifyChain`. -- Create `internal/store/audit_test.go`. -- Create `internal/store/evidence_log.go` β€” `FocusEvent`, `AppendFocus`, `ReplaySession`, `PruneOlderThan`. -- Create `internal/store/evidence_log_test.go`. -- Modify `internal/store/store.go` β€” `Snapshot` gains `SessionID`, `OutcomePending`. -- Modify `internal/session/session.go` β€” clock, stats, `RecordWindow`, lifecycle hooks, `SetOnChange`, recovery, evidence projection in `State`. -- Modify `internal/session/session_test.go` β€” accumulation / replay / audit-at-End tests. -- Modify `internal/web/web.go` β€” register `onChange`, switch timer + Init to `Expire()`. -- Modify `internal/web/web_test.go` β€” evidence payload test. -- Modify `internal/web/static/index.html` β€” render evidence in Active + Review views. -- Modify `cmd/antidriftd/main.go` β€” construct `evidence.NewSource()`, start `Watch` goroutine wired to `ctrl.RecordWindow`. -- Modify `go.mod` / `go.sum` β€” add `github.com/jezek/xgb`, `github.com/jezek/xgbutil`. - ---- - -## Task 1: Add X11 dependencies - -**Files:** -- Modify: `go.mod`, `go.sum` - -- [ ] **Step 1: Fetch the dependencies** - -Run: -```bash -go get github.com/jezek/xgbutil@latest github.com/jezek/xgb@latest -``` -Expected: `go.mod` gains `github.com/jezek/xgbutil` and `github.com/jezek/xgb` lines (xgb may be indirect until Task 6 imports it). - -- [ ] **Step 2: Verify the module still builds** - -Run: `go build ./...` -Expected: success, no output. - -- [ ] **Step 3: Commit** - -```bash -git add go.mod go.sum -git commit -m "M1: add jezek/xgbutil + xgb dependencies" -``` - ---- - -## Task 2: `evidence` types and `ScrubTitle` - -**Files:** -- Create: `internal/evidence/evidence.go` -- Test: `internal/evidence/evidence_test.go` - -- [ ] **Step 1: Write the failing tests** - -`internal/evidence/evidence_test.go`: -```go -package evidence - -import ( - "context" - "testing" -) - -func TestScrubTitle(t *testing.T) { - cases := []struct{ in, want string }{ - {"Plain title", "Plain title"}, // no digits-with-separator: untouched - {"Buy 2 eggs", "Buy 2 eggs"}, // bare integer: untouched (group is mandatory) - {"12.5%", ""}, // percent decimal: stripped whole - {"1:23:45 remaining", " remaining"}, // clock ratio: stripped - {"-3.0 delta", " delta"}, // leading sign + decimal: stripped - {"Download 50.5% complete", "Download complete"}, // embedded percent decimal - } - for _, c := range cases { - if got := ScrubTitle(c.in); got != c.want { - t.Errorf("ScrubTitle(%q) = %q, want %q", c.in, got, c.want) - } - } -} - -// fakeSource satisfies Source for downstream tests and proves the interface shape. -type fakeSource struct{ snaps []WindowSnapshot } - -func (f fakeSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) { - for _, s := range f.snaps { - onChange(s) - } - <-ctx.Done() -} - -func TestFakeSourceEmits(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - var got []WindowSnapshot - src := fakeSource{snaps: []WindowSnapshot{ - {Title: "a", Class: "code", Health: EvidenceHealth{Available: true}}, - }} - go src.Watch(ctx, func(s WindowSnapshot) { got = append(got, s) }) - // Give the goroutine a chance, then cancel. - cancel() - // We can't assert timing deterministically here; this test only confirms the - // types and signature compile and Watch accepts the callback. - _ = got -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `go test ./internal/evidence/` -Expected: FAIL β€” `undefined: ScrubTitle`, `undefined: WindowSnapshot`, etc. - -- [ ] **Step 3: Implement `evidence.go`** - -`internal/evidence/evidence.go`: -```go -// Package evidence is the activity port: a dumb active-window sensor behind a -// Source interface, plus the value types it emits. It makes no judgment about -// whether a window is on-task β€” that is the advisor's job in a later milestone. -package evidence - -import ( - "context" - "regexp" -) - -// EvidenceHealth records whether the sensor could observe the active window. -type EvidenceHealth struct { - Available bool // true when a window was observed - Reason string // empty when Available; populated when not -} - -// WindowSnapshot is one observation of the active window. -type WindowSnapshot struct { - Title string // full _NET_WM_NAME (raw; used in live view + raw log) - Class string // WM_CLASS - Health EvidenceHealth -} - -// Source is the activity port. Watch runs until ctx is cancelled, invoking -// onChange on every active-window change, and once immediately with the -// current window. -type Source interface { - Watch(ctx context.Context, onChange func(WindowSnapshot)) -} - -// titleNoise matches the legacy clock/ratio/percent runs that pollute bucket -// keys (e.g. "1:23", "50.5%", "-3.0"). Ported verbatim from the Rust impl. -var titleNoise = regexp.MustCompile(`-?\d+([:.]\d+)+%?`) - -// ScrubTitle removes volatile numeric runs so window titles bucket stably. -// The raw event log keeps the unscrubbed title; only bucket keys are scrubbed. -func ScrubTitle(title string) string { - return titleNoise.ReplaceAllString(title, "") -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `go test ./internal/evidence/` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/evidence/evidence.go internal/evidence/evidence_test.go -git commit -m "M1: evidence types, Source port, ScrubTitle" -``` - ---- - -## Task 3: `store/audit.go` β€” hash-chained session summaries - -**Files:** -- Create: `internal/store/audit.go` -- Test: `internal/store/audit_test.go` - -- [ ] **Step 1: Write the failing tests** - -`internal/store/audit_test.go`: -```go -package store - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func auditFixture(t *testing.T) string { - t.Helper() - return filepath.Join(t.TempDir(), "audit.jsonl") -} - -func sampleSummary(id string) SessionSummary { - return SessionSummary{ - SessionID: id, - NextAction: "write plan", - SuccessCond: "plan committed", - Outcome: "completed", - StartedUnix: 1000, - EndedUnix: 2000, - SwitchCount: 3, - Buckets: []BucketTotal{ - {Class: "code", Title: "antidrift", Seconds: 540}, - {Class: "firefox", Title: "docs", Seconds: 120}, - }, - } -} - -func TestAppendSessionChainsAndVerifies(t *testing.T) { - path := auditFixture(t) - for i := 0; i < 3; i++ { - if err := AppendSession(path, sampleSummary("session-"+string(rune('a'+i)))); err != nil { - t.Fatalf("append %d: %v", i, err) - } - } - if err := VerifyChain(path); err != nil { - t.Fatalf("chain should verify: %v", err) - } -} - -func TestGenesisPrevHashIsZeros(t *testing.T) { - path := auditFixture(t) - if err := AppendSession(path, sampleSummary("session-a")); err != nil { - t.Fatalf("append: %v", err) - } - data, _ := os.ReadFile(path) - if !strings.Contains(string(data), strings.Repeat("0", 64)) { - t.Fatalf("genesis prev_hash should be 64 zeros, got: %s", data) - } -} - -func TestVerifyEmptyChain(t *testing.T) { - if err := VerifyChain(auditFixture(t)); err != nil { - t.Fatalf("empty/missing chain should verify, got %v", err) - } -} - -func TestTamperDetected(t *testing.T) { - path := auditFixture(t) - _ = AppendSession(path, sampleSummary("session-a")) - _ = AppendSession(path, sampleSummary("session-b")) - _ = AppendSession(path, sampleSummary("session-c")) - - // Corrupt the middle line's switch_count. - data, _ := os.ReadFile(path) - lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") - lines[1] = strings.Replace(lines[1], `"switch_count":3`, `"switch_count":99`, 1) - if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { - t.Fatalf("rewrite: %v", err) - } - err := VerifyChain(path) - if err == nil || !strings.Contains(err.Error(), "line 2") { - t.Fatalf("tamper on line 2 should be reported, got %v", err) - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `go test ./internal/store/ -run 'Audit|Chain|Genesis|Tamper'` -Expected: FAIL β€” `undefined: AppendSession`, `undefined: VerifyChain`, `undefined: SessionSummary`. - -- [ ] **Step 3: Implement `audit.go`** - -`internal/store/audit.go`: -```go -package store - -import ( - "bufio" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "strings" -) - -const genesisHash = "0000000000000000000000000000000000000000000000000000000000000000" - -// BucketTotal is per-(class, scrubbed-title) accumulated time in a session. -type BucketTotal struct { - Class string `json:"class"` - Title string `json:"title"` // scrubbed - Seconds int64 `json:"seconds"` -} - -// SessionSummary is one permanent, hash-chained record of a finished session. -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"` -} - -// computeHash returns SHA-256(prevHash || canonicalJSON(s with Hash zeroed)). -// encoding/json emits struct fields in declaration order, so this is stable. -func computeHash(s SessionSummary) (string, error) { - s.Hash = "" - canonical, err := json.Marshal(s) - if err != nil { - return "", err - } - h := sha256.New() - h.Write([]byte(s.PrevHash)) - h.Write(canonical) - return hex.EncodeToString(h.Sum(nil)), nil -} - -func readSummaries(path string) ([]SessionSummary, error) { - f, err := os.Open(path) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - defer f.Close() - var out []SessionSummary - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" { - continue - } - var s SessionSummary - if err := json.Unmarshal([]byte(line), &s); err != nil { - return nil, err - } - out = append(out, s) - } - return out, sc.Err() -} - -// AppendSession links s onto the chain (genesis prev_hash = 64 zeros), assigns -// the next contiguous Seq, computes its Hash, and appends one JSON line with an -// fsync. -func AppendSession(path string, s SessionSummary) error { - existing, err := readSummaries(path) - if err != nil { - return err - } - if len(existing) == 0 { - s.Seq = 1 - s.PrevHash = genesisHash - } else { - last := existing[len(existing)-1] - s.Seq = last.Seq + 1 - s.PrevHash = last.Hash - } - hash, err := computeHash(s) - if err != nil { - return err - } - s.Hash = hash - - line, err := json.Marshal(s) - if err != nil { - return err - } - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - if _, err := f.Write(append(line, '\n')); err != nil { - return err - } - return f.Sync() -} - -// VerifyChain re-walks every line, recomputing hashes and confirming each -// prev_hash links to the prior hash and Seq is contiguous from 1. It returns an -// error naming the first broken line (1-based); nil if intact or empty. -func VerifyChain(path string) error { - summaries, err := readSummaries(path) - if err != nil { - return err - } - prev := genesisHash - for i, s := range summaries { - lineNo := i + 1 - if s.Seq != lineNo { - return fmt.Errorf("audit chain broken at line %d: seq %d, want %d", lineNo, s.Seq, lineNo) - } - if s.PrevHash != prev { - return fmt.Errorf("audit chain broken at line %d: prev_hash mismatch", lineNo) - } - want, err := computeHash(s) - if err != nil { - return err - } - if want != s.Hash { - return fmt.Errorf("audit chain broken at line %d: hash mismatch", lineNo) - } - prev = s.Hash - } - return nil -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `go test ./internal/store/ -run 'Audit|Chain|Genesis|Tamper'` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/store/audit.go internal/store/audit_test.go -git commit -m "M1: hash-chained session audit log" -``` - ---- - -## Task 4: `store/evidence_log.go` β€” raw per-session focus log - -**Files:** -- Create: `internal/store/evidence_log.go` -- Test: `internal/store/evidence_log_test.go` - -- [ ] **Step 1: Write the failing tests** - -`internal/store/evidence_log_test.go`: -```go -package store - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestAppendFocusReplayRoundTrips(t *testing.T) { - dir := filepath.Join(t.TempDir(), "sessions") - id := "session-x" - events := []FocusEvent{ - {AtUnixMillis: 1000, Class: "code", Title: "a", Available: true}, - {AtUnixMillis: 2000, Class: "firefox", Title: "b", Available: true}, - {AtUnixMillis: 3000, Class: "", Title: "", Available: false, Reason: "no active window"}, - } - for _, e := range events { - if err := AppendFocus(dir, id, e); err != nil { - t.Fatalf("append: %v", err) - } - } - got, err := ReplaySession(dir, id) - if err != nil { - t.Fatalf("replay: %v", err) - } - if len(got) != len(events) { - t.Fatalf("got %d events, want %d", len(got), len(events)) - } - for i := range events { - if got[i] != events[i] { - t.Errorf("event %d: got %+v want %+v", i, got[i], events[i]) - } - } -} - -func TestReplayMissingSessionIsEmpty(t *testing.T) { - got, err := ReplaySession(filepath.Join(t.TempDir(), "sessions"), "nope") - if err != nil { - t.Fatalf("missing session should not error: %v", err) - } - if len(got) != 0 { - t.Fatalf("missing session should be empty, got %d", len(got)) - } -} - -func TestPruneOlderThan(t *testing.T) { - dir := filepath.Join(t.TempDir(), "sessions") - _ = AppendFocus(dir, "old", FocusEvent{AtUnixMillis: 1}) - _ = AppendFocus(dir, "new", FocusEvent{AtUnixMillis: 1}) - - now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC) - oldTime := now.Add(-40 * 24 * time.Hour) - if err := os.Chtimes(filepath.Join(dir, "old.jsonl"), oldTime, oldTime); err != nil { - t.Fatalf("chtimes: %v", err) - } - - if err := PruneOlderThan(dir, 30*24*time.Hour, now); err != nil { - t.Fatalf("prune: %v", err) - } - if _, err := os.Stat(filepath.Join(dir, "old.jsonl")); !os.IsNotExist(err) { - t.Errorf("old session should be pruned") - } - if _, err := os.Stat(filepath.Join(dir, "new.jsonl")); err != nil { - t.Errorf("new session should survive: %v", err) - } -} - -func TestPruneMissingDirIsNoop(t *testing.T) { - if err := PruneOlderThan(filepath.Join(t.TempDir(), "nope"), time.Hour, time.Now()); err != nil { - t.Fatalf("missing dir should be a no-op, got %v", err) - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `go test ./internal/store/ -run 'Focus|Replay|Prune'` -Expected: FAIL β€” `undefined: FocusEvent`, `undefined: AppendFocus`, etc. - -- [ ] **Step 3: Implement `evidence_log.go`** - -`internal/store/evidence_log.go`: -```go -package store - -import ( - "bufio" - "encoding/json" - "os" - "path/filepath" - "strings" - "time" -) - -// FocusEvent is one raw, disposable observation in a session's firehose log. -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 sessionFile(dir, sessionID string) string { - return filepath.Join(dir, sessionID+".jsonl") -} - -// AppendFocus appends one event to dir/.jsonl, creating dir if -// needed. It is best-effort detail, not the state of truth. -func AppendFocus(dir, sessionID string, e FocusEvent) error { - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - line, err := json.Marshal(e) - if err != nil { - return err - } - f, err := os.OpenFile(sessionFile(dir, sessionID), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return err - } - defer f.Close() - _, err = f.Write(append(line, '\n')) - return err -} - -// ReplaySession reads all events for a session in append order. A missing file -// yields an empty slice and no error. -func ReplaySession(dir, sessionID string) ([]FocusEvent, error) { - f, err := os.Open(sessionFile(dir, sessionID)) - if os.IsNotExist(err) { - return nil, nil - } - if err != nil { - return nil, err - } - defer f.Close() - var out []FocusEvent - sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" { - continue - } - var e FocusEvent - if err := json.Unmarshal([]byte(line), &e); err != nil { - return nil, err - } - out = append(out, e) - } - return out, sc.Err() -} - -// PruneOlderThan deletes session files whose modification time precedes -// now-age. A missing dir is a no-op. now is injected for testability. -func PruneOlderThan(dir string, age time.Duration, now time.Time) error { - entries, err := os.ReadDir(dir) - if os.IsNotExist(err) { - return nil - } - if err != nil { - return err - } - cutoff := now.Add(-age) - for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { - continue - } - info, err := entry.Info() - if err != nil { - continue - } - if info.ModTime().Before(cutoff) { - _ = os.Remove(filepath.Join(dir, entry.Name())) - } - } - return nil -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `go test ./internal/store/ -run 'Focus|Replay|Prune'` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/store/evidence_log.go internal/store/evidence_log_test.go -git commit -m "M1: raw per-session focus log with retention prune" -``` - ---- - -## Task 5: Snapshot gains `SessionID` and `OutcomePending` - -**Files:** -- Modify: `internal/store/store.go` -- Test: `internal/store/store_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `internal/store/store_test.go`: -```go -func TestSnapshotCarriesSessionFields(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.json") - want := Snapshot{ - RuntimeState: domain.RuntimeActive, - SessionID: "session-abc", - OutcomePending: "completed", - } - if err := Save(path, want); err != nil { - t.Fatalf("save: %v", err) - } - got, err := Load(path) - if err != nil { - t.Fatalf("load: %v", err) - } - if got.SessionID != "session-abc" || got.OutcomePending != "completed" { - t.Fatalf("session fields did not round-trip: %+v", got) - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `go test ./internal/store/ -run SnapshotCarriesSessionFields` -Expected: FAIL β€” `unknown field SessionID in struct literal`. - -- [ ] **Step 3: Add the fields** - -In `internal/store/store.go`, extend `Snapshot`: -```go -// Snapshot is the persisted current state of the daemon. -type Snapshot struct { - RuntimeState domain.RuntimeState `json:"runtime_state"` - Commitment *domain.Commitment `json:"commitment,omitempty"` - DeadlineUnixSecs int64 `json:"deadline_unix_secs,omitempty"` - SessionID string `json:"session_id,omitempty"` - OutcomePending string `json:"outcome_pending,omitempty"` -} -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `go test ./internal/store/` -Expected: PASS (all store tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/store/store.go internal/store/store_test.go -git commit -m "M1: snapshot carries session_id and outcome_pending" -``` - ---- - -## Task 6: X11 adapter + platform fallback - -**Files:** -- Create: `internal/evidence/x11.go` (`//go:build linux`) -- Create: `internal/evidence/source_other.go` (`//go:build !linux`) -- Create: `internal/evidence/x11_integration_test.go` (`//go:build linux`) - -This adapter is exercised by the DISPLAY-gated smoke test and by hand (`Done When`); unit coverage of accumulation uses the fake `Source` in Task 7. - -- [ ] **Step 1: Implement the Linux adapter** - -`internal/evidence/x11.go`: -```go -//go:build linux - -package evidence - -import ( - "context" - "log" - - "github.com/jezek/xgb/xproto" - "github.com/jezek/xgbutil" - "github.com/jezek/xgbutil/ewmh" - "github.com/jezek/xgbutil/icccm" - "github.com/jezek/xgbutil/xevent" - "github.com/jezek/xgbutil/xprop" - "github.com/jezek/xgbutil/xwindow" -) - -// NewSource returns the real X11 active-window sensor. -func NewSource() Source { return &x11Source{} } - -type x11Source struct{} - -// Watch opens one long-lived X connection, subscribes to _NET_ACTIVE_WINDOW -// changes on the root window, and emits a snapshot immediately plus on every -// change. Any failure degrades to an Unavailable snapshot; it never panics the -// daemon. -func (s *x11Source) Watch(ctx context.Context, onChange func(WindowSnapshot)) { - X, err := xgbutil.NewConn() - if err != nil { - onChange(unavailable("cannot connect to X server: " + err.Error())) - <-ctx.Done() - return - } - defer X.Conn().Close() - - root := X.RootWin() - activeAtom, err := xprop.Atm(X, "_NET_ACTIVE_WINDOW") - if err != nil { - onChange(unavailable("no _NET_ACTIVE_WINDOW atom: " + err.Error())) - <-ctx.Done() - return - } - - // Listen for property changes on the root window. - if err := xwindow.New(X, root).Listen(xproto.EventMaskPropertyChange); err != nil { - onChange(unavailable("cannot listen on root window: " + err.Error())) - <-ctx.Done() - return - } - - emit := func() { onChange(snapshot(X)) } - emit() // immediate current window - - xevent.PropertyNotifyFun(func(_ *xgbutil.XUtil, ev xevent.PropertyNotifyEvent) { - if ev.Atom == activeAtom { - emit() - } - }).Connect(X, root) - - // Run the event loop until ctx is cancelled. - go func() { - <-ctx.Done() - xevent.Quit(X) - }() - xevent.Main(X) -} - -func snapshot(X *xgbutil.XUtil) WindowSnapshot { - active, err := ewmh.ActiveWindowGet(X) - if err != nil || active == 0 { - return unavailable("no active window") - } - title, err := ewmh.WmNameGet(X, active) - if err != nil || title == "" { - // Fall back to ICCCM WM_NAME. - if t, e := icccm.WmNameGet(X, active); e == nil { - title = t - } - } - var class string - if wmClass, err := icccm.WmClassGet(X, active); err == nil && wmClass != nil { - class = wmClass.Class - } - return WindowSnapshot{ - Title: title, - Class: class, - Health: EvidenceHealth{Available: true}, - } -} - -func unavailable(reason string) WindowSnapshot { - log.Printf("evidence: %s", reason) - return WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: reason}} -} -``` - -- [ ] **Step 2: Implement the non-Linux fallback** - -`internal/evidence/source_other.go`: -```go -//go:build !linux - -package evidence - -import "context" - -// NewSource returns a sensor that reports evidence permanently unavailable on -// platforms without the X11 adapter. -func NewSource() Source { return noopSource{} } - -type noopSource struct{} - -func (noopSource) Watch(ctx context.Context, onChange func(WindowSnapshot)) { - onChange(WindowSnapshot{Health: EvidenceHealth{Available: false, Reason: "no active-window sensor on this platform"}}) - <-ctx.Done() -} -``` - -- [ ] **Step 3: Write the DISPLAY-gated smoke test** - -`internal/evidence/x11_integration_test.go`: -```go -//go:build linux - -package evidence - -import ( - "context" - "os" - "testing" - "time" -) - -func TestX11SourceEmitsWhenDisplaySet(t *testing.T) { - if os.Getenv("DISPLAY") == "" { - t.Skip("no DISPLAY; skipping live X11 smoke test") - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - got := make(chan WindowSnapshot, 1) - go NewSource().Watch(ctx, func(s WindowSnapshot) { - select { - case got <- s: - default: - } - }) - - select { - case snap := <-got: - // Either a real window or a clean Unavailable β€” both are valid; we only - // assert the sensor produced an observation without panicking. - t.Logf("first snapshot: %+v", snap) - case <-ctx.Done(): - t.Fatal("x11 source emitted no snapshot within timeout") - } -} -``` - -- [ ] **Step 4: Build and run** - -Run: `go build ./... && go test ./internal/evidence/` -Expected: build succeeds; tests PASS (smoke test runs or skips depending on `DISPLAY`). - -If a symbol under `github.com/jezek/xgbutil/...` does not resolve (the library's API for `WmNameGet`/`WmClassGet`/`ActiveWindowGet` is stable, but verify against the fetched version), check the package with: -`go doc github.com/jezek/xgbutil/ewmh ActiveWindowGet` and adjust the call to match the installed signature. Do not change behavior β€” only the call shape. - -- [ ] **Step 5: Run go mod tidy and commit** - -Run: `go mod tidy` -```bash -git add internal/evidence/x11.go internal/evidence/source_other.go internal/evidence/x11_integration_test.go go.mod go.sum -git commit -m "M1: X11 active-window adapter + platform fallback" -``` - ---- - -## Task 7: `session.Controller` β€” clock, stats, RecordWindow, lifecycle, recovery - -**Files:** -- Modify: `internal/session/session.go` -- Test: `internal/session/session_test.go` - -This is the heart of M1. Implement the controller changes, then the tests verifying accumulation, the unavailable bucket, no-accounting-outside-Active, crash replay, and audit-write-at-End. - -- [ ] **Step 1: Rewrite `internal/session/session.go`** - -Replace the file contents with: -```go -// Package session owns the daemon's in-memory state of truth and persists a -// snapshot on every change. Transitions go through the pure statemachine. It -// also owns per-session evidence stats: it accumulates active-window time while -// Active, logs raw focus events, and seals each session into the audit chain. -package session - -import ( - "log" - "path/filepath" - "sort" - "sync" - "time" - - "antidrift/internal/domain" - "antidrift/internal/evidence" - "antidrift/internal/statemachine" - "antidrift/internal/store" - - "github.com/google/uuid" -) - -const ( - unavailableTitle = "(evidence unavailable)" - sessionRetention = 30 * 24 * time.Hour -) - -// bucketKey identifies a time bucket; Title is the scrubbed title. -type bucketKey struct{ Class, Title string } - -// EvidenceStats is the in-memory accounting for the current session only. -type EvidenceStats struct { - SessionID string - StartedUnix int64 - Buckets map[bucketKey]time.Duration - SwitchCount int - Current evidence.WindowSnapshot - lastFocusAt time.Time - lastKey bucketKey - hasLast bool -} - -// Controller holds runtime state and the active commitment behind a mutex. -type Controller struct { - mu sync.Mutex - runtimeState domain.RuntimeState - commitment *domain.Commitment - deadline time.Time - snapshotPath string - auditPath string - sessionsDir string - clock func() time.Time - onChange func() - latestWindow evidence.WindowSnapshot - stats *EvidenceStats - outcomePending string -} - -// CommitmentView is the UI projection of the active commitment. -type CommitmentView struct { - NextAction string `json:"next_action"` - SuccessCondition string `json:"success_condition"` - TimeboxSecs int64 `json:"timebox_secs"` - DeadlineUnixSecs int64 `json:"deadline_unix_secs"` -} - -// WindowView / BucketView / EvidenceView are the evidence projection. -type WindowView struct { - Class string `json:"class"` - Title string `json:"title"` -} - -type BucketView struct { - Class string `json:"class"` - Title string `json:"title"` - Seconds int64 `json:"seconds"` -} - -type EvidenceView struct { - Available bool `json:"available"` - Reason string `json:"reason"` - Current WindowView `json:"current"` - SwitchCount int `json:"switch_count"` - Buckets []BucketView `json:"buckets"` -} - -// State is the broadcastable view of the controller. -type State struct { - RuntimeState domain.RuntimeState `json:"runtime_state"` - Commitment *CommitmentView `json:"commitment"` - Evidence *EvidenceView `json:"evidence"` -} - -// New loads any persisted snapshot, prunes stale session logs, and rebuilds -// in-memory stats from the raw log if a live session was interrupted. -func New(snapshotPath string) (*Controller, error) { - s, err := store.Load(snapshotPath) - if err != nil { - return nil, err - } - dir := filepath.Dir(snapshotPath) - c := &Controller{ - runtimeState: s.RuntimeState, - commitment: s.Commitment, - snapshotPath: snapshotPath, - auditPath: filepath.Join(dir, "audit.jsonl"), - sessionsDir: filepath.Join(dir, "sessions"), - clock: time.Now, - outcomePending: s.OutcomePending, - } - if s.DeadlineUnixSecs > 0 { - c.deadline = time.Unix(s.DeadlineUnixSecs, 0) - } - if c.runtimeState == "" { - c.runtimeState = domain.RuntimeLocked - } - _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) - if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { - c.replayStats(s.SessionID) - } - return c, nil -} - -// SetClock overrides the time source (tests only). Call before starting a -// session. -func (c *Controller) SetClock(f func() time.Time) { - c.mu.Lock() - c.clock = f - c.mu.Unlock() -} - -// SetOnChange registers a callback fired after an evidence-driven state change -// (focus updates). It is invoked with the mutex released. -func (c *Controller) SetOnChange(f func()) { - c.mu.Lock() - c.onChange = f - c.mu.Unlock() -} - -func (c *Controller) notify() { - c.mu.Lock() - f := c.onChange - c.mu.Unlock() - if f != nil { - f() - } -} - -// State returns the current broadcastable state. Safe for concurrent use. -func (c *Controller) State() State { - c.mu.Lock() - defer c.mu.Unlock() - return c.stateLocked() -} - -// Deadline returns the active commitment deadline, or the zero time. -func (c *Controller) Deadline() time.Time { - c.mu.Lock() - defer c.mu.Unlock() - return c.deadline -} - -func (c *Controller) stateLocked() State { - st := State{RuntimeState: c.runtimeState} - if c.commitment != nil { - view := &CommitmentView{ - NextAction: c.commitment.NextAction, - SuccessCondition: c.commitment.SuccessCondition, - TimeboxSecs: c.commitment.TimeboxSecs, - } - if !c.deadline.IsZero() { - view.DeadlineUnixSecs = c.deadline.Unix() - } - st.Commitment = view - } - if c.stats != nil { - st.Evidence = &EvidenceView{ - Available: c.stats.Current.Health.Available, - Reason: c.stats.Current.Health.Reason, - Current: WindowView{Class: c.stats.Current.Class, Title: c.stats.Current.Title}, - SwitchCount: c.stats.SwitchCount, - Buckets: bucketViews(c.stats.Buckets), - } - } - return st -} - -func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { - out := make([]BucketView, 0, len(buckets)) - for k, d := range buckets { - out = append(out, BucketView{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) - } - sort.Slice(out, func(i, j int) bool { return out[i].Seconds > out[j].Seconds }) - return out -} - -func (c *Controller) persistLocked() error { - snap := store.Snapshot{ - RuntimeState: c.runtimeState, - Commitment: c.commitment, - OutcomePending: c.outcomePending, - } - if !c.deadline.IsZero() { - snap.DeadlineUnixSecs = c.deadline.Unix() - } - if c.stats != nil { - snap.SessionID = c.stats.SessionID - } - return store.Save(c.snapshotPath, snap) -} - -// EnterPlanning moves Locked -> Planning. -func (c *Controller) EnterPlanning() error { - c.mu.Lock() - defer c.mu.Unlock() - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EnterPlanning) - if err != nil { - return err - } - c.runtimeState = next - return c.persistLocked() -} - -// StartManualCommitment validates input, activates a new commitment, mints a -// session, seeds evidence stats from the latest window, and moves Planning -> -// Active. -func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration) error { - c.mu.Lock() - defer c.mu.Unlock() - commitment, err := domain.NewManual(nextAction, successCondition, timebox) - if err != nil { - return err - } - commitment.State, err = statemachine.TransitionCommitment(commitment.State, statemachine.CommitmentActivate) - if err != nil { - return err - } - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.ActivateAccepted) - if err != nil { - return err - } - now := c.clock() - c.runtimeState = next - c.commitment = &commitment - c.deadline = now.Add(timebox) - c.outcomePending = "" - - sessionID := "session-" + uuid.Must(uuid.NewV7()).String() - c.stats = &EvidenceStats{ - SessionID: sessionID, - StartedUnix: now.Unix(), - Buckets: map[bucketKey]time.Duration{}, - } - seed := c.latestWindow - _ = store.AppendFocus(c.sessionsDir, sessionID, focusEvent(now, seed)) - c.applyEvent(now, seed) - return c.persistLocked() -} - -// Complete moves Active -> Review with a "completed" outcome. -func (c *Controller) Complete() error { return c.enterReview("completed") } - -// Expire moves Active -> Review with an "expired" outcome (timebox elapsed). -func (c *Controller) Expire() error { return c.enterReview("expired") } - -func (c *Controller) enterReview(outcome string) error { - c.mu.Lock() - defer c.mu.Unlock() - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.CompleteForReview) - if err != nil { - return err - } - if c.commitment != nil { - completed, err := statemachine.TransitionCommitment(c.commitment.State, statemachine.Complete) - if err != nil { - return err - } - c.commitment.State = completed - } - // Flush the final open segment, then freeze accounting. - if c.stats != nil && c.stats.hasLast { - c.stats.Buckets[c.stats.lastKey] += c.clock().Sub(c.stats.lastFocusAt) - c.stats.hasLast = false - } - c.runtimeState = next - c.outcomePending = outcome - return c.persistLocked() -} - -// End moves Review -> Locked, writes the session summary to the audit chain, -// and clears the commitment and stats. -func (c *Controller) End() error { - c.mu.Lock() - defer c.mu.Unlock() - next, err := statemachine.TransitionRuntime(c.runtimeState, statemachine.EndWorkPeriod) - if err != nil { - return err - } - if c.stats != nil { - if err := store.AppendSession(c.auditPath, c.buildSummaryLocked()); err != nil { - // State integrity over audit completeness: the transition still - // completes. Surfaced for the operator; no auto-retry in M1. - log.Printf("session: audit append failed: %v", err) - } - } - c.runtimeState = next - c.commitment = nil - c.deadline = time.Time{} - c.stats = nil - c.outcomePending = "" - return c.persistLocked() -} - -func (c *Controller) buildSummaryLocked() store.SessionSummary { - buckets := make([]store.BucketTotal, 0, len(c.stats.Buckets)) - for k, d := range c.stats.Buckets { - buckets = append(buckets, store.BucketTotal{Class: k.Class, Title: k.Title, Seconds: int64(d.Seconds())}) - } - sort.Slice(buckets, func(i, j int) bool { return buckets[i].Seconds > buckets[j].Seconds }) - outcome := c.outcomePending - if outcome == "" { - outcome = "completed" - } - var na, sc string - if c.commitment != nil { - na, sc = c.commitment.NextAction, c.commitment.SuccessCondition - } - return store.SessionSummary{ - SessionID: c.stats.SessionID, - NextAction: na, - SuccessCond: sc, - Outcome: outcome, - StartedUnix: c.stats.StartedUnix, - EndedUnix: c.clock().Unix(), - SwitchCount: c.stats.SwitchCount, - Buckets: buckets, - } -} - -// RecordWindow ingests one sensor observation. It accumulates time only while -// Active, always tracks the latest window for display, and fires onChange after -// releasing the mutex. -func (c *Controller) RecordWindow(snap evidence.WindowSnapshot) { - c.mu.Lock() - c.latestWindow = snap - if c.runtimeState != domain.RuntimeActive || c.stats == nil { - c.mu.Unlock() - c.notify() - return - } - now := c.clock() - _ = store.AppendFocus(c.sessionsDir, c.stats.SessionID, focusEvent(now, snap)) - c.applyEvent(now, snap) - c.mu.Unlock() - c.notify() -} - -// applyEvent advances stats by one observation: it credits the prior segment to -// the prior bucket, counts a context switch on key change, and records the new -// current window. Used by both live tracking and crash replay. Caller holds mu. -func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) { - if c.stats.hasLast { - c.stats.Buckets[c.stats.lastKey] += now.Sub(c.stats.lastFocusAt) - } - newKey := keyFor(snap) - if c.stats.hasLast && newKey != c.stats.lastKey { - c.stats.SwitchCount++ - } - c.stats.lastKey = newKey - c.stats.lastFocusAt = now - c.stats.hasLast = true - c.stats.Current = snap -} - -// replayStats rebuilds in-memory stats from the raw session log after a crash. -func (c *Controller) replayStats(sessionID string) { - events, err := store.ReplaySession(c.sessionsDir, sessionID) - if err != nil || len(events) == 0 { - c.stats = &EvidenceStats{ - SessionID: sessionID, - StartedUnix: c.clock().Unix(), - Buckets: map[bucketKey]time.Duration{}, - } - return - } - c.stats = &EvidenceStats{ - SessionID: sessionID, - StartedUnix: events[0].AtUnixMillis / 1000, - Buckets: map[bucketKey]time.Duration{}, - } - for _, e := range events { - c.applyEvent(time.UnixMilli(e.AtUnixMillis), snapFromEvent(e)) - } -} - -func keyFor(snap evidence.WindowSnapshot) bucketKey { - if !snap.Health.Available { - return bucketKey{Class: "", Title: unavailableTitle} - } - return bucketKey{Class: snap.Class, Title: evidence.ScrubTitle(snap.Title)} -} - -func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent { - return store.FocusEvent{ - AtUnixMillis: now.UnixMilli(), - Class: snap.Class, - Title: snap.Title, - Available: snap.Health.Available, - Reason: snap.Health.Reason, - } -} - -func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot { - return evidence.WindowSnapshot{ - Title: e.Title, - Class: e.Class, - Health: evidence.EvidenceHealth{Available: e.Available, Reason: e.Reason}, - } -} -``` - -- [ ] **Step 2: Run to verify existing tests still pass** - -Run: `go test ./internal/session/` -Expected: PASS β€” the M0 happy-path/restore tests are unaffected (they don't set a clock and don't inspect evidence). - -- [ ] **Step 3: Write the M1 stats tests** - -Append to `internal/session/session_test.go` (add `antidrift/internal/evidence` and `antidrift/internal/store` to imports): -```go -// fakeClock returns successive instants on demand. -type fakeClock struct{ now time.Time } - -func (f *fakeClock) advance(d time.Duration) { f.now = f.now.Add(d) } -func (f *fakeClock) fn() func() time.Time { return func() time.Time { return f.now } } - -func snap(class, title string) evidence.WindowSnapshot { - return evidence.WindowSnapshot{Class: class, Title: title, Health: evidence.EvidenceHealth{Available: true}} -} - -func bucketSeconds(t *testing.T, st session.State, class, title string) int64 { - t.Helper() - if st.Evidence == nil { - t.Fatalf("expected evidence projection") - } - for _, b := range st.Evidence.Buckets { - if b.Class == class && b.Title == title { - return b.Seconds - } - } - return -1 -} - -func TestAccumulatesBucketsAndSwitches(t *testing.T) { - c, _ := newTestController(t) - clk := &fakeClock{now: time.Unix(1000, 0)} - c.SetClock(clk.fn()) - c.RecordWindow(snap("code", "antidrift")) // latest window before start - - _ = c.EnterPlanning() - _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seeds at t=1000 with code/antidrift - - clk.advance(10 * time.Second) - c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox - clk.advance(20 * time.Second) - c.RecordWindow(snap("code", "antidrift")) // credits 20s to firefox/docs, switch back - - st := c.State() - if got := bucketSeconds(t, st, "code", "antidrift"); got != 10 { - t.Errorf("code bucket = %d, want 10", got) - } - if got := bucketSeconds(t, st, "firefox", "docs"); got != 20 { - t.Errorf("firefox bucket = %d, want 20", got) - } - if st.Evidence.SwitchCount != 2 { - t.Errorf("switch count = %d, want 2", st.Evidence.SwitchCount) - } -} - -func TestNoAccountingOutsideActive(t *testing.T) { - c, _ := newTestController(t) - clk := &fakeClock{now: time.Unix(1000, 0)} - c.SetClock(clk.fn()) - // Locked: RecordWindow must not create stats. - c.RecordWindow(snap("code", "x")) - if c.State().Evidence != nil { - t.Fatalf("no session: evidence should be nil") - } -} - -func TestUnavailableAccruesToReservedBucket(t *testing.T) { - c, _ := newTestController(t) - clk := &fakeClock{now: time.Unix(1000, 0)} - c.SetClock(clk.fn()) - _ = c.EnterPlanning() - _ = c.StartManualCommitment("work", "done", 25*time.Minute) // seed: empty unavailable latestWindow - // latestWindow was zero-value (unavailable) at seed time. - clk.advance(5 * time.Second) - c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket - st := c.State() - if got := bucketSeconds(t, st, "", "(evidence unavailable)"); got != 5 { - t.Errorf("unavailable bucket = %d, want 5", got) - } -} - -func TestCrashReplayRebuildsStats(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.json") - first, _ := New(path) - clk := &fakeClock{now: time.Unix(1000, 0)} - first.SetClock(clk.fn()) - first.RecordWindow(snap("code", "antidrift")) - _ = first.EnterPlanning() - _ = first.StartManualCommitment("work", "done", 25*time.Minute) - clk.advance(15 * time.Second) - first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch - - // Simulate crash + restart: New replays the raw log. - second, err := New(path) - if err != nil { - t.Fatalf("reopen: %v", err) - } - st := second.State() - if st.RuntimeState != domain.RuntimeActive { - t.Fatalf("restored state should be Active, got %s", st.RuntimeState) - } - if got := bucketSeconds(t, st, "code", "antidrift"); got != 15 { - t.Errorf("replayed code bucket = %d, want 15", got) - } - if st.Evidence.SwitchCount != 1 { - t.Errorf("replayed switch count = %d, want 1", st.Evidence.SwitchCount) - } -} - -func TestEndWritesAuditSummary(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "state.json") - c, _ := New(path) - clk := &fakeClock{now: time.Unix(1000, 0)} - c.SetClock(clk.fn()) - c.RecordWindow(snap("code", "antidrift")) - _ = c.EnterPlanning() - _ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute) - clk.advance(30 * time.Second) - c.RecordWindow(snap("firefox", "docs")) - clk.advance(10 * time.Second) - if err := c.Complete(); err != nil { // flush: +10s to firefox/docs - t.Fatalf("complete: %v", err) - } - if err := c.End(); err != nil { - t.Fatalf("end: %v", err) - } - - auditPath := filepath.Join(dir, "audit.jsonl") - if err := store.VerifyChain(auditPath); err != nil { - t.Fatalf("chain should verify: %v", err) - } - // Re-read and assert the single summary's totals. - c2, _ := New(path) // not strictly needed; read raw instead - _ = c2 -} -``` - -Note: `TestEndWritesAuditSummary` verifies the chain is valid after `End`. To assert bucket contents, the simplest check is `VerifyChain` passing plus a non-empty file; deeper field assertions are already covered by `store/audit_test.go`. Keep the test focused on "End extends a verifiable chain." - -- [ ] **Step 4: Run to verify pass** - -Run: `go test ./internal/session/` -Expected: PASS (all M0 + M1 session tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/session/session.go internal/session/session_test.go -git commit -m "M1: controller evidence stats, accumulation, replay, audit-at-End" -``` - ---- - -## Task 8: `web` β€” wire onChange, switch expiry to Expire - -**Files:** -- Modify: `internal/web/web.go` -- Test: `internal/web/web_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `internal/web/web_test.go` (add `antidrift/internal/evidence` to imports): -```go -func TestActiveStatePayloadCarriesEvidence(t *testing.T) { - s := newTestServer(t) - r := s.Router() - _ = post(t, r, "/planning", "") - body := `{"next_action":"Build web","success_condition":"web tests pass","timebox_secs":1500}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { - t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) - } - // A focus update should appear in the serialized state. - s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "code", Title: "antidrift", Health: evidence.EvidenceHealth{Available: true}}) - - js := s.stateJSON() - if !strings.Contains(js, `"evidence"`) { - t.Fatalf("payload missing evidence object: %s", js) - } - if !strings.Contains(js, `"class":"code"`) { - t.Fatalf("payload missing current window: %s", js) - } -} - -func TestLockedStateHasNullEvidence(t *testing.T) { - s := newTestServer(t) - js := s.stateJSON() - if !strings.Contains(js, `"evidence":null`) { - t.Fatalf("locked payload should have null evidence: %s", js) - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `go test ./internal/web/ -run 'Evidence|NullEvidence'` -Expected: the `RecordWindow` call won't compile until imports are added, then the null-evidence assertion passes already (State.Evidence is nil for Locked) but the evidence-present test confirms wiring. If both compile and pass without web.go changes, still apply Step 3 (onChange + Expire) β€” it is required for live SSE and correct outcome tracking. - -- [ ] **Step 3: Update `web.go`** - -In `NewServer`, register the broadcast callback so focus updates fan out over SSE: -```go -func NewServer(ctrl *session.Controller) *Server { - s := &Server{ctrl: ctrl, bcast: NewBroadcaster()} - ctrl.SetOnChange(s.broadcast) - return s -} -``` - -In `armExpiry`, the timer fires an *expiry*, not a user completion: -```go - s.timer = time.AfterFunc(d, func() { - if err := s.ctrl.Expire(); err == nil { - s.broadcast() - } - }) -``` - -In `Init`, a restored-but-past-deadline session also expires: -```go - if err := s.ctrl.Expire(); err == nil { - s.broadcast() - } -``` - -- [ ] **Step 4: Run to verify pass** - -Run: `go test ./internal/web/` -Expected: PASS (all web tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/web/web.go internal/web/web_test.go -git commit -m "M1: broadcast focus updates, expiry uses Expire outcome" -``` - ---- - -## Task 9: Wire the sensor in `main.go` - -**Files:** -- Modify: `cmd/antidriftd/main.go` - -- [ ] **Step 1: Construct the source and start watching** - -Edit `cmd/antidriftd/main.go`. Add imports `"context"` and `"antidrift/internal/evidence"`. After `srv.Init()` and before `go openBrowser(...)`: -```go - src := evidence.NewSource() - go src.Watch(context.Background(), ctrl.RecordWindow) -``` - -The goroutine runs for the daemon's lifetime; `ctrl.RecordWindow` is safe for concurrent use and only accounts time while Active. - -- [ ] **Step 2: Build** - -Run: `go build ./...` -Expected: success. - -- [ ] **Step 3: Commit** - -```bash -git add cmd/antidriftd/main.go -git commit -m "M1: start active-window sensor wired to the controller" -``` - ---- - -## Task 10: Surface evidence in the browser UI - -**Files:** -- Modify: `internal/web/static/index.html` - -The page stays a pure renderer. Add an evidence block to the Active view and a summary to the Review view. No design polish (that is M4). - -- [ ] **Step 1: Add a small evidence renderer and styles** - -In the `` block in `internal/web/static/index.html` (everything between the tags, currently lines 8–41) and paste it unchanged into the new file `internal/web/static/app.css`. Do not edit the rules in this task β€” this is a pure move. (They are replaced wholesale in Task 2.) - -- [ ] **Step 4: Create `app.js` by moving the inline script verbatim** - -Cut the entire current contents of the `` block in `internal/web/static/index.html` (everything between the tags, currently lines 49–227) and paste it unchanged into the new file `internal/web/static/app.js`. - -- [ ] **Step 5: Replace `index.html` with the markup shell** - -Replace the whole file `internal/web/static/index.html` with: - -```html - - - - - -AntiDrift - - - -
-

AntiDrift

-
connecting…
-
- - - -``` - -Note: the script must reference `view` and (from Task 2 on) `app`; both ids exist here. The script tag is at the end of ``, so the DOM is parsed before it runs (matches the current inline-at-end-of-body behavior). - -- [ ] **Step 6: Add asset routes in `web.go`** - -In `internal/web/web.go`, the `Router()` method currently has (around lines 43–46): - -```go - sub, _ := fs.Sub(staticFS, "static") - r.GET("/", func(c *gin.Context) { - c.FileFromFS("/", http.FS(sub)) - }) -``` - -Add the two asset routes immediately after the `r.GET("/", …)` block: - -```go - r.GET("/app.css", func(c *gin.Context) { - c.FileFromFS("/app.css", http.FS(sub)) - }) - r.GET("/app.js", func(c *gin.Context) { - c.FileFromFS("/app.js", http.FS(sub)) - }) -``` - -- [ ] **Step 7: Run the asset test and the full web suite** - -Run: `go test ./internal/web/ -v` -Expected: PASS β€” `TestServesStaticAssets` passes and all pre-existing web tests still pass (they assert on endpoints/JSON, not markup). - -- [ ] **Step 8: Verify vet and the whole module** - -Run: `go vet ./... && go test -race ./...` -Expected: clean, all green. - -- [ ] **Step 9: Commit** - -```bash -git add internal/web/static/index.html internal/web/static/app.css internal/web/static/app.js internal/web/web.go internal/web/web_test.go -git commit -m "M4: split web UI assets into app.css and app.js - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 2: Cockpit visual system + locked/planning/active/drift/nudge - -Replace `app.css` with the new design system and rewrite `app.js`'s rendering into stacked bands with a `data-state`-driven accent. Review stays a simple functional band here; Task 3 turns it into the recap. No Go changes β€” the existing endpoint tests are the regression guard. - -**Files:** -- Modify: `internal/web/static/app.css` (replace whole file) -- Modify: `internal/web/static/app.js` (replace whole file) -- Test: existing `internal/web/web_test.go` (regression only β€” no new test) - -- [ ] **Step 1: Replace `app.css` with the cockpit design system** - -Replace the whole file `internal/web/static/app.css` with: - -```css -:root { - color-scheme: dark; - --bg: #0d0f14; - --panel: #161922; - --line: #232733; - --ink: #e6e8ee; - --ink-dim: #8b91a6; - --ok: #34d399; - --warn: #f0b429; - --danger: #f06070; - --accent: #6b7280; /* default / locked */ -} - -/* The single state-driven knob: only --accent changes per state. */ -[data-state="planning"] { --accent: #4c6ef5; } -[data-state="active"] { --accent: #34d399; } -[data-state="nudge"] { --accent: #f0b429; } -[data-state="drift"] { --accent: #f06070; } -[data-state="review"] { --accent: #a78bfa; } - -* { box-sizing: border-box; } - -body { - margin: 0; - font: 15px/1.5 system-ui, sans-serif; - background: var(--bg); - color: var(--ink); -} - -main { max-width: 560px; margin: 7vh auto; padding: 0 20px; } - -h1 { - font-size: 12px; letter-spacing: .28em; text-transform: uppercase; - color: var(--ink-dim); margin: 0 0 14px 6px; -} - -/* The HUD card: a stack of bands. */ -.card { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 14px; - overflow: hidden; -} - -.band { border-top: 1px solid var(--line); padding: 16px 20px; } -.band:first-child { border-top: 0; } - -.statusband { - display: flex; align-items: baseline; gap: 12px; - border-top: 3px solid var(--accent); - background: color-mix(in srgb, var(--accent) 8%, var(--panel)); -} - -.pill { - font-size: 11px; letter-spacing: .18em; text-transform: uppercase; - font-weight: 700; color: var(--accent); -} -.status-meta { font-size: 13px; color: var(--ink-dim); } - -.timer { - font-size: 52px; font-weight: 700; line-height: 1; - font-variant-numeric: tabular-nums; color: var(--ink); -} - -.action { font-size: 19px; font-weight: 600; } -.meta { color: var(--ink-dim); margin: 4px 0 0; } - -label { display: block; font-size: 12px; color: var(--ink-dim); margin: 14px 0 5px; } -label:first-child { margin-top: 0; } -input { - width: 100%; padding: 10px 12px; - background: var(--bg); border: 1px solid var(--line); - border-radius: 8px; color: var(--ink); font: inherit; -} -input:focus { outline: 0; border-color: var(--accent); } - -.btn { - margin-top: 16px; padding: 10px 16px; border: 0; border-radius: 8px; - font: inherit; font-weight: 600; cursor: pointer; -} -.btn-primary { background: var(--accent); color: #0d0f14; } -.btn-ghost { background: var(--line); color: var(--ink); } -.btn:disabled { background: var(--line); color: var(--ink-dim); cursor: not-allowed; } - -/* Drift/nudge actions live in the status band; lay them out below the text. */ -.band-actions { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 8px; } -.band-actions .btn { margin-top: 0; padding: 7px 12px; } - -.drift-reason, .nudge-msg { color: var(--ink); margin: 4px 0 0; font-weight: 400; } -.statusband.col { flex-direction: column; align-items: stretch; gap: 6px; } - -/* Evidence band */ -.evidence .now { font-size: 13px; color: var(--ink); } -.health-ok { color: var(--ok); } -.health-bad { color: var(--warn); } -.buckets { list-style: none; padding: 0; margin: 10px 0 0; font-size: 13px; color: var(--ink-dim); } -.buckets li { - display: flex; justify-content: space-between; padding: 2px 0; - font-family: ui-monospace, monospace; font-variant-numeric: tabular-nums; -} -.switches { font-size: 12px; color: var(--ink-dim); margin-top: 8px; } - -/* Review summary band (built out in Task 3) */ -.summary { font-size: 14px; } -.summary-row { - display: flex; justify-content: space-between; padding: 3px 0; - font-variant-numeric: tabular-nums; color: var(--ink-dim); -} -.summary-row span:last-child { color: var(--ink); font-family: ui-monospace, monospace; } -``` - -- [ ] **Step 2: Replace `app.js` with the band-based renderer** - -Replace the whole file `internal/web/static/app.js` with: - -```js -const app = document.getElementById('app'); -const view = document.getElementById('view'); -let countdownTimer = null; -let renderedState = null; // which runtime_state the DOM currently shows -let dismissedNudge = null; // text of the nudge the user has dismissed - -function post(path, body) { - return fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: body ? JSON.stringify(body) : undefined, - }); -} - -function fmt(secs) { - secs = Math.max(0, Math.floor(secs)); - const m = String(Math.floor(secs / 60)).padStart(2, '0'); - const s = String(secs % 60).padStart(2, '0'); - return `${m}:${s}`; -} - -// stateKey maps server state to the data-state accent bucket. Drift outranks -// a nudge, and a nudge the user already dismissed reverts to plain active. -function stateKey(state) { - const rs = state.runtime_state; - if (rs !== 'active') return rs || 'locked'; - const d = state.drift || {}; - if (d.status === 'drifting') return 'drift'; - if (d.nudge && d.nudge !== dismissedNudge) return 'nudge'; - return 'active'; -} - -function setStateAttr(state) { - app.dataset.state = stateKey(state); -} - -function evidenceBlock(ev) { - if (!ev) return ''; - const health = ev.available - ? `tracking` - : `evidence unavailable: ${ev.reason || 'unknown'}`; - const now = ev.current && (ev.current.class || ev.current.title) - ? `${ev.current.class || '?'} Β· ${ev.current.title || ''}` : 'β€”'; - const rows = (ev.buckets || []).map(b => - `
  • ${(b.class || '?')} Β· ${b.title || ''}${fmt(b.seconds)}
  • `).join(''); - return `
    -
    now ${now}   ${health}
    -
      ${rows}
    -
    context switches: ${ev.switch_count || 0}
    -
    `; -} - -// updateActiveDrift owns the active status band: it renders on-task, nudge, -// drift, or pending content into #statusband and rebinds the buttons. -function updateActiveDrift(state) { - const el = document.getElementById('statusband'); - if (!el) return; - setStateAttr(state); - const drift = state.drift || {}; - const switches = (state.evidence && state.evidence.switch_count) || 0; - - if (drift.status === 'drifting') { - el.className = 'band statusband col'; - el.innerHTML = `
    Drift
    -
    ${drift.reason || 'This looks off task.'}
    -
    - - - -
    `; - document.getElementById('refocus').onclick = () => post('/refocus'); - document.getElementById('ontask').onclick = () => post('/ontask'); - document.getElementById('enddrift').onclick = () => post('/complete'); - return; - } - - const nudge = drift.nudge || ''; - if (!nudge) dismissedNudge = null; // trajectory recovered; allow future nudges - if (nudge && nudge !== dismissedNudge) { - el.className = 'band statusband col'; - el.innerHTML = `
    Heads up
    -
    ${nudge}
    -
    - -
    `; - document.getElementById('dismissnudge').onclick = () => { - dismissedNudge = nudge; - setStateAttr(state); - updateActiveDrift(state); - }; - return; - } - - if (drift.status === 'pending') { - el.className = 'band statusband'; - el.innerHTML = `Activechecking focus…`; - return; - } - - el.className = 'band statusband'; - el.innerHTML = `Active - on task Β· ${switches} switches`; -} - -function updatePlanningCoach(coach) { - const statusEl = document.getElementById('coachStatus'); - if (!statusEl) return; - if (!coach || coach.status === 'idle') { statusEl.textContent = ''; return; } - if (coach.status === 'pending') { statusEl.textContent = 'Sharpening…'; return; } - if (coach.status === 'error') { statusEl.textContent = coach.error || 'coach unavailable'; return; } - if (coach.status === 'ready' && coach.proposal) { - statusEl.textContent = 'AI suggestion applied β€” edit freely.'; - const stamp = JSON.stringify(coach.proposal); - if (statusEl.dataset.applied === stamp) return; - statusEl.dataset.applied = stamp; - const na = document.getElementById('na'), sc = document.getElementById('sc'), - mins = document.getElementById('mins'), start = document.getElementById('start'); - if (na && !na.value.trim()) na.value = coach.proposal.next_action || ''; - if (sc && !sc.value.trim()) sc.value = coach.proposal.success_condition || ''; - if (mins && coach.proposal.timebox_secs) mins.value = Math.round(coach.proposal.timebox_secs / 60); - if (start) start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); - const apps = document.getElementById('apps'); - if (apps && !apps.value.trim() && Array.isArray(coach.proposal.allowed_window_classes)) { - apps.value = coach.proposal.allowed_window_classes.join(', '); - } - } -} - -function render(state) { - setStateAttr(state); - const rs = state.runtime_state; - if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); - return; - } - if (rs === 'active' && renderedState === 'active') { - updateActiveDrift(state); - return; - } - if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; } - renderedState = rs; - - if (rs === 'locked') { - view.innerHTML = `
    Locked
    -
    -

    No active commitment.

    - -
    `; - document.getElementById('plan').onclick = () => post('/planning'); - - } else if (rs === 'planning') { - view.innerHTML = `
    Planning
    -
    - - - -
    -
    -
    - - - - - - -
    `; - const na = document.getElementById('na'), sc = document.getElementById('sc'), - mins = document.getElementById('mins'), start = document.getElementById('start'); - const check = () => { start.disabled = !(na.value.trim() && sc.value.trim() && +mins.value > 0); }; - [na, sc, mins].forEach(el => el.oninput = check); - start.onclick = () => post('/commitment', { - next_action: na.value.trim(), - success_condition: sc.value.trim(), - timebox_secs: Math.round(+mins.value * 60), - allowed_window_classes: (document.getElementById('apps').value || '') - .split(',').map(s => s.trim()).filter(Boolean), - }); - document.getElementById('sharpen').onclick = () => { - const intent = document.getElementById('intent').value.trim(); - if (intent) post('/coach', { intent }); - }; - updatePlanningCoach(state.coach); - - } else if (rs === 'active') { - const c = state.commitment || {}; - view.innerHTML = `
    -
    --:--
    -
    -
    ${c.next_action || ''}
    -

    done when: ${c.success_condition || ''}

    -
    - ${evidenceBlock(state.evidence)} -
    `; - document.getElementById('done').onclick = () => post('/complete'); - const t = document.getElementById('t'); - const tick = () => { t.textContent = fmt((c.deadline_unix_secs || 0) - Date.now() / 1000); }; - tick(); - countdownTimer = setInterval(tick, 250); - updateActiveDrift(state); - - } else if (rs === 'review') { - const c = state.commitment || {}; - view.innerHTML = `
    Review
    -
    -
    Session ended
    -

    ${c.next_action || ''}

    -
    - ${evidenceBlock(state.evidence)} -
    `; - document.getElementById('end').onclick = () => post('/end'); - - } else { - view.textContent = rs; - } -} - -const es = new EventSource('/events'); -es.onmessage = (e) => render(JSON.parse(e.data)); -es.onerror = () => { view.textContent = 'disconnected β€” is antidriftd running?'; }; -``` - -- [ ] **Step 3: Run the web suite (regression guard)** - -Run: `go test ./internal/web/ -v` -Expected: PASS β€” all endpoint/asset tests still green (markup changed, contracts did not). - -- [ ] **Step 4: Manual visual check** - -Run the daemon and click through states: - -```bash -go run ./cmd/antidriftd -``` - -Verify in the browser (http://localhost:7777): -- Locked: gray accent, "Start planning". -- Planning: blue accent, all fields, Sharpen + Start. -- Active on-task: green accent, "Active Β· on task Β· N switches", big timer, evidence band. -- Drift (force by setting allowed apps then switching to an off-list window): red accent, reason + three buttons. -- Nudge (semantic nudge within an allowed app): amber accent, message + Dismiss; after Dismiss the accent returns to green. - -- [ ] **Step 5: Commit** - -```bash -git add internal/web/static/app.css internal/web/static/app.js -git commit -m "M4: cockpit HUD with state-driven accent - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 3: Polish the review screen into a session recap - -Turn the bare review state into a presentational summary built from data the state already carries (`commitment` + `evidence`). No new backend data. - -**Files:** -- Modify: `internal/web/static/app.js` (add `reviewSummary` helper; swap the review branch's markup) -- Modify: `internal/web/static/app.css` (`.summary` styles already added in Task 2 β€” verify, no change expected) -- Test: existing `internal/web/web_test.go` (regression only) - -- [ ] **Step 1: Add the `reviewSummary` helper to `app.js`** - -Insert this function immediately after `evidenceBlock` in `internal/web/static/app.js`: - -```js -// reviewSummary renders a presentational recap from already-available state: -// the commitment plus the per-window evidence buckets. No new backend data. -function reviewSummary(ev) { - if (!ev) return ''; - const rows = (ev.buckets || []).map(b => - `
    ${(b.class || '?')} Β· ${b.title || ''}${fmt(b.seconds)}
    `).join(''); - const switches = `
    context switches${ev.switch_count || 0}
    `; - return `
    ${switches}${rows}
    `; -} -``` - -- [ ] **Step 2: Swap the review branch markup to use the recap** - -In `internal/web/static/app.js`, find the review branch (the `else if (rs === 'review')` block) and replace its `view.innerHTML = …` assignment. Change FROM: - -```js - view.innerHTML = `
    Review
    -
    -
    Session ended
    -

    ${c.next_action || ''}

    -
    - ${evidenceBlock(state.evidence)} -
    `; -``` - -TO: - -```js - view.innerHTML = `
    Review
    -
    -
    Session ended
    -

    ${c.next_action || ''}

    -

    done when: ${c.success_condition || ''}

    -
    - ${reviewSummary(state.evidence)} -
    `; -``` - -(The verbose live `evidenceBlock` is replaced by the calmer `reviewSummary` recap; the "now / tracking" line belongs to the active HUD, not the post-session summary.) - -- [ ] **Step 3: Run the web suite (regression guard)** - -Run: `go test ./internal/web/ -v` -Expected: PASS. - -- [ ] **Step 4: Manual visual check of review** - -Start a short commitment (1 minute) with allowed apps, switch between a couple of windows, let it expire (or click Complete), and confirm the review screen shows: violet accent, "Session ended", the action + success condition, then a summary band listing context switches and the per-window time buckets, then End. - -- [ ] **Step 5: Final module verification** - -Run: `go vet ./... && go test -race ./...` -Expected: clean, all green. - -- [ ] **Step 6: Commit** - -```bash -git add internal/web/static/app.js internal/web/static/app.css -git commit -m "M4: presentational review recap - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## After all tasks - -Dispatch a final code reviewer over the three commits, then use **superpowers:finishing-a-development-branch** to close out M4. - -Done when: the UI renders the cockpit HUD across all six `data-state` values, CSS/JS are served as separate embedded assets, the review screen shows a recap, and `go vet ./... && go test -race ./...` is clean. diff --git a/docs/superpowers/plans/2026-05-31-m5-tasks-port.md b/docs/superpowers/plans/2026-05-31-m5-tasks-port.md deleted file mode 100644 index 10a2f14..0000000 --- a/docs/superpowers/plans/2026-05-31-m5-tasks-port.md +++ /dev/null @@ -1,794 +0,0 @@ -# M5 β€” Tasks Port Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `tasks.Provider` port with an Amazing Marvin adapter (`am --json`) that lists today's open tasks; surface them on the planning screen as clickable chips that seed the intent field. - -**Architecture:** A new leaf package `internal/tasks` (interface + primitives only, like `ai`) with a CLI adapter that shells out to ampy's `am --json`. The `session.Controller` fetches the list asynchronously on entering planning β€” mirroring the existing planning coach (generation counter, status enum, graceful degradation) β€” and projects it into the broadcast `State`. The browser renders the list during planning; clicking a task fills `#intent`, which flows into the unchanged coach pipeline. Read-only; no writeback; no new endpoints. - -**Tech Stack:** Go 1.26, stdlib `os/exec` + `encoding/json`, Gin + SSE (unchanged), vanilla JS/CSS, stdlib `testing`. - -**Reference:** Design spec at `docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md`. The patterns being mirrored: the `ai` port (`internal/ai/coach.go`, `internal/ai/backend.go`) and the planning coach in `internal/session/session.go` (`RequestCoach`, `resetCoachLocked`, `CoachView`). - ---- - -## File Structure - -- **Create `internal/tasks/tasks.go`** β€” the `Provider` interface and the `Task` value type. Leaf package; imports only `context`. -- **Create `internal/tasks/marvin.go`** β€” the `Marvin` adapter (shells out to `am --json`), an injectable `runner`, and the pure `parse` function. -- **Create `internal/tasks/marvin_test.go`** β€” `parse` table tests, command-splitting test, and adapter tests with a fake runner. -- **Modify `internal/session/session.go`** β€” add tasks fields, `TaskView`/`TasksView`, `State.Tasks`, `SetTasks`, `startTasksFetchLocked`, the planning-only projection, and the `EnterPlanning` hook. -- **Modify `internal/session/session_test.go`** β€” `fakeProvider`, `waitTasksStatus`, and four controller tests. -- **Modify `cmd/antidriftd/main.go`** β€” construct the Marvin adapter and call `ctrl.SetTasks`. -- **Modify `internal/web/web_test.go`** β€” `stubProvider` and a planning-payload-carries-tasks test. -- **Modify `internal/web/static/app.js`** β€” `updatePlanningTasks`, a tasks band in the planning render, and calls from both render paths. -- **Modify `internal/web/static/app.css`** β€” `.tasklist` / `.task-chip` styling. - ---- - -## Task 1: `tasks` package (port + Marvin adapter) - -**Files:** -- Create: `internal/tasks/tasks.go` -- Create: `internal/tasks/marvin.go` -- Test: `internal/tasks/marvin_test.go` - -- [ ] **Step 1: Write the port interface and value type** - -Create `internal/tasks/tasks.go`: - -```go -// Package tasks is the Tasks port: it answers "what should I be doing?" by -// listing the open to-do items due today or earlier. It imports nothing from -// the rest of the app, so it stays a leaf package. -package tasks - -import "context" - -// 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) -} -``` - -- [ ] **Step 2: Write the failing adapter tests** - -Create `internal/tasks/marvin_test.go`: - -```go -package tasks - -import ( - "context" - "errors" - "testing" -) - -func TestParse(t *testing.T) { - cases := []struct { - name string - in string - want []Task - wantErr bool - }{ - { - name: "valid array", - in: `[{"id":"a","title":"Write spec","parentId":"p","day":"2026-05-31","done":false}]`, - want: []Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}, - }, - {name: "empty array", in: `[]`, want: []Task{}}, - {name: "json null", in: `null`, want: []Task{}}, - { - name: "drops done and empty-title", - in: `[{"id":"a","title":"keep","day":"","done":false},{"id":"b","title":"done one","done":true},{"id":"c","title":" ","done":false}]`, - want: []Task{{ID: "a", Title: "keep", Day: ""}}, - }, - {name: "malformed", in: `not json`, wantErr: true}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := parse([]byte(tc.in)) - if tc.wantErr { - if err == nil { - t.Fatal("want error, got nil") - } - return - } - if err != nil { - t.Fatalf("parse: %v", err) - } - if len(got) != len(tc.want) { - t.Fatalf("len = %d, want %d (%+v)", len(got), len(tc.want), got) - } - for i := range got { - if got[i] != tc.want[i] { - t.Errorf("[%d] = %+v, want %+v", i, got[i], tc.want[i]) - } - } - }) - } -} - -func TestNewMarvinCommandSplitting(t *testing.T) { - cases := []struct { - in string - wantCmd string - wantArgs []string - }{ - {"", "am", []string{"--json"}}, - {"am", "am", []string{"--json"}}, - {"uv run am", "uv", []string{"run", "am", "--json"}}, - } - for _, tc := range cases { - m := NewMarvin(tc.in) - if m.cmd != tc.wantCmd { - t.Errorf("NewMarvin(%q).cmd = %q, want %q", tc.in, m.cmd, tc.wantCmd) - } - if len(m.args) != len(tc.wantArgs) { - t.Fatalf("NewMarvin(%q).args = %v, want %v", tc.in, m.args, tc.wantArgs) - } - for i := range m.args { - if m.args[i] != tc.wantArgs[i] { - t.Errorf("NewMarvin(%q).args[%d] = %q, want %q", tc.in, i, m.args[i], tc.wantArgs[i]) - } - } - } -} - -func TestTodayUsesRunnerOutput(t *testing.T) { - m := NewMarvin("am") - var gotName string - var gotArgs []string - m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { - gotName, gotArgs = name, args - return []byte(`[{"id":"x","title":"Do thing","day":"2026-05-31","done":false}]`), nil - } - got, err := m.Today(context.Background()) - if err != nil { - t.Fatalf("Today: %v", err) - } - if gotName != "am" || len(gotArgs) != 1 || gotArgs[0] != "--json" { - t.Fatalf("runner called with %q %v", gotName, gotArgs) - } - if len(got) != 1 || got[0].Title != "Do thing" { - t.Fatalf("Today result = %+v", got) - } -} - -func TestTodayPropagatesError(t *testing.T) { - m := NewMarvin("am") - m.run = func(ctx context.Context, name string, args ...string) ([]byte, error) { - return nil, errors.New("am not found") - } - if _, err := m.Today(context.Background()); err == nil { - t.Fatal("want error from failing runner") - } -} -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `go test ./internal/tasks/ -v` -Expected: FAIL β€” `undefined: NewMarvin`, `undefined: parse` (build error). - -- [ ] **Step 4: Write the adapter implementation** - -Create `internal/tasks/marvin.go`: - -```go -package tasks - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "strings" -) - -// runner executes a command and returns its stdout. Production shells out; -// tests inject a fake to avoid spawning a process. -type runner func(ctx context.Context, name string, args ...string) ([]byte, error) - -func execRunner(ctx context.Context, name string, args ...string) ([]byte, error) { - cmd := exec.CommandContext(ctx, name, args...) - var out, errb bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &errb - if err := cmd.Run(); err != nil { - if s := strings.TrimSpace(errb.String()); s != "" { - return nil, fmt.Errorf("%s: %w: %s", name, err, s) - } - return nil, fmt.Errorf("%s: %w", name, err) - } - return out.Bytes(), nil -} - -// Marvin is the Amazing Marvin adapter. It shells out to ampy's CLI -// (`am --json`, no subcommand) and parses the open tasks due today or earlier. -type Marvin struct { - cmd string - args []string - run runner -} - -// NewMarvin builds the adapter. command is split on spaces so both "am" and -// "uv run am" work; empty defaults to "am". The "--json" flag is always -// appended, and no subcommand is passed (ampy lists today's tasks by default). -func NewMarvin(command string) *Marvin { - fields := strings.Fields(command) - if len(fields) == 0 { - fields = []string{"am"} - } - args := append([]string{}, fields[1:]...) - args = append(args, "--json") - return &Marvin{cmd: fields[0], args: args, run: execRunner} -} - -// Today returns the open tasks due today or earlier, or an error if the CLI -// fails. Callers degrade gracefully on error. -func (m *Marvin) Today(ctx context.Context) ([]Task, error) { - out, err := m.run(ctx, m.cmd, m.args...) - if err != nil { - return nil, err - } - return parse(out) -} - -type rawTask struct { - ID string `json:"id"` - Title string `json:"title"` - Day string `json:"day"` - Done bool `json:"done"` -} - -// parse maps `am --json` output (a JSON array of task objects) to []Task. It -// drops tasks that are done or have an empty title. A JSON null or empty array -// yields an empty slice, not an error. -func parse(data []byte) ([]Task, error) { - var raw []rawTask - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("tasks: parse: %w", err) - } - out := make([]Task, 0, len(raw)) - for _, r := range raw { - title := strings.TrimSpace(r.Title) - if r.Done || title == "" { - continue - } - out = append(out, Task{ID: r.ID, Title: title, Day: r.Day}) - } - return out, nil -} -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `go test ./internal/tasks/ -v` -Expected: PASS β€” `TestParse`, `TestNewMarvinCommandSplitting`, `TestTodayUsesRunnerOutput`, `TestTodayPropagatesError`. - -- [ ] **Step 6: Commit** - -```bash -git add internal/tasks/tasks.go internal/tasks/marvin.go internal/tasks/marvin_test.go -git commit -m "$(cat <<'EOF' -Add tasks port and Amazing Marvin adapter - -The tasks.Provider port answers "what should I be doing?". The Marvin -adapter shells out to ampy's `am --json` and parses today's open tasks, -dropping done/empty-title entries. Leaf package mirroring ai. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Task 2: Controller wiring (async fetch + projection) - -**Files:** -- Modify: `internal/session/session.go` -- Test: `internal/session/session_test.go` - -- [ ] **Step 1: Write the failing controller tests** - -Append to `internal/session/session_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add: - -```go -type fakeProvider struct { - list []tasks.Task - err error - gate chan struct{} // if non-nil, Today blocks until it receives -} - -func (f *fakeProvider) Today(ctx context.Context) ([]tasks.Task, error) { - if f.gate != nil { - <-f.gate - } - return f.list, f.err -} - -// waitTasksStatus polls until the tasks view reaches want, or fails after 2s. -func waitTasksStatus(t *testing.T, c *Controller, want string) State { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - st := c.State() - if st.Tasks != nil && st.Tasks.Status == want { - return st - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("tasks status never reached %q (last: %+v)", want, c.State().Tasks) - return State{} -} - -func TestEnterPlanningFetchesTasks(t *testing.T) { - c, _ := newTestController(t) - c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "Write spec", Day: "2026-05-31"}}}) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - st := waitTasksStatus(t, c, "ready") - if len(st.Tasks.Tasks) != 1 || st.Tasks.Tasks[0].Title != "Write spec" { - t.Fatalf("tasks list wrong: %+v", st.Tasks) - } -} - -func TestTasksFetchError(t *testing.T) { - c, _ := newTestController(t) - c.SetTasks(&fakeProvider{err: errors.New("am down")}) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - st := waitTasksStatus(t, c, "error") - if len(st.Tasks.Tasks) != 0 { - t.Fatalf("error state should carry no tasks: %+v", st.Tasks) - } -} - -func TestNoProviderNoTasksView(t *testing.T) { - c, _ := newTestController(t) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - if c.State().Tasks != nil { - t.Fatalf("nil provider should yield no tasks view: %+v", c.State().Tasks) - } -} - -func TestTasksViewAbsentOutsidePlanning(t *testing.T) { - c, _ := newTestController(t) - c.SetTasks(&fakeProvider{list: []tasks.Task{{ID: "a", Title: "x"}}}) - if c.State().Tasks != nil { - t.Fatalf("no tasks view while Locked: %+v", c.State().Tasks) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v` -Expected: FAIL β€” `c.SetTasks undefined` and `st.Tasks undefined` (build error). - -- [ ] **Step 3: Add the import and constants** - -In `internal/session/session.go`, add `"antidrift/internal/tasks"` to the import block (it already imports `"antidrift/internal/ai"`). - -Immediately after the existing coach constants block (the `const ( coachIdle = "idle" … )` group near the top of the file), add: - -```go -const tasksTimeout = 30 * time.Second - -const ( - tasksIdle = "idle" - tasksPending = "pending" - tasksReady = "ready" - tasksError = "error" -) -``` - -- [ ] **Step 4: Add the controller fields** - -In the `Controller` struct, immediately after the coach fields (`coachGen int`), add: - -```go - tasksProvider tasks.Provider - tasksStatus string - tasksList []tasks.Task - tasksGen int -``` - -(Field is named `tasksProvider`, not `tasks`, so it does not shadow the `tasks` package.) - -- [ ] **Step 5: Add the view types** - -Immediately after the `CoachView` struct definition, add: - -```go -// TaskView is one to-do item in the planning tasks list. -type TaskView struct { - ID string `json:"id"` - Title string `json:"title"` - Day string `json:"day,omitempty"` -} - -// TasksView projects the ephemeral planning tasks state (Marvin's today list). -type TasksView struct { - Status string `json:"status"` - Tasks []TaskView `json:"tasks,omitempty"` -} -``` - -In the `State` struct, add a field immediately after `Coach *CoachView … `: - -```go - Tasks *TasksView `json:"tasks,omitempty"` -``` - -- [ ] **Step 6: Add the planning-only projection** - -In `stateLocked`, inside the existing `if c.runtimeState == domain.RuntimePlanning {` block, immediately after `st.Coach = cv`, add: - -```go - if c.tasksProvider != nil { - tstatus := c.tasksStatus - if tstatus == "" { - tstatus = tasksIdle - } - tv := &TasksView{Status: tstatus} - for _, t := range c.tasksList { - tv.Tasks = append(tv.Tasks, TaskView{ID: t.ID, Title: t.Title, Day: t.Day}) - } - st.Tasks = tv - } -``` - -- [ ] **Step 7: Add SetTasks and the async fetch** - -Immediately after `resetCoachLocked` (so the tasks helpers sit next to the coach helpers), add: - -```go -// SetTasks injects the Tasks provider. Mirrors SetCoach. A nil provider keeps -// the planning tasks list absent. -func (c *Controller) SetTasks(p tasks.Provider) { - c.mu.Lock() - c.tasksProvider = p - c.mu.Unlock() -} - -// startTasksFetchLocked kicks off an asynchronous Today() fetch when a provider -// is set. Mirrors RequestCoach: generation-guarded, discards stale or -// post-planning results, and notifies on completion. Caller holds mu. -func (c *Controller) startTasksFetchLocked() { - c.tasksList = nil - if c.tasksProvider == nil { - c.tasksStatus = tasksIdle - return - } - c.tasksGen++ - gen := c.tasksGen - c.tasksStatus = tasksPending - provider := c.tasksProvider - go func() { - ctx, cancel := context.WithTimeout(context.Background(), tasksTimeout) - defer cancel() - list, err := provider.Today(ctx) - - c.mu.Lock() - if gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning { - c.mu.Unlock() - return // stale or left planning: discard - } - if err != nil { - c.tasksStatus = tasksError - c.tasksList = nil - } else { - c.tasksStatus = tasksReady - c.tasksList = list - } - c.mu.Unlock() - c.notify() - }() -} -``` - -(`context` is already imported in `session.go`.) - -- [ ] **Step 8: Hook the fetch into EnterPlanning** - -In `EnterPlanning`, add `c.startTasksFetchLocked()` immediately after the existing `c.resetCoachLocked()` line: - -```go - c.runtimeState = next - c.resetCoachLocked() - c.startTasksFetchLocked() - return c.persistLocked() -``` - -(Launching the goroutine while holding `mu` is safe: its first action is the `Today()` call, so it only blocks on the lock after `EnterPlanning` has returned and released it.) - -- [ ] **Step 9: Run the tests to verify they pass** - -Run: `go test ./internal/session/ -run 'Tasks|FetchesTasks' -v` -Expected: PASS β€” `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`. - -- [ ] **Step 10: Run the full session suite (no regressions, race-clean)** - -Run: `go test -race ./internal/session/` -Expected: PASS (existing coach/drift/nudge tests unaffected β€” `EnterPlanning` with no provider just sets the idle status). - -- [ ] **Step 11: Commit** - -```bash -git add internal/session/session.go internal/session/session_test.go -git commit -m "$(cat <<'EOF' -Fetch today's tasks asynchronously on entering planning - -The controller fetches the Marvin task list when entering planning, -mirroring the planning coach: generation-guarded goroutine, status enum -(idle/pending/ready/error), projected into State only while planning and -only when a provider is set. nil provider degrades to no tasks view. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Task 3: Daemon wiring + web payload test - -**Files:** -- Modify: `cmd/antidriftd/main.go` -- Test: `internal/web/web_test.go` - -- [ ] **Step 1: Write the failing web test** - -Append to `internal/web/web_test.go`. Add `"antidrift/internal/tasks"` to the import block first, then add: - -```go -type stubProvider struct { - list []tasks.Task -} - -func (s stubProvider) Today(ctx context.Context) ([]tasks.Task, error) { - return s.list, nil -} - -func TestPlanningStatePayloadCarriesTasks(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetTasks(stubProvider{list: []tasks.Task{{ID: "a", Title: "Write the spec", Day: "2026-05-31"}}}) - r := s.Router() - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { - t.Fatalf("/planning code %d", w.Code) - } - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if tv := s.ctrl.State().Tasks; tv != nil && tv.Status == "ready" { - break - } - time.Sleep(5 * time.Millisecond) - } - js := s.stateJSON() - if !strings.Contains(js, `"tasks"`) { - t.Fatalf("planning payload missing tasks object: %s", js) - } - if !strings.Contains(js, `"title":"Write the spec"`) { - t.Fatalf("planning payload missing task title: %s", js) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v` -Expected: FAIL β€” `undefined: tasks` / `s.ctrl.SetTasks undefined` until the import resolves and the controller exposes the method (the method exists from Task 2, so the only failure here would be a missing import; if Task 2 is complete this compiles and the assertion drives correctness). - -- [ ] **Step 3: Wire the adapter into the daemon** - -In `cmd/antidriftd/main.go`, add `"antidrift/internal/tasks"` to the import block. Then, immediately after the AI-wiring block (after the `if backend, err := ai.NewBackend(...) { … } else { … }` block closes), add: - -```go - // Wire the Tasks port: Amazing Marvin via ampy's `am` CLI. The command is - // overridable with ANTIDRIFT_MARVIN_CMD (e.g. "uv run am" or an absolute - // path). A missing or failing CLI degrades to no tasks panel at fetch time β€” - // planning still works. - ctrl.SetTasks(tasks.NewMarvin(os.Getenv("ANTIDRIFT_MARVIN_CMD"))) - log.Printf("tasks: marvin adapter (am)") -``` - -(`os` and `log` are already imported in `main.go`.) - -- [ ] **Step 4: Run the web test to verify it passes** - -Run: `go test ./internal/web/ -run TestPlanningStatePayloadCarriesTasks -v` -Expected: PASS. - -- [ ] **Step 5: Verify the daemon builds** - -Run: `go build ./cmd/antidriftd/` -Expected: builds with no output. - -- [ ] **Step 6: Commit** - -```bash -git add cmd/antidriftd/main.go internal/web/web_test.go -git commit -m "$(cat <<'EOF' -Wire Marvin tasks adapter into the daemon - -main constructs the Marvin adapter (command overridable via -ANTIDRIFT_MARVIN_CMD) and injects it. Adds a web test asserting the -planning state payload carries today's tasks. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Task 4: Planning UI β€” today's tasks as seed chips - -**Files:** -- Modify: `internal/web/static/app.js` -- Modify: `internal/web/static/app.css` - -This task is presentational; the data path is already covered by the Task 3 web test. There is no JS test harness in this project (consistent with M4), so verify by build/test plus the manual checklist below. - -- [ ] **Step 1: Add the `updatePlanningTasks` function** - -In `internal/web/static/app.js`, immediately after the `updatePlanningCoach` function definition (before `function render(state)`), add: - -```js -// updatePlanningTasks renders today's Marvin tasks as clickable seed chips. -// Clicking a chip fills the intent field; the coach pipeline is unchanged. -// idle/error/empty render nothing; pending shows a quiet loading line. -function updatePlanningTasks(tasks) { - const el = document.getElementById('tasksBand'); - if (!el) return; - if (!tasks || tasks.status === 'idle' || tasks.status === 'error') { el.innerHTML = ''; return; } - if (tasks.status === 'pending') { el.innerHTML = `
    loading tasks…
    `; return; } - const list = tasks.tasks || []; - if (!list.length) { el.innerHTML = ''; return; } - const chips = list.map((t, i) => - ``).join(''); - el.innerHTML = `
    ${chips}
    `; - el.querySelectorAll('.task-chip').forEach(btn => { - btn.onclick = () => { - const intent = document.getElementById('intent'); - if (intent) { intent.value = list[+btn.dataset.i].title; intent.focus(); } - }; - }); -} -``` - -(Task titles are interpolated into `innerHTML` exactly as the existing coach/evidence rendering does. The titles originate from the user's own Marvin database; this carries the same pre-existing self-XSS posture as the rest of the UI and introduces no new untrusted source β€” out of scope to change here.) - -- [ ] **Step 2: Add the tasks band to the planning render** - -In the planning branch of `render` (`} else if (rs === 'planning') {`), add a tasks band placeholder between the intent band and the "Next action" band. Change: - -```js -
    - - - -
    -
    -
    - -``` - -to: - -```js -
    - - - -
    -
    -
    -
    - -``` - -- [ ] **Step 3: Call `updatePlanningTasks` from both render paths** - -In the planning partial-update early-return at the top of `render`, change: - -```js - if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); - return; - } -``` - -to: - -```js - if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - return; - } -``` - -And at the end of the full planning render branch, change the trailing: - -```js - updatePlanningCoach(state.coach); - - } else if (rs === 'active') { -``` - -to: - -```js - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - - } else if (rs === 'active') { -``` - -- [ ] **Step 4: Add the chip styling** - -Append to `internal/web/static/app.css`: - -```css -/* Planning: today's tasks as clickable seed chips */ -.tasklist { display: flex; flex-wrap: wrap; gap: 8px; } -.task-chip { - padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; - background: var(--bg); color: var(--ink); font: inherit; font-size: 13px; - cursor: pointer; text-align: left; -} -.task-chip:hover { border-color: var(--accent); color: var(--accent); } -``` - -- [ ] **Step 5: Verify assets still serve and the full suite is race-clean** - -Run: `go vet ./... && go test -race ./...` -Expected: PASS across all packages (the embedded-asset and planning-payload tests cover the wiring; JS/CSS are presentational). - -- [ ] **Step 6: Manual visual check (by eye)** - -Build and run the daemon (`go run ./cmd/antidriftd/`) with `am` available, click **Start planning**, and confirm: a "Today" band shows task chips; clicking a chip fills the intent field; **Sharpen** then drives the coach as before. With `am` absent, the band stays empty and planning works normally. - -- [ ] **Step 7: Commit** - -```bash -git add internal/web/static/app.js internal/web/static/app.css -git commit -m "$(cat <<'EOF' -Show today's tasks as seed chips on the planning screen - -The planning view renders Marvin's today list as clickable chips; -clicking one fills the intent field, feeding the existing coach flow. -idle/error/empty render nothing, pending shows a quiet loading line. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Final Verification - -After all tasks: - -- [ ] `go vet ./... && go test -race ./...` is clean. -- [ ] `internal/tasks` imports only `context`, `encoding/json`, and stdlib `os/exec`/`bytes`/`fmt`/`strings` β€” nothing from `domain`, `session`, `evidence`, or `web` (leaf package preserved). -- [ ] No new POST routes; the seed click is client-only. -- [ ] No writeback to Marvin (read-only, per spec Β§7). diff --git a/docs/superpowers/plans/2026-06-01-faithful-reflection.md b/docs/superpowers/plans/2026-06-01-faithful-reflection.md deleted file mode 100644 index 7c29c10..0000000 --- a/docs/superpowers/plans/2026-06-01-faithful-reflection.md +++ /dev/null @@ -1,458 +0,0 @@ -# Faithful Reflection (on/off-task split) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the session-end reflection block report how much time was on-task vs off-task, so the AI reviewer stops writing charitable recaps. - -**Architecture:** Each window segment is classified at the moment it is credited, reading the live `driftStatus` that is already correct at that instant (in `RecordWindow`, `applyEvent` credits the just-ended segment *before* `evaluateDriftLocked` reclassifies for the new window). The split is accumulated in two new per-bucket maps plus one scalar on the in-memory `EvidenceStats`; the existing `Buckets` total map is untouched. `buildReflectionFinishedLocked` then renders on/off/unclassified totals plus a top-N on-task list and a top-N off-task list. - -**Tech Stack:** Go, standard library only. Tests use the existing `testing` package with the package-local `fakeClock`, `fakeJudge`, `snap`/`obs`, `startActive`, and `waitDriftStatus` helpers in `internal/session/session_test.go`. - -**Spec:** `docs/superpowers/specs/2026-06-01-faithful-reflection-design.md` - ---- - -## File Structure - -- `internal/session/stats.go` β€” `EvidenceStats` gains the split fields; new `creditLocked` helper centralizes crediting; `applyEvent` routes through it. Allocation of the new maps added to both `replayStats` branches. -- `internal/session/session.go` β€” allocate the split maps in `StartManualCommitment`; route the end-of-session flush in `enterReview` through `creditLocked`. -- `internal/session/roles.go` β€” `buildReflectionFinishedLocked` renders totals + split top lists; small `writeBucketList` / `sumDurations` helpers added alongside it. -- `internal/session/session_test.go` β€” three tests: `creditLocked` unit mapping, rendering (incl. empty-list omission), and end-to-end credit-time wiring. - -Three tasks, each self-contained and committed independently: - -1. **Split accounting** β€” fields, allocation, `creditLocked`, route both credit sites. (Driven by the `creditLocked` unit test.) -2. **Reflection rendering** β€” rewrite `buildReflectionFinishedLocked`. (Driven by a rendering unit test, including empty-off-task omission.) -3. **End-to-end faithfulness** β€” a test-only task proving the live drift status reaches the right bucket through a real `RecordWindow` sequence. - ---- - -## Task 1: Split accounting in EvidenceStats - -Add the on/off/unclassified accumulators and a single crediting helper, then route the two existing credit sites through it. The existing `Buckets` total map is preserved unchanged (it still feeds the live evidence panel and the persisted history summary). - -**Files:** -- Modify: `internal/session/stats.go` (struct fields ~13-22, `applyEvent` ~27-39, `replayStats` ~42-60) -- Modify: `internal/session/session.go` (`StartManualCommitment` stats init ~248-252, `enterReview` flush ~280-283) -- Test: `internal/session/session_test.go` (new test appended) - -- [ ] **Step 1: Write the failing test** - -Append to `internal/session/session_test.go`: - -```go -func TestCreditLockedSplitsByDriftStatus(t *testing.T) { - c, _ := newTestController(t) - c.stats = &EvidenceStats{ - Buckets: map[bucketKey]time.Duration{}, - OnTask: map[bucketKey]time.Duration{}, - OffTask: map[bucketKey]time.Duration{}, - } - k := bucketKey{Class: "code", Title: "main.go"} - - c.driftStatus = driftOnTask - c.creditLocked(k, 10*time.Second) - c.driftStatus = driftDrifting - c.creditLocked(k, 5*time.Second) - c.driftStatus = driftIdle - c.creditLocked(k, 3*time.Second) - c.driftStatus = driftPending - c.creditLocked(k, 2*time.Second) - - if got := c.stats.OnTask[k]; got != 10*time.Second { - t.Errorf("OnTask = %v, want 10s", got) - } - if got := c.stats.OffTask[k]; got != 5*time.Second { - t.Errorf("OffTask = %v, want 5s", got) - } - if got := c.stats.unclassified; got != 5*time.Second { // 3s idle + 2s pending - t.Errorf("unclassified = %v, want 5s", got) - } - if got := c.stats.Buckets[k]; got != 20*time.Second { // total of all four - t.Errorf("Buckets total = %v, want 20s", got) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/session/ -run TestCreditLockedSplitsByDriftStatus` -Expected: FAIL β€” compile error: `c.stats.OnTask` / `c.stats.OffTask` / `c.stats.unclassified` undefined and `c.creditLocked` undefined. - -- [ ] **Step 3: Add the split fields to `EvidenceStats`** - -In `internal/session/stats.go`, replace the struct (currently lines 12-22): - -```go -// EvidenceStats is the in-memory accounting for the current session only. -type EvidenceStats struct { - SessionID string - StartedUnix int64 - Buckets map[bucketKey]time.Duration // total time per bucket (unchanged) - OnTask map[bucketKey]time.Duration // on-task portion per bucket - OffTask map[bucketKey]time.Duration // off-task portion per bucket - unclassified time.Duration // idle/pending time; aggregate only - - SwitchCount int - Current evidence.WindowSnapshot - lastFocusAt time.Time - lastKey bucketKey - hasLast bool -} -``` - -- [ ] **Step 4: Add `creditLocked` and route `applyEvent` through it** - -In `internal/session/stats.go`, change the credit line in `applyEvent` (currently lines 28-30) and add the helper. The `applyEvent` body becomes: - -```go -func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) { - if c.stats.hasLast { - c.creditLocked(c.stats.lastKey, now.Sub(c.stats.lastFocusAt)) - } - newKey := keyFor(snap) - if c.stats.hasLast && newKey != c.stats.lastKey { - c.stats.SwitchCount++ - } - c.stats.lastKey = newKey - c.stats.lastFocusAt = now - c.stats.hasLast = true - c.stats.Current = snap -} - -// creditLocked credits duration d to bucket k: always to the running total, and -// to the on/off/unclassified split per the live drift status β€” which, at every -// credit site, is the classification of the segment being credited (applyEvent -// runs before evaluateDriftLocked reclassifies; the end-of-session flush runs -// before the state transition). idle/pending route to unclassified: honest, never -// falsely on-task. Caller holds mu. -func (c *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 - } -} -``` - -- [ ] **Step 5: Allocate the split maps everywhere `Buckets` is allocated** - -There are three allocation sites. The maps must be non-nil before `creditLocked` runs. - -In `internal/session/session.go`, `StartManualCommitment` (currently lines 248-252): - -```go - c.stats = &EvidenceStats{ - SessionID: sessionID, - StartedUnix: now.Unix(), - Buckets: map[bucketKey]time.Duration{}, - OnTask: map[bucketKey]time.Duration{}, - OffTask: map[bucketKey]time.Duration{}, - } -``` - -In `internal/session/stats.go`, `replayStats` β€” the empty/error branch (currently lines 45-50): - -```go - c.stats = &EvidenceStats{ - SessionID: sessionID, - StartedUnix: c.clock().Unix(), - Buckets: map[bucketKey]time.Duration{}, - OnTask: map[bucketKey]time.Duration{}, - OffTask: map[bucketKey]time.Duration{}, - } - return -``` - -and the replay branch (currently lines 52-56): - -```go - c.stats = &EvidenceStats{ - SessionID: sessionID, - StartedUnix: events[0].AtUnixMillis / 1000, - Buckets: map[bucketKey]time.Duration{}, - OnTask: map[bucketKey]time.Duration{}, - OffTask: map[bucketKey]time.Duration{}, - } -``` - -- [ ] **Step 6: Route the end-of-session flush through `creditLocked`** - -In `internal/session/session.go`, `enterReview` flush (currently lines 280-283): - -```go - // Flush the final open segment, then freeze accounting. - if c.stats != nil && c.stats.hasLast { - c.creditLocked(c.stats.lastKey, c.clock().Sub(c.stats.lastFocusAt)) - c.stats.hasLast = false - } -``` - -- [ ] **Step 7: Run the new test and the full session suite** - -Run: `go test ./internal/session/ -run TestCreditLockedSplitsByDriftStatus` -Expected: PASS - -Run: `go test ./internal/session/` -Expected: PASS β€” all existing tests (bucket totals, crash replay, audit summary) unaffected, since `Buckets` is unchanged. - -- [ ] **Step 8: Commit** - -```bash -git add internal/session/stats.go internal/session/session.go internal/session/session_test.go -git commit -m "Split session time into on/off/unclassified buckets" -``` - ---- - -## Task 2: Render the split in the reflection block - -Rewrite `buildReflectionFinishedLocked` to emit on/off/unclassified totals followed by a top-N on-task list and a top-N off-task list, reusing the existing `bucketViews` sorter. An empty list (and its label) is omitted. - -**Files:** -- Modify: `internal/session/roles.go` (`buildReflectionFinishedLocked` ~269-292) -- Test: `internal/session/session_test.go` (new test appended) - -- [ ] **Step 1: Write the failing test** - -Append to `internal/session/session_test.go`. This test sets the split maps directly on a started session, so it is deterministic and independent of drift timing: - -```go -func TestReflectionBlockShowsOnOffSplit(t *testing.T) { - c, _ := newTestController(t) - startActive(t, c, nil) // sets commitment ("write report" / "report drafted") and stats - - c.stats.SwitchCount = 2 - c.stats.OnTask = map[bucketKey]time.Duration{ - {Class: "code", Title: "main.go"}: 20 * time.Minute, - } - c.stats.OffTask = map[bucketKey]time.Duration{ - {Class: "firefox", Title: "YouTube"}: 8 * time.Minute, - {Class: "firefox", Title: "Reddit"}: 4 * time.Minute, - } - c.stats.unclassified = 5 * time.Minute - c.outcomePending = "completed" - - c.mu.Lock() - got := c.buildReflectionFinishedLocked() - c.mu.Unlock() - - want := "Next action: write report\n" + - "Success condition: report drafted\n" + - "Outcome: completed\n" + - "On-task 20m / Off-task 12m / Unclassified 5m\n" + - "Context switches: 2\n" + - "On-task:\n" + - "- code Β· main.go: 20m\n" + - "Off-task:\n" + - "- firefox Β· YouTube: 8m\n" + - "- firefox Β· Reddit: 4m" - if got != want { - t.Errorf("reflection block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) - } -} - -func TestReflectionBlockOmitsEmptyOffTaskList(t *testing.T) { - c, _ := newTestController(t) - startActive(t, c, nil) - - c.stats.OnTask = map[bucketKey]time.Duration{ - {Class: "code", Title: "main.go"}: 20 * time.Minute, - } - c.stats.OffTask = map[bucketKey]time.Duration{} // none - c.stats.unclassified = 0 - c.outcomePending = "completed" - - c.mu.Lock() - got := c.buildReflectionFinishedLocked() - c.mu.Unlock() - - if strings.Contains(got, "Off-task:") { - t.Errorf("fully on-task session must omit the Off-task list, got:\n%s", got) - } - if !strings.Contains(got, "On-task 20m / Off-task 0m / Unclassified 0m") { - t.Errorf("totals line missing or wrong, got:\n%s", got) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `go test ./internal/session/ -run TestReflectionBlock` -Expected: FAIL β€” the current block renders top buckets from `c.stats.Buckets` (e.g. lines like `- Β· : 0m`) and has no `On-task NNm / Off-task NNm` totals line, so the string comparison fails and `Off-task:` is absent for the wrong reason in the second test (verify the first test fails on the totals line). - -- [ ] **Step 3: Rewrite `buildReflectionFinishedLocked`** - -In `internal/session/roles.go`, replace the function (currently lines 265-292) with: - -```go -// buildReflectionFinishedLocked renders the just-finished session as a compact -// block for the reviewer: the commitment, the outcome, on/off/unclassified time -// totals, and a top-N on-task list and top-N off-task list. The split fields are -// populated live by creditLocked. Caller holds mu; c.stats/c.commitment are still -// set (End clears them, but enterReview runs before End). -func (c *Controller) buildReflectionFinishedLocked() string { - var na, sc string - if c.commitment != nil { - na, sc = c.commitment.NextAction, c.commitment.SuccessCondition - } - outcome := c.outcomePending - if outcome == "" { - outcome = "completed" - } - var b strings.Builder - fmt.Fprintf(&b, "Next action: %s\n", na) - fmt.Fprintf(&b, "Success condition: %s\n", sc) - fmt.Fprintf(&b, "Outcome: %s\n", outcome) - if c.stats != nil { - onMin := int64(sumDurations(c.stats.OnTask).Seconds()) / 60 - offMin := int64(sumDurations(c.stats.OffTask).Seconds()) / 60 - unclMin := int64(c.stats.unclassified.Seconds()) / 60 - fmt.Fprintf(&b, "On-task %dm / Off-task %dm / Unclassified %dm\n", onMin, offMin, unclMin) - fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount) - writeBucketList(&b, "On-task", c.stats.OnTask) - writeBucketList(&b, "Off-task", c.stats.OffTask) - } - return strings.TrimRight(b.String(), "\n") -} - -// writeBucketList renders a labeled, time-descending list of buckets capped at -// reflectionTopBuckets. It writes nothing β€” not even the label β€” when the map is -// empty, so a single-sided session shows only the list that has time. -func writeBucketList(b *strings.Builder, label string, m map[bucketKey]time.Duration) { - views := bucketViews(m) - if len(views) == 0 { - return - } - fmt.Fprintf(b, "%s:\n", label) - for i, bv := range views { - if i >= reflectionTopBuckets { - break - } - fmt.Fprintf(b, "- %s Β· %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60) - } -} - -// sumDurations totals the durations in a bucket map. -func sumDurations(m map[bucketKey]time.Duration) time.Duration { - var total time.Duration - for _, d := range m { - total += d - } - return total -} -``` - -Note: `bucketViews` (in `views.go`) already returns a `[]BucketView` sorted descending by seconds, so it serves both lists. The old loop over `bucketViews(c.stats.Buckets)` is gone; `c.stats.Buckets` is no longer read here (it remains in use by `views.go` and the audit summary). - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `go test ./internal/session/ -run TestReflectionBlock` -Expected: PASS - -Run: `go test ./internal/session/` -Expected: PASS β€” the existing reflection tests (`TestReflectionFetchedOnReview` etc.) assert on the reviewer's `Recap`, not the finished-block text, so they are unaffected. - -- [ ] **Step 5: Commit** - -```bash -git add internal/session/roles.go internal/session/session_test.go -git commit -m "Render on/off-task split in the reflection block" -``` - ---- - -## Task 3: End-to-end faithfulness through RecordWindow - -A test-only task: prove that the live drift status produced by the real drift pipeline reaches the correct split bucket. This is the crux of "faithful" β€” it exercises the credit-time ordering (`applyEvent` before `evaluateDriftLocked`) and the async drift judge, which the direct unit tests in Tasks 1-2 deliberately bypass. - -**Files:** -- Test: `internal/session/session_test.go` (new test appended) - -- [ ] **Step 1: Write the test** - -The sequence: the seed segment accrues while `idle` (unclassified, because the seed never runs the drift pipeline), then on-task code time accrues via the local allowlist match, then off-task firefox time accrues after the async judge returns drifting. `waitDriftStatus` ensures the verdict has landed before the segment that follows is credited, so the classification is deterministic despite the async judge. Durations are in minutes so the integer-minute rendering is exact and the two off-task buckets differ (no sort tie). - -```go -func TestRecordWindowCreditsSplitFaithfully(t *testing.T) { - c, _ := newTestController(t) - clk := &fakeClock{now: time.Unix(1000, 0)} - c.SetClock(clk.fn()) - c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off"}}) - - c.RecordWindow(snap("code", "main.go")) // latest window before start - startActive(t, c, []string{"code"}) // seeds code/main.go at t=1000, driftStatus idle - - clk.advance(5 * time.Minute) - c.RecordWindow(snap("code", "main.go")) // credits 5m to code/main.go while idle -> unclassified; now on-task (local match) - clk.advance(10 * time.Minute) - c.RecordWindow(snap("code", "main.go")) // credits 10m on-task - clk.advance(10 * time.Minute) - c.RecordWindow(snap("firefox", "YouTube")) // credits 10m on-task (total 20m); firefox -> judge (pending) - waitDriftStatus(t, c, "drifting") // wait for the async verdict before crediting the firefox segment - - clk.advance(8 * time.Minute) - c.RecordWindow(snap("firefox", "Reddit")) // credits 8m to firefox/YouTube while drifting -> off-task; cache keeps drifting - clk.advance(4 * time.Minute) - if err := c.Complete(); err != nil { // flush: credits 4m to firefox/Reddit while drifting -> off-task - t.Fatalf("complete: %v", err) - } - - c.mu.Lock() - got := c.buildReflectionFinishedLocked() - c.mu.Unlock() - - want := "Next action: write report\n" + - "Success condition: report drafted\n" + - "Outcome: completed\n" + - "On-task 20m / Off-task 12m / Unclassified 5m\n" + - "Context switches: 2\n" + - "On-task:\n" + - "- code Β· main.go: 20m\n" + - "Off-task:\n" + - "- firefox Β· YouTube: 8m\n" + - "- firefox Β· Reddit: 4m" - if got != want { - t.Errorf("faithful split mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) - } -} -``` - -- [ ] **Step 2: Run the test** - -Run: `go test ./internal/session/ -run TestRecordWindowCreditsSplitFaithfully` -Expected: PASS β€” Tasks 1 and 2 already supply the behavior; this test asserts the wiring end-to-end. - -If it FAILS on the `Unclassified 5m` portion, that confirms the credit-time ordering is being read correctly (the seed segment is genuinely idle); do not "fix" it by pre-classifying the seed β€” that unclassified slice is the honest result and is asserted on purpose. - -- [ ] **Step 3: Run the full suite with the race detector** - -Run: `go test -race ./internal/session/` -Expected: PASS, no race warnings. (The test spawns the real drift-judge goroutine; `waitDriftStatus` synchronizes before the next credit, and `buildReflectionFinishedLocked` is read under `c.mu`.) - -- [ ] **Step 4: Commit** - -```bash -git add internal/session/session_test.go -git commit -m "Test faithful on/off-task crediting through RecordWindow" -``` - ---- - -## Final verification - -After all three tasks: - -- [ ] Run: `go build ./...` β€” Expected: success. -- [ ] Run: `go vet ./...` β€” Expected: no diagnostics. -- [ ] Run: `go test ./...` β€” Expected: all packages PASS. - -## Out of scope (do not implement) - -- Persisting the split to the focus log or audit/history summary (no cross-restart reconstruction). -- Per-bucket unclassified breakdown (only the aggregate is shown). -- Any change to the live evidence panel (`views.go`), the drift/nudge pipeline, the reviewer prompt contract beyond the finished-block text, or the web UI. diff --git a/docs/superpowers/plans/2026-06-01-m6-knowledge-port.md b/docs/superpowers/plans/2026-06-01-m6-knowledge-port.md deleted file mode 100644 index f84f712..0000000 --- a/docs/superpowers/plans/2026-06-01-m6-knowledge-port.md +++ /dev/null @@ -1,1162 +0,0 @@ -# M6 β€” Knowledge Port Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `knowledge.Source` port with a single-config-file adapter that -loads the user's standing profile (`~/.antidrift/knowledge.md`, overridable via -`ANTIDRIFT_KNOWLEDGE_FILE`). The `session.Controller` loads it asynchronously on -entering planning β€” mirroring the tasks fetch β€” caches the text, and threads it -into the AI **coach** prompt as grounding. A subtle planning-screen indicator -shows whether the profile loaded and from where, and a single new route lets the -user point at a different file. Coach-only; read-only; graceful degradation. - -**Architecture:** A new leaf package `internal/knowledge` (interface + -file adapter, like `tasks`) returning a `Profile{Text, Path}`. The controller -gains tasks-shaped wiring (generation counter, status enum, async load, -planning-only projection) plus one new seam: the loaded text is cached and -passed to the coach. This requires the milestone's one non-additive change β€” the -`ai.Coach` interface gains a `grounding` parameter. Drift judge and nudge are -untouched. - -**Tech Stack:** Go 1.26, stdlib `os`/`io`/`path/filepath`/`unicode/utf8` + -`encoding/json`, Gin + SSE (unchanged), vanilla JS/CSS, stdlib `testing`. - -**Reference:** Design spec at -`docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md`. The patterns -being mirrored: the `tasks` port (`internal/tasks/tasks.go`, -`internal/tasks/marvin.go`) and the tasks fetch in -`internal/session/session.go` (`startTasksFetchLocked`, `SetTasks`, `TasksView`, -the planning projection in `stateLocked`). - ---- - -## File Structure - -- **Create `internal/knowledge/knowledge.go`** β€” the `Source` interface and the - `Profile` value type. Leaf package; imports only `context`. -- **Create `internal/knowledge/file.go`** β€” the `FileSource` adapter (one file - read), path resolution (`~` expansion, default), and the rune-safe truncation - helper. -- **Create `internal/knowledge/file_test.go`** β€” adapter tests against temp - files: present, absent, explicit-path override, oversize truncation, - whitespace-only, read error. -- **Modify `internal/ai/coach.go`** β€” change the `Coach` interface and - `Service.Coach` to take `grounding`, and `buildPrompt` to inject an - `## About the user` section only when grounding is non-empty. -- **Modify `internal/ai/coach_test.go`** β€” update call sites for the new - signature; add a grounding-in-prompt test and an empty-grounding-unchanged - test. -- **Modify `internal/session/session.go`** β€” knowledge fields, constants, - `KnowledgeView`, `State.Knowledge`, `SetKnowledge`, `SetKnowledgePath`, - `startKnowledgeFetchLocked`, the planning-only projection, the `EnterPlanning` - hook, and passing cached grounding in `RequestCoach`. -- **Modify `internal/session/session_test.go`** β€” update `fakeCoach` for the new - signature; add `fakeSource`, `waitKnowledgeStatus`, and controller tests. -- **Modify `cmd/antidriftd/main.go`** β€” construct the `FileSource` adapter and - call `ctrl.SetKnowledge`. -- **Modify `internal/web/web.go`** β€” add the `POST /knowledge/path` route and - handler. -- **Modify `internal/web/web_test.go`** β€” update `stubCoach` for the new - signature; add a `stubSource`, a planning-payload-carries-knowledge test, and - a path-selection test. -- **Modify `internal/web/static/app.js`** β€” `updatePlanningKnowledge`, a - knowledge line + selector in the planning render, and calls from both render - paths. -- **Modify `internal/web/static/app.css`** β€” `.knowline` / selector styling. - ---- - -## Task 1: `knowledge` package (port + FileSource adapter) - -**Files:** -- Create: `internal/knowledge/knowledge.go` -- Create: `internal/knowledge/file.go` -- Test: `internal/knowledge/file_test.go` - -- [ ] **Step 1: Write the port interface and value type** - -Create `internal/knowledge/knowledge.go`: - -```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) -} -``` - -- [ ] **Step 2: Write the failing adapter tests** - -Create `internal/knowledge/file_test.go`: - -```go -package knowledge - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoadPresentFile(t *testing.T) { - dir := t.TempDir() - p := filepath.Join(dir, "knowledge.md") - if err := os.WriteFile(p, []byte(" I am a focus-tool author.\n"), 0o644); err != nil { - t.Fatal(err) - } - got, err := NewFileSource(p).Load(context.Background(), "") - if err != nil { - t.Fatalf("Load: %v", err) - } - if got.Text != "I am a focus-tool author." { - t.Errorf("Text = %q (whitespace should be trimmed)", got.Text) - } - if got.Path != p { - t.Errorf("Path = %q, want %q", got.Path, p) - } -} - -func TestLoadAbsentFileIsNotError(t *testing.T) { - p := filepath.Join(t.TempDir(), "missing.md") - got, err := NewFileSource(p).Load(context.Background(), "") - if err != nil { - t.Fatalf("absent file must not error: %v", err) - } - if got.Text != "" { - t.Errorf("Text = %q, want empty for absent file", got.Text) - } - if got.Path != p { - t.Errorf("Path = %q, want %q even when absent", got.Path, p) - } -} - -func TestLoadExplicitPathBeatsDefault(t *testing.T) { - dir := t.TempDir() - def := filepath.Join(dir, "default.md") - other := filepath.Join(dir, "other.md") - if err := os.WriteFile(def, []byte("default"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(other, []byte("explicit"), 0o644); err != nil { - t.Fatal(err) - } - got, err := NewFileSource(def).Load(context.Background(), other) - if err != nil { - t.Fatalf("Load: %v", err) - } - if got.Text != "explicit" || got.Path != other { - t.Fatalf("explicit path ignored: %+v", got) - } -} - -func TestLoadTruncatesOversize(t *testing.T) { - p := filepath.Join(t.TempDir(), "big.md") - if err := os.WriteFile(p, []byte(strings.Repeat("a", maxProfileBytes+500)), 0o644); err != nil { - t.Fatal(err) - } - got, err := NewFileSource(p).Load(context.Background(), "") - if err != nil { - t.Fatalf("Load: %v", err) - } - if len(got.Text) > maxProfileBytes+len("\n…(truncated)") { - t.Errorf("Text not truncated: %d bytes", len(got.Text)) - } - if !strings.HasSuffix(got.Text, "(truncated)") { - t.Errorf("missing truncation marker: %q", got.Text[len(got.Text)-20:]) - } -} - -func TestLoadWhitespaceOnlyIsAbsent(t *testing.T) { - p := filepath.Join(t.TempDir(), "blank.md") - if err := os.WriteFile(p, []byte(" \n\t\n"), 0o644); err != nil { - t.Fatal(err) - } - got, err := NewFileSource(p).Load(context.Background(), "") - if err != nil { - t.Fatalf("Load: %v", err) - } - if got.Text != "" { - t.Errorf("whitespace-only should be empty, got %q", got.Text) - } -} - -func TestLoadReadErrorIsError(t *testing.T) { - dir := t.TempDir() // a directory is not readable as a file - if _, err := NewFileSource(dir).Load(context.Background(), ""); err == nil { - t.Fatal("reading a directory should error") - } -} -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `go test ./internal/knowledge/ -v` -Expected: FAIL β€” `undefined: NewFileSource`, `undefined: maxProfileBytes` (build error). - -- [ ] **Step 4: Write the adapter implementation** - -Create `internal/knowledge/file.go`: - -```go -package knowledge - -import ( - "context" - "errors" - "fmt" - "io/fs" - "os" - "path/filepath" - "strings" - "unicode/utf8" -) - -// maxProfileBytes caps the grounding text so the coach prompt stays bounded. -const maxProfileBytes = 6 * 1024 - -// FileSource reads the user's profile from a single file. It is stateless: the -// selected path is passed to Load, so the controller can repoint it at runtime -// without a mutable field. -type FileSource struct { - defaultPath string // used when Load is called with path == "" -} - -// NewFileSource builds the adapter. defaultPath is used when Load receives an -// empty path; if it too is empty, Load falls back to ~/.antidrift/knowledge.md. -func NewFileSource(defaultPath string) *FileSource { - return &FileSource{defaultPath: defaultPath} -} - -// Load reads the profile at path (or the default). A missing file yields an -// empty-Text Profile and no error; only a real read failure errors. The -// resolved absolute path is always returned for display. -func (s *FileSource) Load(ctx context.Context, path string) (Profile, error) { - resolved := s.resolve(path) - data, err := os.ReadFile(resolved) - if errors.Is(err, fs.ErrNotExist) { - return Profile{Path: resolved}, nil - } - if err != nil { - return Profile{Path: resolved}, fmt.Errorf("knowledge: read %s: %w", resolved, err) - } - text := strings.TrimSpace(string(data)) - if text == "" { - return Profile{Path: resolved}, nil - } - return Profile{Text: truncate(text, maxProfileBytes), Path: resolved}, nil -} - -// resolve picks path, else the default, else ~/.antidrift/knowledge.md; expands -// a leading ~; and makes the result absolute for stable display. -func (s *FileSource) resolve(path string) string { - p := path - if p == "" { - p = s.defaultPath - } - if p == "" { - if home, err := os.UserHomeDir(); err == nil { - p = filepath.Join(home, ".antidrift", "knowledge.md") - } - } - p = expandTilde(p) - if abs, err := filepath.Abs(p); err == nil { - return abs - } - return p -} - -func expandTilde(p string) string { - if p != "~" && !strings.HasPrefix(p, "~/") { - return p - } - home, err := os.UserHomeDir() - if err != nil { - return p - } - if p == "~" { - return home - } - return filepath.Join(home, p[2:]) -} - -// truncate clips s to max bytes on a UTF-8 rune boundary and appends a marker. -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - cut := max - for cut > 0 && !utf8.RuneStart(s[cut]) { - cut-- - } - return s[:cut] + "\n…(truncated)" -} -``` - -(`ctx` is unused β€” a local file read is fast β€” but kept for interface -compliance and future adapters.) - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `go test ./internal/knowledge/ -v` -Expected: PASS β€” all six `TestLoad*`. - -- [ ] **Step 6: Commit** - -```bash -git add internal/knowledge/knowledge.go internal/knowledge/file.go internal/knowledge/file_test.go -git commit -m "$(cat <<'EOF' -Add knowledge port and single-file adapter - -The knowledge.Source port answers "who am I; what are my priorities?". -The FileSource adapter reads one profile file (path selectable per call), -treats a missing/blank file as absent (not an error), and caps the text. -Leaf package mirroring tasks. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Task 2: Coach grounding (the `ai.Coach` signature change) - -**Files:** -- Modify: `internal/ai/coach.go` -- Test: `internal/ai/coach_test.go` - -- [ ] **Step 1: Update the coach tests for the new signature + grounding** - -In `internal/ai/coach_test.go`, change the three `Coach(...)` call sites to pass -a grounding argument: - -- Line ~25: `svc.Coach(context.Background(), "write the tests", "")` -- Line ~39: `NewService(fb).Coach(context.Background(), "x", "")` -- Line ~46: `NewService(fb).Coach(context.Background(), "x", "")` - -Then add two tests at the end: - -```go -func TestCoachPromptIncludesGrounding(t *testing.T) { - fb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`} - if _, err := NewService(fb).Coach(context.Background(), "ship it", "I value small diffs."); err != nil { - t.Fatalf("coach: %v", err) - } - if !strings.Contains(fb.gotPrompt, "About the user") || !strings.Contains(fb.gotPrompt, "small diffs") { - t.Fatalf("prompt missing grounding block: %s", fb.gotPrompt) - } -} - -func TestCoachEmptyGroundingUnchanged(t *testing.T) { - withFb := &fakeBackend{out: `{"next_action":"a","success_condition":"b","timebox_minutes":20}`} - _, _ = NewService(withFb).Coach(context.Background(), "ship it", "") - // The empty-grounding prompt must equal buildPrompt(intent) of the old shape: - // it must NOT contain the grounding header. - if strings.Contains(withFb.gotPrompt, "About the user") { - t.Fatalf("empty grounding leaked a header: %s", withFb.gotPrompt) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `go test ./internal/ai/ -run Coach -v` -Expected: FAIL β€” too many arguments to `Coach` (build error) until the signature -changes. - -- [ ] **Step 3: Change the interface, the method, and the prompt builder** - -In `internal/ai/coach.go`: - -Change the interface: - -```go -// Coach turns a free-text intent into a validated Proposal. grounding is -// optional standing context about the user (the knowledge port); "" means none. -type Coach interface { - Coach(ctx context.Context, intent, grounding string) (Proposal, error) -} -``` - -Change the method: - -```go -func (s *Service) Coach(ctx context.Context, intent, grounding string) (Proposal, error) { - out, err := s.backend.Run(ctx, buildPrompt(intent, grounding)) - if err != nil { - return Proposal{}, err - } - return parseProposal(out) -} -``` - -Change `buildPrompt` to take grounding and inject the block only when non-empty, -so an empty grounding yields a byte-for-byte unchanged prompt: - -```go -func buildPrompt(intent, grounding string) string { - preamble := `You are a focus coach. The user gives a rough intent for a work session. Turn it into ONE concrete, actionable commitment. - -Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: -{"next_action": "", "success_condition": "", "timebox_minutes": , "allowed_window_classes": [""]} - -Rules: -- next_action: a single concrete imperative action, doable now. -- success_condition: observable and verifiable; how you'd know it is done. -- timebox_minutes: a realistic integer number of minutes for the action. -- allowed_window_classes: a short list of window/application class names that count as on-task for this action (lowercase, e.g. "code", "firefox"). May be empty.` - - about := "" - if grounding != "" { - about = "\n\n## About the user\n" + grounding + "\nUse this standing context to make the commitment fit who they are and what matters to them." - } - return preamble + about + "\n\nUser intent: " + intent -} -``` - -(No new imports: grounding is checked with `!= ""`. The controller passes -already-trimmed text from the adapter.) - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `go test ./internal/ai/ -v` -Expected: PASS β€” existing coach tests plus the two new grounding tests. -Note: `internal/session` and `internal/web` will not build yet (their fake/stub -coaches still have the old signature) β€” Tasks 3 and 4 fix those. Build them only -after their tasks. - -- [ ] **Step 5: Commit** - -```bash -git add internal/ai/coach.go internal/ai/coach_test.go -git commit -m "$(cat <<'EOF' -Thread optional grounding into the coach prompt - -The ai.Coach interface gains a grounding parameter carrying standing -context about the user (the knowledge port). buildPrompt injects an -"About the user" block only when grounding is non-empty, so an empty -grounding produces a byte-for-byte unchanged prompt. Drift judge and -nudge are untouched. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Task 3: Controller wiring (async load + projection + grounding) - -**Files:** -- Modify: `internal/session/session.go` -- Test: `internal/session/session_test.go` - -- [ ] **Step 1: Update `fakeCoach` and write the failing controller tests** - -In `internal/session/session_test.go`, first add `"antidrift/internal/knowledge"` -to the import block. Update `fakeCoach.Coach` (line ~251) to the new signature, -recording grounding so a test can assert it: - -```go -type fakeCoach struct { - prop ai.Proposal - err error - gotGrounding string -} - -func (f *fakeCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { - f.gotGrounding = grounding - return f.prop, f.err -} -``` - -(Adjust to the existing `fakeCoach` field set if it differs β€” keep its current -fields and only add the signature change plus `gotGrounding`.) - -Then add the fake source, the poll helper, and the tests: - -```go -type fakeSource struct { - profile knowledge.Profile - err error -} - -func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { - return f.profile, f.err -} - -// waitKnowledgeStatus polls until the knowledge view reaches want, or fails after 2s. -func waitKnowledgeStatus(t *testing.T, c *Controller, want string) State { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - st := c.State() - if st.Knowledge != nil && st.Knowledge.Status == want { - return st - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("knowledge status never reached %q (last: %+v)", want, c.State().Knowledge) - return State{} -} - -func TestEnterPlanningLoadsKnowledge(t *testing.T) { - c, _ := newTestController(t) - c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p/knowledge.md"}}) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - st := waitKnowledgeStatus(t, c, "ready") - if st.Knowledge.Path != "/p/knowledge.md" || st.Knowledge.Chars != len("I value small diffs.") { - t.Fatalf("knowledge view wrong: %+v", st.Knowledge) - } -} - -func TestKnowledgeAbsentWhenEmptyText(t *testing.T) { - c, _ := newTestController(t) - c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Path: "/p/missing.md"}}) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - st := waitKnowledgeStatus(t, c, "absent") - if st.Knowledge.Path != "/p/missing.md" { - t.Fatalf("absent view should still carry the path: %+v", st.Knowledge) - } -} - -func TestKnowledgeLoadError(t *testing.T) { - c, _ := newTestController(t) - c.SetKnowledge(&fakeSource{err: errors.New("permission denied")}) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - waitKnowledgeStatus(t, c, "error") -} - -func TestNoSourceNoKnowledgeView(t *testing.T) { - c, _ := newTestController(t) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - if c.State().Knowledge != nil { - t.Fatalf("nil source should yield no knowledge view: %+v", c.State().Knowledge) - } -} - -func TestKnowledgeViewAbsentOutsidePlanning(t *testing.T) { - c, _ := newTestController(t) - c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "x"}}) - if c.State().Knowledge != nil { - t.Fatalf("no knowledge view while Locked: %+v", c.State().Knowledge) - } -} - -func TestCoachReceivesCachedGrounding(t *testing.T) { - c, _ := newTestController(t) - fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}} - c.SetCoach(fc) - c.SetKnowledge(&fakeSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/p"}}) - if err := c.EnterPlanning(); err != nil { - t.Fatalf("enter planning: %v", err) - } - waitKnowledgeStatus(t, c, "ready") - if err := c.RequestCoach("ship it"); err != nil { - t.Fatalf("request coach: %v", err) - } - // Poll until the coach has run (proposal ready), then assert grounding flowed. - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && fc.gotGrounding == "" { - time.Sleep(5 * time.Millisecond) - } - if fc.gotGrounding != "I value small diffs." { - t.Fatalf("coach grounding = %q, want the cached profile", fc.gotGrounding) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `go test ./internal/session/ -run 'Knowledge|CachedGrounding' -v` -Expected: FAIL β€” `c.SetKnowledge undefined`, `st.Knowledge undefined` (build error). - -- [ ] **Step 3: Add the import and constants** - -In `internal/session/session.go`, add `"antidrift/internal/knowledge"` to the -import block. Immediately after the tasks constants block (the -`const ( tasksIdle = "idle" … )` group), add: - -```go -const knowledgeTimeout = 10 * time.Second - -const ( - knowledgeIdle = "idle" - knowledgePending = "pending" - knowledgeReady = "ready" - knowledgeAbsent = "absent" - knowledgeError = "error" -) -``` - -- [ ] **Step 4: Add the controller fields** - -In the `Controller` struct, immediately after the tasks fields (`tasksGen int`), -add: - -```go - knowledge knowledge.Source - knowledgeStatus string - knowledgeText string // cached grounding the coach reads - knowledgePath string // selected path; "" = adapter default - knowledgeChars int - knowledgeGen int -``` - -(The field is named `knowledge`; it does not collide with the package because -the package is only referenced by type in this struct and in method signatures, -where the selector form `knowledge.Source` is used before the field is in scope. -If the compiler reports shadowing in any helper, rename the field to -`knowledgeSrc` and adjust β€” but the tasks port uses `tasksProvider` for exactly -this reason, so prefer `knowledgeSrc` for symmetry if unsure.) - -> **Implementer note:** to match the tasks port's anti-shadowing convention -> (`tasksProvider`, not `tasks`), name this field **`knowledgeSrc`** and use it -> consistently below. The steps that follow assume `knowledgeSrc`. - -- [ ] **Step 5: Add the view types** - -Immediately after the `TasksView` struct definition, add: - -```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 cross the wire. -type KnowledgeView struct { - Status string `json:"status"` - Path string `json:"path,omitempty"` - Chars int `json:"chars,omitempty"` -} -``` - -In the `State` struct, add a field immediately after `Tasks *TasksView …`: - -```go - Knowledge *KnowledgeView `json:"knowledge,omitempty"` -``` - -- [ ] **Step 6: Add the planning-only projection** - -In `stateLocked`, inside the `if c.runtimeState == domain.RuntimePlanning {` -block, immediately after the tasks projection (`st.Tasks = tv` and its guard), -add: - -```go - if c.knowledgeSrc != nil { - kstatus := c.knowledgeStatus - if kstatus == "" { - kstatus = knowledgeIdle - } - st.Knowledge = &KnowledgeView{Status: kstatus, Path: c.knowledgePath, Chars: c.knowledgeChars} - } -``` - -(Note: `c.knowledgePath` here is the *resolved* path cached from the last load, -not the selected override β€” see Step 7, which stores the resolved path back.) - -- [ ] **Step 7: Add SetKnowledge, SetKnowledgePath, and the async load** - -Immediately after `startTasksFetchLocked` (so the knowledge helpers sit next to -the tasks helpers), add: - -```go -// SetKnowledge injects the Knowledge source. Mirrors SetTasks. A nil source -// keeps the planning knowledge view absent and the coach ungrounded. -func (c *Controller) SetKnowledge(s knowledge.Source) { - c.mu.Lock() - c.knowledgeSrc = s - c.mu.Unlock() -} - -// SetKnowledgePath selects an explicit profile path (session-only; not -// persisted). While planning, it re-loads immediately so the indicator and the -// cached grounding update. An empty path resets to the adapter default. -func (c *Controller) SetKnowledgePath(path string) { - c.mu.Lock() - c.knowledgePath = strings.TrimSpace(path) - planning := c.runtimeState == domain.RuntimePlanning - if planning { - c.startKnowledgeFetchLocked() - } - c.mu.Unlock() - if planning { - c.notify() - } -} - -// startKnowledgeFetchLocked kicks off an asynchronous Load when a source is set. -// Mirrors startTasksFetchLocked: generation-guarded, discards stale or -// post-planning results, and notifies on completion. The loaded text is cached -// in knowledgeText for the coach to read. Caller holds mu. -func (c *Controller) startKnowledgeFetchLocked() { - c.knowledgeText = "" - c.knowledgeChars = 0 - if c.knowledgeSrc == nil { - c.knowledgeStatus = knowledgeIdle - return - } - c.knowledgeGen++ - gen := c.knowledgeGen - c.knowledgeStatus = knowledgePending - src := c.knowledgeSrc - path := c.knowledgePath - go func() { - ctx, cancel := context.WithTimeout(context.Background(), knowledgeTimeout) - defer cancel() - prof, err := src.Load(ctx, path) - - c.mu.Lock() - if gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning { - c.mu.Unlock() - return // stale or left planning: discard - } - if err != nil { - c.knowledgeStatus = knowledgeError - c.knowledgeText = "" - c.knowledgeChars = 0 - if prof.Path != "" { - c.knowledgePath = prof.Path - } - } else if prof.Text == "" { - c.knowledgeStatus = knowledgeAbsent - c.knowledgeText = "" - c.knowledgeChars = 0 - c.knowledgePath = prof.Path - } else { - c.knowledgeStatus = knowledgeReady - c.knowledgeText = prof.Text - c.knowledgeChars = len(prof.Text) - c.knowledgePath = prof.Path - } - c.mu.Unlock() - c.notify() - }() -} -``` - -(Caching the *resolved* `prof.Path` back into `c.knowledgePath` lets the -indicator and the pre-filled selector show the real location even when the -selection was empty/default. `context` and `strings` are already imported in -`session.go`.) - -- [ ] **Step 8: Hook the load into EnterPlanning** - -In `EnterPlanning`, add `c.startKnowledgeFetchLocked()` immediately after -`c.startTasksFetchLocked()`: - -```go - c.runtimeState = next - c.resetCoachLocked() - c.startTasksFetchLocked() - c.startKnowledgeFetchLocked() - return c.persistLocked() -``` - -- [ ] **Step 9: Pass cached grounding into the coach** - -In `RequestCoach`, capture the grounding under the lock next to -`coach := c.coach`: - -```go - coach := c.coach - grounding := c.knowledgeText - c.mu.Unlock() -``` - -and pass it to the call inside the goroutine: - -```go - prop, err := coach.Coach(ctx, intent, grounding) -``` - -- [ ] **Step 10: Run the tests to verify they pass** - -Run: `go test ./internal/session/ -run 'Knowledge|CachedGrounding' -v` -Expected: PASS β€” the five knowledge tests plus `TestCoachReceivesCachedGrounding`. - -- [ ] **Step 11: Run the full session suite (no regressions, race-clean)** - -Run: `go test -race ./internal/session/` -Expected: PASS (existing coach/tasks/drift/nudge tests unaffected β€” `EnterPlanning` -with no source just sets the idle status; `RequestCoach` with no source passes -`""`). - -- [ ] **Step 12: Commit** - -```bash -git add internal/session/session.go internal/session/session_test.go -git commit -m "$(cat <<'EOF' -Load the knowledge profile on planning and ground the coach - -The controller loads the profile asynchronously on entering planning, -mirroring the tasks fetch: generation-guarded goroutine, status enum -(idle/pending/ready/absent/error), projected into State only while -planning and only when a source is set. The loaded text is cached and -passed to the coach as grounding; SetKnowledgePath repoints the file at -runtime. nil source degrades to no view and an ungrounded coach. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Task 4: Daemon wiring + web route + web tests - -**Files:** -- Modify: `cmd/antidriftd/main.go` -- Modify: `internal/web/web.go` -- Test: `internal/web/web_test.go` - -- [ ] **Step 1: Update `stubCoach` and write the failing web tests** - -In `internal/web/web_test.go`, add `"antidrift/internal/knowledge"` to the import -block. Update `stubCoach.Coach` (line ~111) to the new signature: - -```go -func (s stubCoach) Coach(ctx context.Context, intent, grounding string) (ai.Proposal, error) { -``` - -(keep its existing body). Then add the stub source and the two tests: - -```go -type stubSource struct { - profile knowledge.Profile -} - -func (s stubSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { - if path != "" { - return knowledge.Profile{Text: "from " + path, Path: path}, nil - } - return s.profile, nil -} - -func TestPlanningStatePayloadCarriesKnowledge(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Text: "I value small diffs.", Path: "/home/u/.antidrift/knowledge.md"}}) - r := s.Router() - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { - t.Fatalf("/planning code %d", w.Code) - } - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if kv := s.ctrl.State().Knowledge; kv != nil && kv.Status == "ready" { - break - } - time.Sleep(5 * time.Millisecond) - } - js := s.stateJSON() - if !strings.Contains(js, `"knowledge"`) || !strings.Contains(js, `"status":"ready"`) { - t.Fatalf("planning payload missing knowledge object: %s", js) - } - if strings.Contains(js, "small diffs") { - t.Fatalf("profile text must NOT cross the wire: %s", js) - } -} - -func TestKnowledgePathSelectionReloads(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetKnowledge(stubSource{profile: knowledge.Profile{Path: "/default"}}) - r := s.Router() - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { - t.Fatalf("/planning code %d", w.Code) - } - if w := post(t, r, "/knowledge/path", `{"path":"/custom/profile.md"}`); w.Code != http.StatusOK { - t.Fatalf("/knowledge/path code %d body %s", w.Code, w.Body.String()) - } - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if kv := s.ctrl.State().Knowledge; kv != nil && kv.Path == "/custom/profile.md" { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("path selection did not reload: %+v", s.ctrl.State().Knowledge) -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `go test ./internal/web/ -run 'Knowledge' -v` -Expected: FAIL β€” no `/knowledge/path` route (404) and/or build error until the -route is added. - -- [ ] **Step 3: Add the route and handler** - -In `internal/web/web.go`, register the route in `Router()` after the existing -POST routes (e.g. after `r.POST("/ontask", s.handleOnTask)`): - -```go - r.POST("/knowledge/path", s.handleKnowledgePath) -``` - -Add the request type and handler (near `handleCoach`): - -```go -type knowledgePathRequest struct { - Path string `json:"path"` -} - -// handleKnowledgePath repoints the profile file at runtime (session-only). It -// mutates config, not commitment state, so it never returns a transition error; -// it just sets the path, re-loads, and broadcasts the refreshed state. -func (s *Server) handleKnowledgePath(c *gin.Context) { - var req knowledgePathRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) - return - } - s.ctrl.SetKnowledgePath(req.Path) - s.broadcast() - c.Data(http.StatusOK, "application/json", []byte(s.stateJSON())) -} -``` - -- [ ] **Step 4: Wire the adapter into the daemon** - -In `cmd/antidriftd/main.go`, add `"antidrift/internal/knowledge"` to the import -block. Immediately after the tasks-wiring block (after -`log.Printf("tasks: marvin adapter (am)")`), add: - -```go - // Wire the Knowledge port: a single profile file grounding the coach. The - // default path is overridable with ANTIDRIFT_KNOWLEDGE_FILE; unset falls back - // to ~/.antidrift/knowledge.md. A missing/unreadable file degrades to an - // ungrounded coach at load time β€” planning still works. - ctrl.SetKnowledge(knowledge.NewFileSource(os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"))) - log.Printf("knowledge: file adapter") -``` - -- [ ] **Step 5: Run the web tests + build the daemon** - -Run: `go test ./internal/web/ -run 'Knowledge' -v && go build ./cmd/antidriftd/` -Expected: PASS and a clean build. - -- [ ] **Step 6: Commit** - -```bash -git add cmd/antidriftd/main.go internal/web/web.go internal/web/web_test.go -git commit -m "$(cat <<'EOF' -Wire the knowledge file adapter and a runtime path selector - -main constructs the FileSource (default via ANTIDRIFT_KNOWLEDGE_FILE) -and injects it. Adds POST /knowledge/path to repoint the profile file at -runtime, and web tests asserting the planning payload carries the -knowledge object (status + path, never the text) and that path selection -reloads. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Task 5: Planning UI β€” the grounding indicator + file selector - -**Files:** -- Modify: `internal/web/static/app.js` -- Modify: `internal/web/static/app.css` - -This task is presentational; the data path and the route are covered by Task 4's -web tests. There is no JS test harness in this project (consistent with M4/M5), -so verify by build/test plus the manual checklist below. - -- [ ] **Step 1: Add the `updatePlanningKnowledge` function** - -In `internal/web/static/app.js`, immediately after the `updatePlanningTasks` -function definition (before `function render(state)`), add: - -```js -// updatePlanningKnowledge renders the standing-profile grounding indicator and -// a small "change" affordance to repoint the file. idle/nil renders nothing; -// pending/ready/absent/error each show one quiet line. The profile text never -// reaches the browser β€” only status, path, and size. -function updatePlanningKnowledge(k) { - const el = document.getElementById('knowBand'); - if (!el) return; - if (!k || k.status === 'idle') { el.innerHTML = ''; return; } - const base = (k.path || '').split('/').pop() || k.path || ''; - let line; - if (k.status === 'pending') line = 'loading profile…'; - else if (k.status === 'ready') line = `grounded by ${base} Β· ${k.chars} chars`; - else if (k.status === 'absent') line = `no profile (${k.path || 'unset'})`; - else line = 'profile unreadable'; - el.innerHTML = - `${line} `; - document.getElementById('knowChange').onclick = () => { - const next = prompt('Profile file path (blank = default):', k.path || ''); - if (next !== null) post('/knowledge/path', { path: next.trim() }); - }; -} -``` - -(`base`/`line` are derived from server-provided status and path; the path is the -user's own filesystem path, carrying the same self-XSS posture as the rest of -the UI β€” no new untrusted source. A `prompt()`-based selector is intentionally -minimal, matching M5's restraint.) - -- [ ] **Step 2: Add the knowledge band to the planning render** - -In the planning branch of `render` (`} else if (rs === 'planning') {`), add a -knowledge band placeholder immediately after the tasks band. Change: - -```js -
    -
    - -``` - -to: - -```js -
    -
    -
    - -``` - -- [ ] **Step 3: Call `updatePlanningKnowledge` from both render paths** - -In the planning partial-update early-return at the top of `render`, change: - -```js - if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - return; - } -``` - -to: - -```js - if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - updatePlanningKnowledge(state.knowledge); - return; - } -``` - -And at the end of the full planning render branch, change: - -```js - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - - } else if (rs === 'active') { -``` - -to: - -```js - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - updatePlanningKnowledge(state.knowledge); - - } else if (rs === 'active') { -``` - -- [ ] **Step 4: Add the styling** - -Append to `internal/web/static/app.css`: - -```css -/* Planning: standing-profile grounding indicator */ -.knowline { opacity: 0.85; } -.link { - background: none; border: none; padding: 0; font: inherit; font-size: 12px; - color: var(--accent); cursor: pointer; text-decoration: underline; -} -``` - -(If `#knowBand` shows an empty `.band` box when idle, reuse the tasks-band -treatment β€” both are empty `
    ` placeholders, so they already -behave identically; no extra rule needed.) - -- [ ] **Step 5: Verify assets still serve and the full suite is race-clean** - -Run: `go vet ./... && go test -race ./...` -Expected: PASS across all packages. - -- [ ] **Step 6: Manual visual check (by eye)** - -Build and run the daemon (`go run ./cmd/antidriftd/`). With a profile at -`~/.antidrift/knowledge.md`, click **Start planning** and confirm: a quiet -"grounded by knowledge.md Β· N chars" line appears; **Sharpen** produces a -proposal that reflects the profile. Click **change**, point at another file, and -confirm the line updates. Remove the file and confirm the line reads "no -profile" and planning still works. With AI configured, confirm grounded vs. -ungrounded proposals differ sensibly. - -- [ ] **Step 7: Commit** - -```bash -git add internal/web/static/app.js internal/web/static/app.css -git commit -m "$(cat <<'EOF' -Show a profile-grounding indicator on the planning screen - -The planning view renders a quiet line for the standing-profile state -(loading/grounded/absent/unreadable) with a "change" affordance to -repoint the file via POST /knowledge/path. The profile text never -reaches the browser β€” only status, path, and size. - -Co-Authored-By: Claude Opus 4.8 -EOF -)" -``` - ---- - -## Final Verification - -After all tasks: - -- [ ] `go vet ./... && go test -race ./...` is clean. -- [ ] `internal/knowledge` imports only `context` and stdlib - (`os`/`io/fs`/`errors`/`fmt`/`path/filepath`/`strings`/`unicode/utf8`) β€” - nothing from `domain`, `session`, `ai`, `evidence`, or `web` (leaf package - preserved). -- [ ] The empty-grounding coach prompt is byte-for-byte identical to the pre-M6 - prompt (pinned by `TestCoachEmptyGroundingUnchanged`). -- [ ] The profile **text** never appears in the SSE/state JSON β€” only status, - path, and char count (asserted in `TestPlanningStatePayloadCarriesKnowledge`). -- [ ] Grounding feeds the **coach only**; `JudgeDrift` and `Nudge` signatures and - prompts are unchanged. -- [ ] No new persisted snapshot fields; the selected path is session-only. -- [ ] Only one new route (`POST /knowledge/path`); it mutates config, not - commitment state. -- [ ] Update `README.md` "Status" to describe M6 (knowledge port grounds the - coach), following the M3.5/M5 precedent. -``` - diff --git a/docs/superpowers/plans/2026-06-01-m7-reflection.md b/docs/superpowers/plans/2026-06-01-m7-reflection.md deleted file mode 100644 index 27f22c9..0000000 --- a/docs/superpowers/plans/2026-06-01-m7-reflection.md +++ /dev/null @@ -1,1069 +0,0 @@ -# M7 β€” Reflection Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add the fourth AI role β€” the **Reviewer** β€” so that when a session ends the daemon reflects on it (read against recent sessions), shows a one-line recap on the Review screen, and carries a one-line takeaway forward to ground the coach on the next planning cycle. - -**Architecture:** A new leaf `ai.Reviewer` role (primitives only) mirrors Coach/DriftJudge/Nudge. The `session.Controller` fetches a reflection asynchronously on entering Review (generation-guarded, non-blocking, graceful), caches the recap, and stores a latest-wins `carryForward` that rides the existing snapshot and composes into the coach's existing free-form `grounding` string β€” so `ai.Coach`'s signature is unchanged. The two short lines are display data, so they cross the wire (unlike the M6 profile). Persistence is snapshot-only; no audit-chain changes. - -**Tech Stack:** Go 1.26, stdlib only (`context`, `encoding/json`, `fmt`, `strings`); `go test` for tests; vanilla JS/CSS for the UI. - -**Design spec:** `docs/superpowers/specs/2026-06-01-m7-reflection-design.md` - ---- - -## File Structure - -- **Create** `internal/ai/reflection.go` β€” the `Reflection` struct, `Reviewer` interface, `Service.Review`, prompt builder, parser. -- **Create** `internal/ai/reflection_test.go` β€” prompt + parser + service tests. -- **Modify** `internal/store/audit.go` β€” add exported `RecentSessions(path, n)`. -- **Modify** `internal/store/audit_test.go` β€” test `RecentSessions`. -- **Modify** `internal/store/store.go` β€” add reflection fields to `Snapshot`. -- **Modify** `internal/session/session.go` β€” reviewer field/constants, `SetReviewer`, `startReflectionFetchLocked`, `enterReview` hook, projection (Review + Planning), snapshot persist/restore, grounding composition in `RequestCoach`. -- **Modify** `internal/session/session_test.go` β€” reviewer fakes + reflection tests. -- **Modify** `cmd/antidriftd/main.go` β€” wire the reviewer (`ctrl.SetReviewer`). -- **Modify** `internal/web/web_test.go` β€” assert the reflection crosses the wire. -- **Modify** `internal/web/static/app.js` β€” render recap on Review, carry-forward on Planning. -- **Modify** `internal/web/static/app.css` β€” one styling line. -- **Modify** `README.md` β€” M7 Status paragraph. - -> **Note on intermediate build state:** No task changes an existing exported signature, so the tree builds green after every task. (M7 adds to the `ai`/`store`/`session` APIs; it changes none.) - ---- - -## Task 1: The `ai.Reviewer` role - -**Files:** -- Create: `internal/ai/reflection.go` -- Test: `internal/ai/reflection_test.go` - -The `ai` package is a leaf: it takes only primitive strings. The controller (Task 3) builds the `finished` and `history` strings. The existing `fakeBackend` (in `coach_test.go`, same package) is reused by the tests here β€” do **not** redefine it. - -- [ ] **Step 1: Write the failing tests** - -Create `internal/ai/reflection_test.go`: - -```go -package ai - -import ( - "context" - "errors" - "strings" - "testing" -) - -func TestReviewPromptIncludesSessionAndHistory(t *testing.T) { - fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`} - if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", "HISTORY-BLOCK"); err != nil { - t.Fatalf("review: %v", err) - } - if !strings.Contains(fb.gotPrompt, "FINISHED-BLOCK") { - t.Fatalf("prompt missing finished session: %s", fb.gotPrompt) - } - if !strings.Contains(fb.gotPrompt, "HISTORY-BLOCK") || !strings.Contains(fb.gotPrompt, "Recent sessions") { - t.Fatalf("prompt missing history block: %s", fb.gotPrompt) - } -} - -func TestReviewPromptOmitsEmptyHistory(t *testing.T) { - fb := &fakeBackend{out: `{"recap":"r","carry_forward":"c"}`} - if _, err := NewService(fb).Review(context.Background(), "FINISHED-BLOCK", ""); err != nil { - t.Fatalf("review: %v", err) - } - if strings.Contains(fb.gotPrompt, "Recent sessions") { - t.Fatalf("empty history must not add a history header: %s", fb.gotPrompt) - } -} - -func TestReviewServiceParsesReflection(t *testing.T) { - fb := &fakeBackend{out: `sure: {"recap":"held focus on the port","carry_forward":"start in the editor next time"}`} - refl, err := NewService(fb).Review(context.Background(), "f", "h") - if err != nil { - t.Fatalf("review: %v", err) - } - if refl.Recap != "held focus on the port" || refl.CarryForward != "start in the editor next time" { - t.Fatalf("bad reflection: %+v", refl) - } -} - -func TestReviewServiceBackendError(t *testing.T) { - fb := &fakeBackend{err: errors.New("boom")} - if _, err := NewService(fb).Review(context.Background(), "f", "h"); err == nil { - t.Fatal("want backend error") - } -} - -func TestParseReflectionEmpty(t *testing.T) { - if _, err := parseReflection(""); !errors.Is(err, ErrEmptyResponse) { - t.Fatalf("want ErrEmptyResponse, got %v", err) - } -} - -func TestParseReflectionNoJSON(t *testing.T) { - if _, err := parseReflection("I cannot help."); !errors.Is(err, ErrNoJSON) { - t.Fatalf("want ErrNoJSON, got %v", err) - } -} - -func TestParseReflectionRequiresRecap(t *testing.T) { - if _, err := parseReflection(`{"recap":" ","carry_forward":"x"}`); !errors.Is(err, ErrInvalidReflection) { - t.Fatalf("blank recap should be invalid, got %v", err) - } -} - -func TestParseReflectionAllowsEmptyCarryForward(t *testing.T) { - refl, err := parseReflection(`{"recap":"did the thing","carry_forward":""}`) - if err != nil { - t.Fatalf("carry_forward may be empty: %v", err) - } - if refl.Recap != "did the thing" || refl.CarryForward != "" { - t.Fatalf("bad reflection: %+v", refl) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `go test ./internal/ai/ -run Refl -v` -Expected: build failure β€” `undefined: parseReflection`, `undefined: ErrInvalidReflection`, `Review` not a method of `*Service`. - -- [ ] **Step 3: Write the implementation** - -Create `internal/ai/reflection.go`: - -```go -package ai - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" -) - -// Reflection is the reviewer's output: two short, single-line fields. -type Reflection struct { - Recap string // backward-looking: how the session went (shown on Review) - CarryForward string // forward-looking: one takeaway for the next session -} - -// Reviewer reflects on a just-finished session, read against recent history. It -// takes primitives, not domain/store types, so ai stays a leaf package. -// -// finished: a compact description of the session that just ended. -// history: a compact description of the last few prior sessions ("" if none). -type Reviewer interface { - Review(ctx context.Context, finished, history string) (Reflection, error) -} - -// ErrInvalidReflection marks output that parsed as JSON but lacked a usable -// recap, so callers can distinguish "no attempt" from "bad attempt". -var ErrInvalidReflection = errors.New("ai: invalid reflection") - -// Review makes Service satisfy Reviewer over the same backend as Coach. -func (s *Service) Review(ctx context.Context, finished, history string) (Reflection, error) { - out, err := s.backend.Run(ctx, buildReviewPrompt(finished, history)) - if err != nil { - return Reflection{}, err - } - return parseReflection(out) -} - -func buildReviewPrompt(finished, history string) string { - preamble := `You are a focus reviewer. A work session just ended. Reflect on it in two short lines. - -Respond with ONLY a JSON object, no prose and no code fences, exactly this shape: -{"recap": "", "carry_forward": ""} - -Rules: -- recap: one short sentence, backward-looking, grounded in the session below. -- carry_forward: one short, actionable sentence for the next session. It may reference patterns across the recent sessions if any are given. -- Keep each field to a single short sentence.` - - hist := "" - if strings.TrimSpace(history) != "" { - hist = "\n\n## Recent sessions (oldest first)\n" + history - } - return preamble + "\n\n## Session that just ended\n" + finished + hist -} - -type rawReflection struct { - Recap string `json:"recap"` - CarryForward string `json:"carry_forward"` -} - -// parseReflection extracts a Reflection from raw CLI output. A blank recap is -// rejected (ErrInvalidReflection); an empty carry_forward is allowed. -func parseReflection(s string) (Reflection, error) { - if strings.TrimSpace(s) == "" { - return Reflection{}, ErrEmptyResponse - } - if strings.IndexByte(s, '{') < 0 { - return Reflection{}, ErrNoJSON - } - jsonStr, err := extractJSON(s) - if err != nil { - return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err) - } - var raw rawReflection - if err := json.Unmarshal([]byte(jsonStr), &raw); err != nil { - return Reflection{}, fmt.Errorf("%w: %v", ErrInvalidReflection, err) - } - recap := strings.TrimSpace(raw.Recap) - if recap == "" { - return Reflection{}, ErrInvalidReflection - } - return Reflection{Recap: recap, CarryForward: strings.TrimSpace(raw.CarryForward)}, nil -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `go test ./internal/ai/ -v` -Expected: PASS (all existing `ai` tests plus the new reflection tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/ai/reflection.go internal/ai/reflection_test.go -git commit -m "Add reviewer role to the ai port - -A fourth leaf role: Review(finished, history) returns a two-line -Reflection (recap + carry_forward) over the same CLI backend as the -coach. Primitive-only, with a JSON prompt and a tolerant parser that -rejects a blank recap but allows an empty carry_forward. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: `store.RecentSessions` - -**Files:** -- Modify: `internal/store/audit.go` -- Test: `internal/store/audit_test.go` - -`readSummaries` is unexported. Add a bounded, exported reader returning the last *n* summaries in **oldest-first** order. The controller formats these into the `history` string. - -- [ ] **Step 1: Write the failing test** - -Append to `internal/store/audit_test.go`: - -```go -func TestRecentSessionsReturnsLastNOldestFirst(t *testing.T) { - path := auditFixture(t) - for _, id := range []string{"a", "b", "c"} { - if err := AppendSession(path, sampleSummary("session-"+id)); err != nil { - t.Fatalf("append %s: %v", id, err) - } - } - got, err := RecentSessions(path, 2) - if err != nil { - t.Fatalf("recent: %v", err) - } - if len(got) != 2 { - t.Fatalf("want 2 summaries, got %d", len(got)) - } - if got[0].SessionID != "session-b" || got[1].SessionID != "session-c" { - t.Fatalf("want [b c] oldest-first, got [%s %s]", got[0].SessionID, got[1].SessionID) - } -} - -func TestRecentSessionsFewerThanN(t *testing.T) { - path := auditFixture(t) - _ = AppendSession(path, sampleSummary("session-a")) - got, err := RecentSessions(path, 5) - if err != nil { - t.Fatalf("recent: %v", err) - } - if len(got) != 1 || got[0].SessionID != "session-a" { - t.Fatalf("want [a], got %+v", got) - } -} - -func TestRecentSessionsMissingOrZero(t *testing.T) { - if got, err := RecentSessions(auditFixture(t), 5); err != nil || got != nil { - t.Fatalf("missing chain: want (nil,nil), got (%+v,%v)", got, err) - } - path := auditFixture(t) - _ = AppendSession(path, sampleSummary("session-a")) - if got, err := RecentSessions(path, 0); err != nil || got != nil { - t.Fatalf("n=0: want (nil,nil), got (%+v,%v)", got, err) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `go test ./internal/store/ -run RecentSessions -v` -Expected: build failure β€” `undefined: RecentSessions`. - -- [ ] **Step 3: Write the implementation** - -Add to `internal/store/audit.go` (after the `readSummaries` function): - -```go -// RecentSessions returns up to n most-recent summaries from the audit chain in -// oldest-first order. A missing/empty chain or n <= 0 yields (nil, nil). -func RecentSessions(path string, n int) ([]SessionSummary, error) { - if n <= 0 { - return nil, nil - } - all, err := readSummaries(path) - if err != nil { - return nil, err - } - if len(all) == 0 { - return nil, nil - } - if len(all) > n { - all = all[len(all)-n:] - } - return all, nil -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `go test ./internal/store/ -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/store/audit.go internal/store/audit_test.go -git commit -m "Add RecentSessions reader over the audit chain - -Exposes the last n session summaries (oldest-first) for the reviewer to -read as recent-history context. Missing/empty chain or n<=0 yields nil. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Controller reflection wiring - -**Files:** -- Modify: `internal/store/store.go` (snapshot fields) -- Modify: `internal/session/session.go` -- Test: `internal/session/session_test.go` - -This is the integration task: the reviewer is fetched on entering Review, the result is cached and rides the snapshot, the `carryForward` composes into the coach's grounding, and the projection exposes a recap (Review) and carry-forward (Planning). The fetch's generation guard is **generation-only** (no runtime-state gate) because the `carryForward` must still apply if the user clicks **End** before the reviewer returns β€” only a superseded *review* should be discarded. - -- [ ] **Step 1: Add the snapshot fields** - -In `internal/store/store.go`, extend the `Snapshot` struct (add the three fields after `AllowedWindowClasses`): - -```go - AllowedWindowClasses []string `json:"allowed_window_classes,omitempty"` - - ReflectionStatus string `json:"reflection_status,omitempty"` - ReflectionRecap string `json:"reflection_recap,omitempty"` - CarryForward string `json:"carry_forward,omitempty"` -} -``` - -Run: `go build ./internal/store/` -Expected: builds clean. - -- [ ] **Step 2: Write the failing controller tests** - -Append to `internal/session/session_test.go`. First add `"strings"` to the import block (it is not yet imported), placing it after `"sync/atomic"`: - -```go - "strings" - "sync" - "sync/atomic" -``` - -Then append the fakes, helpers, and tests: - -```go -type fakeReviewer struct { - refl ai.Reflection - err error - gate chan struct{} // if non-nil, Review blocks until it receives - calls int32 -} - -func (f *fakeReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) { - atomic.AddInt32(&f.calls, 1) - if f.gate != nil { - <-f.gate - } - return f.refl, f.err -} - -// waitReflectionStatus polls until the reflection view reaches want, or fails after 2s. -func waitReflectionStatus(t *testing.T, c *Controller, want string) State { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - st := c.State() - if st.Reflection != nil && st.Reflection.Status == want { - return st - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("reflection status never reached %q (last: %+v)", want, c.State().Reflection) - return State{} -} - -func driveToReview(t *testing.T, c *Controller) { - t.Helper() - if err := c.EnterPlanning(); err != nil { - t.Fatalf("planning: %v", err) - } - if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil); err != nil { - t.Fatalf("start: %v", err) - } - if err := c.Complete(); err != nil { - t.Fatalf("complete: %v", err) - } -} - -func TestReflectionFetchedOnReview(t *testing.T) { - c, _ := newTestController(t) - c.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "held focus", CarryForward: "start in the editor"}}) - driveToReview(t, c) - st := waitReflectionStatus(t, c, "ready") - if st.Reflection.Recap != "held focus" { - t.Fatalf("recap not projected on Review: %+v", st.Reflection) - } -} - -func TestNoReviewerYieldsIdleReflection(t *testing.T) { - c, _ := newTestController(t) - driveToReview(t, c) - st := c.State() - if st.Reflection == nil || st.Reflection.Status != "idle" { - t.Fatalf("nil reviewer should yield idle reflection, got %+v", st.Reflection) - } - if err := c.End(); err != nil { - t.Fatalf("End must still work with no reviewer: %v", err) - } -} - -func TestCarryForwardGroundsNextCoach(t *testing.T) { - c, _ := newTestController(t) - c.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "begin with the hardest test"}}) - fc := &fakeCoach{prop: ai.Proposal{NextAction: "a", SuccessCondition: "b", TimeboxSecs: 1200}} - c.SetCoach(fc) - driveToReview(t, c) - waitReflectionStatus(t, c, "ready") - if err := c.End(); err != nil { - t.Fatalf("end: %v", err) - } - if err := c.EnterPlanning(); err != nil { - t.Fatalf("planning: %v", err) - } - if err := c.RequestCoach("ship it"); err != nil { - t.Fatalf("request coach: %v", err) - } - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) && fc.grounding() == "" { - time.Sleep(5 * time.Millisecond) - } - if g := fc.grounding(); !strings.Contains(g, "begin with the hardest test") { - t.Fatalf("carry-forward not threaded into coach grounding: %q", g) - } -} - -func TestReflectionStaleResultDiscarded(t *testing.T) { - c, _ := newTestController(t) - slow := &fakeReviewer{refl: ai.Reflection{Recap: "OLD", CarryForward: "old"}, gate: make(chan struct{})} - c.SetReviewer(slow) - driveToReview(t, c) // gen1 pending, goroutine blocks on gate - waitReflectionStatus(t, c, "pending") - - _ = c.End() // Review -> Locked; gen1 still blocked - fast := &fakeReviewer{refl: ai.Reflection{Recap: "NEW", CarryForward: "new"}} - c.SetReviewer(fast) - driveToReview(t, c) // gen2 -> ready NEW - st := waitReflectionStatus(t, c, "ready") - if st.Reflection.Recap != "NEW" { - t.Fatalf("expected NEW, got %+v", st.Reflection) - } - - close(slow.gate) // release gen1; it must be discarded - time.Sleep(50 * time.Millisecond) - if got := c.State().Reflection.Recap; got != "NEW" { - t.Fatalf("stale gen1 overwrote reflection: %q", got) - } -} - -func TestCarryForwardSurvivesRestart(t *testing.T) { - path := filepath.Join(t.TempDir(), "state.json") - first, err := New(path) - if err != nil { - t.Fatalf("new: %v", err) - } - first.SetReviewer(&fakeReviewer{refl: ai.Reflection{Recap: "ok", CarryForward: "lead with tests"}}) - if err := first.EnterPlanning(); err != nil { - t.Fatalf("planning: %v", err) - } - if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil { - t.Fatalf("start: %v", err) - } - if err := first.Complete(); err != nil { - t.Fatalf("complete: %v", err) - } - waitReflectionStatus(t, first, "ready") - if err := first.End(); err != nil { - t.Fatalf("end: %v", err) - } - - second, err := New(path) - if err != nil { - t.Fatalf("reopen: %v", err) - } - if err := second.EnterPlanning(); err != nil { - t.Fatalf("planning: %v", err) - } - st := second.State() - if st.Reflection == nil || st.Reflection.CarryForward != "lead with tests" { - t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection) - } -} -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `go test ./internal/session/ -run 'Reflection|CarryForward|Reviewer' -v` -Expected: build failure β€” `undefined: (*Controller).SetReviewer`, `st.Reflection` undefined. - -- [ ] **Step 4: Add reflection constants** - -In `internal/session/session.go`, add `"fmt"` to the import block (after `"errors"`): - -```go - "context" - "errors" - "fmt" - "log" -``` - -Then add the constants after the `knowledge` const block (after line ~59): - -```go -const reflectionTimeout = 30 * time.Second - -const reflectionHistoryN = 5 - -const ( - reflectionIdle = "idle" - reflectionPending = "pending" - reflectionReady = "ready" - reflectionAbsent = "absent" -) -``` - -- [ ] **Step 5: Add controller fields** - -In the `Controller` struct, add a reflection block after the `knowledge` block (after `knowledgeGen int`): - -```go - reviewer ai.Reviewer - reflectionStatus string - reflectionRecap string - carryForward string // latest-wins takeaway; grounds the next coach - reflectionGen int -``` - -- [ ] **Step 6: Add the `ReflectionView` and the `State` field** - -After the `KnowledgeView` type, add: - -```go -// ReflectionView projects the reviewer's output. Recap is shown on Review; -// CarryForward is shown on the next Planning screen. Unlike the knowledge -// profile, these short lines exist to be displayed, so they cross the wire. -type ReflectionView struct { - Status string `json:"status,omitempty"` - Recap string `json:"recap,omitempty"` - CarryForward string `json:"carry_forward,omitempty"` -} -``` - -In the `State` struct, add the field after `Knowledge`: - -```go - Knowledge *KnowledgeView `json:"knowledge,omitempty"` - Reflection *ReflectionView `json:"reflection,omitempty"` - Drift *DriftView `json:"drift,omitempty"` -``` - -- [ ] **Step 7: Restore reflection fields in `New`** - -In `New`, extend the `Controller` literal (after `outcomePending: s.OutcomePending,`): - -```go - clock: time.Now, - outcomePending: s.OutcomePending, - reflectionStatus: s.ReflectionStatus, - reflectionRecap: s.ReflectionRecap, - carryForward: s.CarryForward, - } -``` - -- [ ] **Step 8: Persist reflection fields in `persistLocked`** - -In `persistLocked`, after `snap.AllowedWindowClasses = c.allowedClasses`: - -```go - snap.AllowedWindowClasses = c.allowedClasses - snap.ReflectionStatus = c.reflectionStatus - snap.ReflectionRecap = c.reflectionRecap - snap.CarryForward = c.carryForward - return store.Save(c.snapshotPath, snap) -``` - -- [ ] **Step 9: Project reflection in `stateLocked`** - -Inside `stateLocked`, in the `if c.runtimeState == domain.RuntimePlanning {` block, after the knowledge projection (`st.Knowledge = ...` closing brace), add: - -```go - if c.carryForward != "" { - st.Reflection = &ReflectionView{CarryForward: c.carryForward} - } - } -``` - -Then, after that planning block closes (before `if c.runtimeState == domain.RuntimeActive {`), add a Review block: - -```go - if c.runtimeState == domain.RuntimeReview { - rstatus := c.reflectionStatus - if rstatus == "" { - rstatus = reflectionIdle - } - st.Reflection = &ReflectionView{Status: rstatus, Recap: c.reflectionRecap} - } -``` - -- [ ] **Step 10: Add `SetReviewer`, `startReflectionFetchLocked`, and the input builders** - -After the `SetKnowledge`/`SetKnowledgePath`/`startKnowledgeFetchLocked` group (after `startKnowledgeFetchLocked` closes, ~line 539), add: - -```go -// SetReviewer injects the AI reviewer. A nil reviewer keeps reflection idle and -// leaves the coach ungrounded by any carry-forward. -func (c *Controller) SetReviewer(r ai.Reviewer) { - c.mu.Lock() - c.reviewer = r - c.mu.Unlock() -} - -// startReflectionFetchLocked kicks off an asynchronous reflection when a -// reviewer is set, on entering Review. Unlike the tasks/knowledge fetches, the -// completion guard is generation-only (not state-gated): the carry-forward must -// still apply if the user clicks End before the reviewer returns. A superseded -// review (a later session's fetch) bumps the generation and discards this one. -// The recap and carry-forward are cleared up front so a failed/slow reviewer -// never leaves stale data from the previous session. Caller holds mu. -func (c *Controller) startReflectionFetchLocked() { - c.reflectionRecap = "" - c.carryForward = "" - if c.reviewer == nil { - c.reflectionStatus = reflectionIdle - return - } - c.reflectionGen++ - gen := c.reflectionGen - c.reflectionStatus = reflectionPending - reviewer := c.reviewer - finished := c.buildReflectionFinishedLocked() - history := buildReflectionHistory(c.auditPath) // small, bounded file read - go func() { - ctx, cancel := context.WithTimeout(context.Background(), reflectionTimeout) - defer cancel() - refl, err := reviewer.Review(ctx, finished, history) - - c.mu.Lock() - if gen != c.reflectionGen { - c.mu.Unlock() - return // superseded review: discard - } - if err != nil || strings.TrimSpace(refl.Recap) == "" { - c.reflectionStatus = reflectionAbsent - c.reflectionRecap = "" - c.carryForward = "" - } else { - c.reflectionStatus = reflectionReady - c.reflectionRecap = refl.Recap - c.carryForward = refl.CarryForward - } - _ = c.persistLocked() - c.mu.Unlock() - c.notify() - }() -} - -// buildReflectionFinishedLocked renders the just-finished session as a compact -// block for the reviewer. Caller holds mu; c.stats/c.commitment are still set -// (End clears them, but enterReview runs before End). Reuses bucketViews for the -// per-window totals, already sorted desc by seconds. -func (c *Controller) buildReflectionFinishedLocked() string { - var na, sc string - if c.commitment != nil { - na, sc = c.commitment.NextAction, c.commitment.SuccessCondition - } - outcome := c.outcomePending - if outcome == "" { - outcome = "completed" - } - var b strings.Builder - fmt.Fprintf(&b, "Next action: %s\n", na) - fmt.Fprintf(&b, "Success condition: %s\n", sc) - fmt.Fprintf(&b, "Outcome: %s\n", outcome) - if c.stats != nil { - fmt.Fprintf(&b, "Context switches: %d\n", c.stats.SwitchCount) - for i, bv := range bucketViews(c.stats.Buckets) { - if i >= 3 { - break - } - fmt.Fprintf(&b, "- %s Β· %s: %dm\n", bv.Class, bv.Title, bv.Seconds/60) - } - } - return strings.TrimRight(b.String(), "\n") -} - -// buildReflectionHistory renders the last few prior sessions as compact lines. -// The just-finished session is not yet in the chain (End appends it), so it is -// not double-counted. Returns "" when there is no usable history. -func buildReflectionHistory(auditPath string) string { - sums, err := store.RecentSessions(auditPath, reflectionHistoryN) - if err != nil || len(sums) == 0 { - return "" - } - var b strings.Builder - for _, s := range sums { - top := "" - if len(s.Buckets) > 0 { - top = fmt.Sprintf(", top %s %dm", s.Buckets[0].Class, s.Buckets[0].Seconds/60) - } - fmt.Fprintf(&b, "- %s: %s (%d switches%s)\n", s.Outcome, s.NextAction, s.SwitchCount, top) - } - return strings.TrimRight(b.String(), "\n") -} -``` - -- [ ] **Step 11: Fire the fetch on entering Review** - -In `enterReview`, insert the fetch after `c.outcomePending = outcome` and before the final `return c.persistLocked()`: - -```go - c.runtimeState = next - c.outcomePending = outcome - c.startReflectionFetchLocked() - return c.persistLocked() -``` - -- [ ] **Step 12: Compose the carry-forward into the coach grounding** - -Add a helper near `RequestCoach`: - -```go -// composedGroundingLocked combines the standing profile (knowledge port) with -// the latest carry-forward takeaway into the single free-form grounding string -// the coach already accepts. Caller holds mu. -func (c *Controller) composedGroundingLocked() string { - g := c.knowledgeText - if c.carryForward != "" { - if g != "" { - g += "\n\n" - } - g += "Last session's takeaway: " + c.carryForward - } - return g -} -``` - -Then in `RequestCoach`, replace the line `grounding := c.knowledgeText` with: - -```go - grounding := c.composedGroundingLocked() -``` - -- [ ] **Step 13: Run the tests to verify they pass** - -Run: `go test ./internal/session/ -run 'Reflection|CarryForward|Reviewer' -v` -Expected: PASS (5 new tests). - -- [ ] **Step 14: Run the full session + store + ai suites with the race detector** - -Run: `go test -race ./internal/session/ ./internal/store/ ./internal/ai/` -Expected: PASS. (`TestCoachReceivesCachedGrounding` still passes: with no reviewer set, `carryForward` is "" and the composed grounding equals the profile text exactly.) - -- [ ] **Step 15: Commit** - -```bash -git add internal/store/store.go internal/session/session.go internal/session/session_test.go -git commit -m "Reflect on entering Review and ground the next coach - -The controller fetches a reflection asynchronously on enterReview -(generation-guarded, non-blocking, graceful) and caches a one-line -recap plus a latest-wins carry-forward. The recap projects onto Review, -the carry-forward onto the next Planning, and both ride the snapshot. -RequestCoach composes the carry-forward into the coach's existing -free-form grounding string, so ai.Coach is unchanged. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Daemon wiring + web payload test - -**Files:** -- Modify: `cmd/antidriftd/main.go` -- Test: `internal/web/web_test.go` - -- [ ] **Step 1: Write the failing web test** - -Append to `internal/web/web_test.go`: - -```go -type stubReviewer struct { - refl ai.Reflection -} - -func (s stubReviewer) Review(ctx context.Context, finished, history string) (ai.Reflection, error) { - return s.refl, nil -} - -func TestReflectionFlowsToReviewThenPlanning(t *testing.T) { - s := newTestServer(t) - s.ctrl.SetReviewer(stubReviewer{refl: ai.Reflection{Recap: "held focus well", CarryForward: "start in the editor"}}) - r := s.Router() - _ = post(t, r, "/planning", "") - body := `{"next_action":"a","success_condition":"b","timebox_secs":1500}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { - t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) - } - if w := post(t, r, "/complete", ""); w.Code != http.StatusOK { - t.Fatalf("/complete code %d", w.Code) - } - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if rv := s.ctrl.State().Reflection; rv != nil && rv.Status == "ready" { - break - } - time.Sleep(5 * time.Millisecond) - } - js := s.stateJSON() - if !strings.Contains(js, `"recap":"held focus well"`) { - t.Fatalf("review payload missing recap: %s", js) - } - - // End -> Locked -> Planning: the carry-forward should surface on planning. - if w := post(t, r, "/end", ""); w.Code != http.StatusOK { - t.Fatalf("/end code %d", w.Code) - } - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { - t.Fatalf("/planning code %d", w.Code) - } - js2 := s.stateJSON() - if !strings.Contains(js2, `"carry_forward":"start in the editor"`) { - t.Fatalf("planning payload missing carry-forward: %s", js2) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `go test ./internal/web/ -run Reflection -v` -Expected: build failure β€” `s.ctrl.SetReviewer` undefined is already resolved by Task 3, so the failure here is the missing daemon wiring is **not** what this tests; the test should actually compile and PASS already (the controller wiring from Task 3 is enough). If it PASSES, that is expected β€” proceed to Step 3 to add the daemon wiring (which the test does not exercise). If it FAILS to compile, re-check Task 3 was applied. - -> Rationale: the web payload is produced by the controller, which Task 3 already wired. This test guards the wire format. The daemon change in Step 3 is the production wiring (`main.go`) that no test exercises, mirroring how the M6 tasks/knowledge adapters are wired. - -- [ ] **Step 3: Wire the reviewer in `main.go`** - -In `cmd/antidriftd/main.go`, in the AI block, add `ctrl.SetReviewer(svc)` alongside the other roles and update the log line: - -```go - svc := ai.NewService(backend) - ctrl.SetCoach(svc) - ctrl.SetDriftJudge(svc) - ctrl.SetNudge(svc) - ctrl.SetReviewer(svc) - log.Printf("ai: %s backend (coach + drift judge + nudge + reviewer)", backend.Name()) -``` - -- [ ] **Step 4: Run the test + build to verify** - -Run: `go test ./internal/web/ -run Reflection -v && go build ./...` -Expected: test PASS; build clean. - -- [ ] **Step 5: Commit** - -```bash -git add cmd/antidriftd/main.go internal/web/web_test.go -git commit -m "Wire the reviewer and assert reflection on the wire - -main injects the AI service as the reviewer alongside the other roles. -A web test drives a session to Review and asserts the recap rides the -state payload, then to the next Planning and asserts the carry-forward -does too. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: Planning + Review UI - -**Files:** -- Modify: `internal/web/static/app.js` -- Modify: `internal/web/static/app.css` -- Modify: `README.md` - -No JS test harness exists in this repo (consistent with M2–M6); this task is verified by `go build`/`go vet` and a human visual check. Keep the changes small and presentational. - -- [ ] **Step 1: Add the two render helpers** - -In `internal/web/static/app.js`, after `updatePlanningKnowledge` (ends ~line 180), add: - -```js -// reflectionBlock renders the reviewer's recap on the Review screen. idle/nil or -// an empty recap renders nothing; pending shows a quiet line; ready shows the -// one-line recap. -function reflectionBlock(refl) { - if (!refl) return ''; - if (refl.status === 'pending') return `
    reflecting…
    `; - if (refl.status === 'ready' && refl.recap) { - return `
    ${refl.recap}
    `; - } - return ''; -} - -// updatePlanningReflection renders last session's carry-forward takeaway as a -// quiet one-liner on the planning screen. Nothing renders without one. -function updatePlanningReflection(refl) { - const el = document.getElementById('reflectBand'); - if (!el) return; - if (!refl || !refl.carry_forward) { el.innerHTML = ''; return; } - el.innerHTML = `Last time: ${refl.carry_forward}`; -} -``` - -- [ ] **Step 2: Call the planning helper in both planning render paths** - -In `render`, the incremental planning branch currently reads: - -```js - if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - updatePlanningKnowledge(state.knowledge); - return; - } -``` - -Add the reflection call: - -```js - if (rs === 'planning' && renderedState === 'planning') { - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - updatePlanningKnowledge(state.knowledge); - updatePlanningReflection(state.reflection); - return; - } -``` - -In the full planning render (the `} else if (rs === 'planning') {` block), add the `reflectBand` div after `knowBand`: - -```js -
    -
    -
    -``` - -and add the call alongside the other planning updaters at the end of that block: - -```js - updatePlanningCoach(state.coach); - updatePlanningTasks(state.tasks); - updatePlanningKnowledge(state.knowledge); - updatePlanningReflection(state.reflection); -``` - -- [ ] **Step 3: Render the recap on the Review screen** - -In the `} else if (rs === 'review') {` block, insert `reflectionBlock` before `reviewSummary`: - -```js - ${reflectionBlock(state.reflection)} - ${reviewSummary(state.evidence)} -``` - -- [ ] **Step 4: Add the styling line** - -In `internal/web/static/app.css`, after the `.knowline { opacity: 0.85; }` line, add: - -```css -.reflectline { opacity: 0.85; } -``` - -- [ ] **Step 5: Update the README Status section** - -In `README.md`, add a new paragraph at the top of the `## Status` section (above the M6 paragraph): - -```markdown -M7 (reflection): when a session ends, a fourth AI role β€” the reviewer β€” -reflects on it, read against your recent sessions, and produces two short -lines: a recap shown on the Review screen, and a carry-forward takeaway that -grounds the coach the next time you plan. It runs once asynchronously on -entering Review, never blocks the End button, and degrades gracefully β€” with -no backend (or a slow/failed call) Review and Planning behave exactly as -before. The carry-forward is snapshot-persisted (latest-wins) and composes -into the coach's grounding; the reflection lines are short and cross the wire -by design, while the knowledge profile still does not. -``` - -- [ ] **Step 6: Verify build and vet** - -Run: `go build ./... && go vet ./... && go test ./...` -Expected: all PASS. - -- [ ] **Step 7: Commit** - -```bash -git add internal/web/static/app.js internal/web/static/app.css README.md -git commit -m "Show the recap on Review and the carry-forward on Planning - -The Review screen renders the reviewer's one-line recap (quiet pending -line, then the recap); the Planning screen renders last session's -carry-forward as a 'Last time: …' one-liner, mirroring the knowledge -indicator. Updates the README Status section for M7. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Final Verification - -After all tasks, confirm the milestone holds end-to-end: - -- [ ] `go build ./...` β€” clean. -- [ ] `go vet ./...` β€” clean. -- [ ] `go test -race ./...` β€” all packages PASS. -- [ ] **Spec invariants:** - - `ai.Coach`'s signature is unchanged (grep: `func.*Coach(ctx context.Context, intent, grounding string)` still the only Coach signature). - - No new file or format under `~/.antidrift` beyond snapshot fields (grep `audit.go` for no new write paths; `RecentSessions` is read-only). - - The reflection recap/carry-forward appear in `State` JSON; the knowledge profile text still does not (`TestPlanningStatePayloadCarriesKnowledge` still passes). - - End is never blocked on the reviewer (`TestNoReviewerYieldsIdleReflection` and the stale-discard test cover the fast-End path). - ---- - -## Self-Review Notes - -- **Spec coverage:** new role (Task 1) βœ“; recent-history input via `RecentSessions` (Task 2) βœ“; fetch-on-Review + generation guard + graceful + non-blocking + snapshot persistence + grounding composition + projection (Task 3) βœ“; daemon wiring + wire-format (Task 4) βœ“; Review recap + Planning carry-forward UI + README (Task 5) βœ“; "out of scope" items (no reflections.jsonl, no Coach signature change, no session.go refactor) are respected. -- **Type consistency:** `Reflection{Recap, CarryForward}`, `ReflectionView{Status, Recap, CarryForward}`, `Snapshot.{ReflectionStatus, ReflectionRecap, CarryForward}`, and controller fields `reflectionStatus/reflectionRecap/carryForward/reflectionGen/reviewer` are used identically across tasks. JSON keys `recap`/`carry_forward`/`status` match between the view tags and the JS/web assertions. -- **Generation guard difference:** documented in Task 3 Step 10 β€” generation-only (not state-gated) so the carry-forward applies even after a fast End, unlike tasks/knowledge which gate on `RuntimePlanning`. diff --git a/docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md b/docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md deleted file mode 100644 index 3afd95a..0000000 --- a/docs/superpowers/plans/2026-06-01-m8-enforcement-window-minimize.md +++ /dev/null @@ -1,796 +0,0 @@ -# M8 (Tier A) β€” Window-minimize Enforcement Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** When a session opts into enforcement and the drift judge confirms the active window is off-task, minimize that window β€” activating the dormant `domain.EnforcementLevel` and establishing the unprivileged `enforce.Guard` port. - -**Architecture:** A new leaf port `enforce.Guard` (`MinimizeActive(ctx)`), with an X11 adapter (native `jezek/xgbutil`, no `xdotool`) and a no-op adapter, mirroring `evidence.Source`. The `session.Controller` owns all policy: it threads a per-commitment `EnforcementLevel` (persisted in the snapshot), and on every confirmed-drift observation at the `block` level it runs the guard's minimize off-lock β€” the established async-I/O discipline. The browser shows a planning toggle and a drift-band note. - -**Tech Stack:** Go 1.26, stdlib + `github.com/jezek/xgbutil` (already a dependency), Gin, vanilla JS/CSS, stdlib `testing`. - ---- - -## File Structure - -**New files** -- `internal/enforce/enforce.go` β€” the `Guard` interface (no build tag). One responsibility: define the port. -- `internal/enforce/guard_other.go` (`//go:build !linux`) β€” no-op `NewGuard`. -- `internal/enforce/x11.go` (`//go:build linux`) β€” real `NewGuard` using xgbutil. -- `internal/enforce/enforce_test.go` β€” portable unit test (compiles on all platforms). -- `internal/enforce/x11_integration_test.go` (`//go:build linux`) β€” live X11 smoke test, skipped without `DISPLAY`. - -**Modified files** -- `internal/store/store.go` β€” `Snapshot` gains `EnforcementLevel`. -- `internal/session/session.go` β€” `EnforcementLevel` field, signature change, persistence, the `enforce` hook, `DriftView.Enforced`. -- `internal/session/session_test.go` β€” call-site updates, `fakeGuard`, enforcement tests. -- `internal/web/web.go` β€” `commitmentRequest.Enforce` β†’ level mapping. -- `internal/web/web_test.go` β€” `stubJudge` + enforced-on-the-wire test. -- `cmd/antidriftd/main.go` β€” `ctrl.SetGuard(enforce.NewGuard())`. -- `internal/web/static/app.js` β€” planning toggle + drift-band note. -- `internal/web/static/app.css` β€” note/toggle styling. -- `README.md` β€” M8 paragraph. - ---- - -## Task 1: The `enforce` port and its adapters - -**Files:** -- Create: `internal/enforce/enforce.go` -- Create: `internal/enforce/guard_other.go` -- Create: `internal/enforce/x11.go` -- Create: `internal/enforce/enforce_test.go` -- Create: `internal/enforce/x11_integration_test.go` - -- [ ] **Step 1: Write the failing test** - -Create `internal/enforce/enforce_test.go` (no build tag β€” must compile on every platform): - -```go -package enforce - -import "testing" - -// NewGuard must return a usable Guard on every platform (real on linux, no-op -// elsewhere). We assert non-nil only: calling MinimizeActive here would touch a -// real X server on linux, which the integration test covers under a DISPLAY -// guard. The behavioural contract is exercised in the session package via a fake -// Guard. -func TestNewGuardReturnsUsableGuard(t *testing.T) { - if g := NewGuard(); g == nil { - t.Fatal("NewGuard returned nil") - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/enforce/` -Expected: FAIL β€” build error, `undefined: NewGuard` / package has no Go files yet. - -- [ ] **Step 3: Write the interface** - -Create `internal/enforce/enforce.go`: - -```go -// Package enforce makes drift cost something at the OS level. Tier A minimizes -// the active window when the session is enforcing and the drift judge has -// confirmed the window is off-task. The Guard is a pure OS primitive; all -// policy β€” whether and when to enforce β€” lives in the session controller. -package enforce - -import "context" - -// Guard performs OS-level enforcement actions on demand. -type Guard interface { - // MinimizeActive minimizes the currently-focused window. It is idempotent - // (minimizing an already-minimized window is harmless) and best-effort: it - // returns an error for diagnostics, but callers never block on it and treat - // failure as "enforcement did nothing this time." - MinimizeActive(ctx context.Context) error -} -``` - -- [ ] **Step 4: Write the no-op adapter** - -Create `internal/enforce/guard_other.go`: - -```go -//go:build !linux - -package enforce - -import "context" - -// NewGuard returns a no-op guard on platforms without the X11 adapter. -func NewGuard() Guard { return noopGuard{} } - -type noopGuard struct{} - -func (noopGuard) MinimizeActive(context.Context) error { return nil } -``` - -- [ ] **Step 5: Write the X11 adapter** - -Create `internal/enforce/x11.go`. The minimize is the standard ICCCM iconify: a `WM_CHANGE_STATE` client message carrying `IconicState` (`icccm.StateIconic`, value 3) sent to the root window, which `ewmh.ClientEvent` does (it targets the root with SubstructureNotify|Redirect): - -```go -//go:build linux - -package enforce - -import ( - "context" - "fmt" - - "github.com/jezek/xgbutil" - "github.com/jezek/xgbutil/ewmh" - "github.com/jezek/xgbutil/icccm" -) - -// NewGuard returns the real X11 window-minimize guard. -func NewGuard() Guard { return x11Guard{} } - -type x11Guard struct{} - -// MinimizeActive iconifies the currently-focused window by sending an ICCCM -// WM_CHANGE_STATE -> IconicState client message to the root window. It opens a -// short-lived X connection per call: enforcement fires at most once per drift -// observation (which is debounce-gated upstream), so there is no shared -// connection or event loop to manage, and nothing to race on shutdown. Any X -// failure is returned for the caller to log; the caller never blocks on it. -func (x11Guard) MinimizeActive(_ context.Context) error { - X, err := xgbutil.NewConn() - if err != nil { - return fmt.Errorf("enforce: cannot connect to X server: %w", err) - } - defer X.Conn().Close() - - active, err := ewmh.ActiveWindowGet(X) - if err != nil { - return fmt.Errorf("enforce: no active window: %w", err) - } - if active == 0 { - return nil // nothing focused; nothing to minimize - } - if err := ewmh.ClientEvent(X, active, "WM_CHANGE_STATE", int(icccm.StateIconic)); err != nil { - return fmt.Errorf("enforce: minimize request failed: %w", err) - } - return nil -} -``` - -- [ ] **Step 6: Write the live X11 integration test** - -Create `internal/enforce/x11_integration_test.go` (mirrors `internal/evidence/x11_integration_test.go`): - -```go -//go:build linux - -package enforce - -import ( - "context" - "os" - "testing" - "time" -) - -func TestX11GuardMinimizeActiveDoesNotPanic(t *testing.T) { - if os.Getenv("DISPLAY") == "" { - t.Skip("no DISPLAY; skipping live X11 minimize smoke test") - } - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - // Either it minimizes the active window or returns an error (e.g. no active - // window); we only assert it returns without panicking. - if err := NewGuard().MinimizeActive(ctx); err != nil { - t.Logf("MinimizeActive returned (acceptable): %v", err) - } -} -``` - -- [ ] **Step 7: Run tests to verify they pass** - -Run: `go test ./internal/enforce/` -Expected: PASS (`TestNewGuardReturnsUsableGuard` passes; the integration test passes or skips depending on `DISPLAY`). - -- [ ] **Step 8: Build all platforms compile** - -Run: `go vet ./internal/enforce/ && GOOS=darwin go build ./internal/enforce/` -Expected: no output (both the linux and non-linux files compile). - -- [ ] **Step 9: Commit** - -```bash -git add internal/enforce/ -git commit -m "Add enforce.Guard port with X11 and no-op adapters" -``` - ---- - -## Task 2: Activate the per-commitment EnforcementLevel (signature, field, persistence) - -**Files:** -- Modify: `internal/store/store.go:16-28` (Snapshot struct) -- Modify: `internal/session/session.go` (Controller field, `New`, `persistLocked`, `StartManualCommitment`, a test accessor) -- Modify: `internal/web/web.go:108-122` (request field + level mapping) -- Modify: `internal/session/session_test.go` (call-site updates + persistence test) - -- [ ] **Step 1: Write the failing test** - -Add to `internal/session/session_test.go` (`domain`, `filepath`, `time` are already imported): - -```go -func TestEnforcementLevelPersists(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "state.json") - first, err := New(path) - if err != nil { - t.Fatal(err) - } - first.SetClock(func() time.Time { return time.Unix(1000, 0) }) - if err := first.EnterPlanning(); err != nil { - t.Fatal(err) - } - if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementBlock); err != nil { - t.Fatal(err) - } - - second, err := New(path) - if err != nil { - t.Fatal(err) - } - if got := second.EnforcementLevelForTest(); got != domain.EnforcementBlock { - t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `go test ./internal/session/ -run TestEnforcementLevelPersists` -Expected: FAIL β€” build error: `StartManualCommitment` wants 4 args, and `EnforcementLevelForTest` is undefined. - -- [ ] **Step 3: Add the snapshot field** - -In `internal/store/store.go`, add to the `Snapshot` struct (after the `CarryForward` line): - -```go - EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"` -``` - -(`domain` is already imported in `store.go`.) - -- [ ] **Step 4: Add the Controller field** - -In `internal/session/session.go`, in the `Controller` struct, add next to `allowedClasses` (the other durable per-session field): - -```go - enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement -``` - -- [ ] **Step 5: Change the StartManualCommitment signature and set the field** - -In `internal/session/session.go`, change the signature and store the level. Replace the header line: - -```go -func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error { -``` - -and, immediately after the existing `c.allowedClasses = append([]string(nil), allowedClasses...)` line, add: - -```go - c.enforcementLevel = level -``` - -- [ ] **Step 6: Persist and restore the level** - -In `persistLocked`, after `snap.AllowedWindowClasses = c.allowedClasses`, add: - -```go - snap.EnforcementLevel = c.enforcementLevel -``` - -In `New`, inside the `if c.runtimeState == domain.RuntimeActive && s.SessionID != ""` block, right after `c.allowedClasses = s.AllowedWindowClasses`, add: - -```go - c.enforcementLevel = s.EnforcementLevel -``` - -- [ ] **Step 7: Add the test accessor** - -In `internal/session/session.go`, near the other `*ForTest` helpers (e.g. `AllowedClassesForTest`), add: - -```go -// EnforcementLevelForTest exposes the active session's enforcement level. Tests -// only. -func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel { - c.mu.Lock() - defer c.mu.Unlock() - return c.enforcementLevel -} -``` - -- [ ] **Step 8: Update the web handler to supply a level** - -In `internal/web/web.go`, add the field to `commitmentRequest`: - -```go - Enforce bool `json:"enforce"` -``` - -and in `handleCommitment`, replace the `StartManualCommitment` call with a mapped level (Tier A honors only `block`/`warn`): - -```go - level := domain.EnforcementWarn - if req.Enforce { - level = domain.EnforcementBlock - } - err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level) -``` - -Add `"antidrift/internal/domain"` to `web.go`'s imports. - -- [ ] **Step 9: Update the session test call sites** - -In `internal/session/session_test.go`, every existing `StartManualCommitment(...)` call now needs a trailing level argument. Add `, domain.EnforcementWarn` to each (preserving today's advisory behavior). Also update the `startActive` helper to delegate to a new level-aware helper so Task 3 can start at `block`: - -```go -func startActive(t *testing.T, c *Controller, allowed []string) { - t.Helper() - startActiveLevel(t, c, allowed, domain.EnforcementWarn) -} - -func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) { - t.Helper() - if err := c.EnterPlanning(); err != nil { - t.Fatalf("planning: %v", err) - } - if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed, level); err != nil { - t.Fatalf("start: %v", err) - } -} -``` - -The direct call sites to update (add `, domain.EnforcementWarn` before the closing paren) are at roughly lines 40, 70, 85, 107, 150, 185, 202, 231, 377, 392, 562, 891, 1069, 1161. (Use `grep -n 'StartManualCommitment(' internal/session/session_test.go` to find any the line numbers have since shifted; the old `startActive` body at ~441 is replaced by the helper above.) - -- [ ] **Step 10: Run the full session + web suite** - -Run: `go build ./... && go test ./internal/session/ ./internal/web/ ./internal/store/` -Expected: PASS, including `TestEnforcementLevelPersists`. Fix any call site the grep missed. - -- [ ] **Step 11: Commit** - -```bash -git add internal/store/store.go internal/session/session.go internal/session/session_test.go internal/web/web.go -git commit -m "Thread per-commitment enforcement level through to the snapshot" -``` - ---- - -## Task 3: Fire the guard on confirmed drift at the block level - -**Files:** -- Modify: `internal/session/session.go` (`enforce` import, `guard` field, `enforceTimeout`, `SetGuard`, `enforceActionLocked`, `RecordWindow`, the judge closure, `DriftView`, drift projection) -- Modify: `internal/session/session_test.go` (`fakeGuard`, `waitGuardCalls`, enforcement tests) - -- [ ] **Step 1: Write the failing tests** - -Add to `internal/session/session_test.go`. First the fake guard and a poll helper (`context`, `sync/atomic`, `time` already imported): - -```go -type fakeGuard struct{ calls int32 } - -func (g *fakeGuard) MinimizeActive(context.Context) error { - atomic.AddInt32(&g.calls, 1) - return nil -} - -func waitGuardCalls(t *testing.T, g *fakeGuard, min int32) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if atomic.LoadInt32(&g.calls) >= min { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("guard minimize calls never reached %d (got %d)", min, atomic.LoadInt32(&g.calls)) -} -``` - -Then the tests: - -```go -func TestDriftMinimizesAtBlockViaBothPaths(t *testing.T) { - c, _ := newTestController(t) - c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) - g := &fakeGuard{} - c.SetGuard(g) - startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) - - // Async path: first off-task observation launches the judge; on confirmation - // the guard minimizes once. - c.RecordWindow(obs("firefox", "YouTube")) - st := waitDriftStatus(t, c, "drifting") - if !st.Drift.Enforced { - t.Fatalf("Enforced should be true while drifting at block: %+v", st.Drift) - } - waitGuardCalls(t, g, 1) - - // Synchronous cached path: same class again hits the per-class cache, sets - // drifting under the lock, and minimizes again without a new judgment. - c.RecordWindow(obs("firefox", "Reddit")) - waitGuardCalls(t, g, 2) - if got := atomic.LoadInt32(&g.calls); got < 2 { - t.Fatalf("cached-drift path did not minimize: %d calls", got) - } -} - -func TestWarnLevelNeverMinimizes(t *testing.T) { - c, _ := newTestController(t) - c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) - g := &fakeGuard{} - c.SetGuard(g) - startActiveLevel(t, c, []string{"code"}, domain.EnforcementWarn) - - c.RecordWindow(obs("firefox", "YouTube")) - st := waitDriftStatus(t, c, "drifting") - if st.Drift.Enforced { - t.Fatalf("Enforced must be false at warn: %+v", st.Drift) - } - time.Sleep(50 * time.Millisecond) // allow any stray enforcement goroutine to run - if got := atomic.LoadInt32(&g.calls); got != 0 { - t.Fatalf("warn level must not minimize, got %d calls", got) - } -} - -func TestOnTaskNeverMinimizes(t *testing.T) { - c, _ := newTestController(t) - c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "should not be called"}}) - g := &fakeGuard{} - c.SetGuard(g) - startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) - - c.RecordWindow(obs("code", "main.go")) // local on-task match - time.Sleep(50 * time.Millisecond) - if got := atomic.LoadInt32(&g.calls); got != 0 { - t.Fatalf("on-task must not minimize, got %d calls", got) - } -} - -func TestBlockWithoutGuardDoesNotPanic(t *testing.T) { - c, _ := newTestController(t) - c.SetDriftJudge(&fakeJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) - // No guard wired. - startActiveLevel(t, c, []string{"code"}, domain.EnforcementBlock) - c.RecordWindow(obs("firefox", "YouTube")) - waitDriftStatus(t, c, "drifting") // must reach drifting without panicking -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/session/ -run 'Minimize|BlockWithout|WarnLevel|OnTaskNever'` -Expected: FAIL β€” `SetGuard` undefined and `DriftView` has no `Enforced` field. - -- [ ] **Step 3: Import the enforce package and add the guard field** - -In `internal/session/session.go`, add to the import block: - -```go - "antidrift/internal/enforce" -``` - -and add to the `Controller` struct (next to `judge ai.DriftJudge`): - -```go - guard enforce.Guard -``` - -- [ ] **Step 4: Add the timeout constant and SetGuard** - -Add `enforceTimeout` beside `driftTimeout` in the `const` block: - -```go - enforceTimeout = 5 * time.Second -``` - -Add the setter near `SetDriftJudge`: - -```go -// SetGuard injects the OS enforcement guard. A nil guard disables window-minimize -// enforcement; everything else behaves identically. -func (c *Controller) SetGuard(g enforce.Guard) { - c.mu.Lock() - defer c.mu.Unlock() - c.guard = g -} -``` - -- [ ] **Step 5: Add the enforcement-action helper** - -In `internal/session/session.go`, add near `evaluateDriftLocked`: - -```go -// enforceActionLocked returns the minimize thunk when this observation should be -// enforced β€” guard wired, level is block, and drift is confirmed β€” else nil. The -// returned func performs blocking X11 I/O and MUST run after the caller releases -// c.mu, following the off-lock async-I/O discipline used by the other roles. -func (c *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) - } - } -} -``` - -- [ ] **Step 6: Wire the synchronous (cached) path in RecordWindow** - -In `RecordWindow`, replace the tail that runs the judge launch: - -```go - launch := c.evaluateDriftLocked(now, snap) - c.mu.Unlock() - if launch != nil { - go launch() - } - c.notify() -``` - -with: - -```go - launch := c.evaluateDriftLocked(now, snap) - enforceAct := c.enforceActionLocked() - c.mu.Unlock() - if launch != nil { - go launch() - } - if enforceAct != nil { - go enforceAct() - } - c.notify() -``` - -(Name the local `enforceAct`, not `enforce` β€” `enforce` is the imported package.) - -- [ ] **Step 7: Wire the async path in the judge closure** - -In `evaluateDriftLocked`, the judge closure currently ends: - -```go - c.judgedClasses[class] = v - if c.stats != nil && c.stats.Current.Class == class { - c.applyVerdictLocked(v) - } - c.mu.Unlock() - c.notify() -``` - -Replace it with (capture the enforcement action under the lock, run it after unlock): - -```go - c.judgedClasses[class] = v - var enforceAct func() - if c.stats != nil && c.stats.Current.Class == class { - c.applyVerdictLocked(v) - enforceAct = c.enforceActionLocked() - } - c.mu.Unlock() - if enforceAct != nil { - enforceAct() - } - c.notify() -``` - -- [ ] **Step 8: Add the Enforced projection field** - -In `internal/session/session.go`, add to `DriftView`: - -```go - Enforced bool `json:"enforced,omitempty"` -``` - -and in `stateLocked`, where the `RuntimeActive` drift projection is built, replace: - -```go - st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage} -``` - -with: - -```go - enforced := c.enforcementLevel == domain.EnforcementBlock && c.driftStatus == driftDrifting - st.Drift = &DriftView{Status: status, Reason: c.driftReason, Nudge: c.nudgeMessage, Enforced: enforced} -``` - -- [ ] **Step 9: Run the tests to verify they pass** - -Run: `go test ./internal/session/ -run 'Minimize|BlockWithout|WarnLevel|OnTaskNever'` -Expected: PASS. - -- [ ] **Step 10: Run the full session suite under the race detector** - -Run: `go test -race ./internal/session/` -Expected: PASS, no race warnings (the enforce goroutine reads only its captured `guard`; all controller reads are under the lock). - -- [ ] **Step 11: Commit** - -```bash -git add internal/session/session.go internal/session/session_test.go -git commit -m "Minimize the off-task window on confirmed drift at the block level" -``` - ---- - -## Task 4: Daemon wiring and the on-the-wire web test - -**Files:** -- Modify: `cmd/antidriftd/main.go` (construct + inject the guard) -- Modify: `internal/web/web_test.go` (`stubJudge` + enforced-on-the-wire test) - -- [ ] **Step 1: Write the failing web test** - -Add to `internal/web/web_test.go`. A stub drift judge (the `ai`, `context`, `evidence`, `strings`, `time` imports are already present; add `time` if the build complains): - -```go -type stubJudge struct{ verdict ai.Verdict } - -func (j stubJudge) JudgeDrift(ctx context.Context, commitment, class, title string) (ai.Verdict, error) { - return j.verdict, nil -} - -func TestEnforceTogglePutsEnforcedOnTheWire(t *testing.T) { - s := newTestServer(t) - r := s.Router() - s.ctrl.SetDriftJudge(stubJudge{verdict: ai.Verdict{OnTask: false, Reason: "off-task"}}) - - if w := post(t, r, "/planning", ""); w.Code != http.StatusOK { - t.Fatalf("/planning code %d", w.Code) - } - body := `{"next_action":"a","success_condition":"b","timebox_secs":1500,"allowed_window_classes":["code"],"enforce":true}` - if w := post(t, r, "/commitment", body); w.Code != http.StatusOK { - t.Fatalf("/commitment code %d body %s", w.Code, w.Body.String()) - } - - s.ctrl.RecordWindow(evidence.WindowSnapshot{Class: "firefox", Title: "YouTube", Health: evidence.EvidenceHealth{Available: true}}) - - deadline := time.Now().Add(2 * time.Second) - for time.Now().Before(deadline) { - if strings.Contains(s.stateJSON(), `"enforced":true`) { - return - } - time.Sleep(5 * time.Millisecond) - } - t.Fatalf("state never reported enforced:true; last: %s", s.stateJSON()) -} -``` - -- [ ] **Step 2: Run the test to verify it passes already at the session layer** - -Run: `go test ./internal/web/ -run TestEnforceTogglePutsEnforcedOnTheWire` -Expected: PASS β€” the level plumbing (Task 2) and projection (Task 3) already make this green. (This test guards the web boundary and the JSON contract; if it fails, the `enforce` request field or the `enforced` projection is wired wrong.) - -- [ ] **Step 3: Wire the guard into the daemon** - -In `cmd/antidriftd/main.go`, add the import `"antidrift/internal/enforce"`, and after the evidence source is constructed (`src := evidence.NewSource()`), inject the guard: - -```go - ctrl.SetGuard(enforce.NewGuard()) - log.Printf("enforce: window-minimize guard") -``` - -- [ ] **Step 4: Build and vet** - -Run: `go build ./... && go vet ./...` -Expected: no output. - -- [ ] **Step 5: Commit** - -```bash -git add cmd/antidriftd/main.go internal/web/web_test.go -git commit -m "Wire the enforce guard into the daemon and assert it on the wire" -``` - ---- - -## Task 5: Planning toggle, drift-band note, and README - -**Files:** -- Modify: `internal/web/static/app.js` (planning form toggle + POST field; drift-band note) -- Modify: `internal/web/static/app.css` (note + toggle styling) -- Modify: `README.md` (M8 paragraph) - -This task is presentation only; it has no Go test (consistent with prior UI milestones). The behavior is already covered by Task 4's on-the-wire test; verify visually in the browser. - -- [ ] **Step 1: Add the planning toggle to the form** - -In `internal/web/static/app.js`, in the `} else if (rs === 'planning') {` branch, inside the final `
    ` (the one with Next action / Success condition / Minutes / Allowed apps), add the toggle right before the `Start commitment` button: - -```html - -

    Minimize off-task windows when you drift.

    -``` - -- [ ] **Step 2: Send the toggle value on commit** - -In the same branch, update the `start.onclick` POST body to include `enforce`: - -```js - start.onclick = () => post('/commitment', { - next_action: na.value.trim(), - success_condition: sc.value.trim(), - timebox_secs: Math.round(+mins.value * 60), - allowed_window_classes: (document.getElementById('apps').value || '') - .split(',').map(s => s.trim()).filter(Boolean), - enforce: document.getElementById('enforce').checked, - }); -``` - -- [ ] **Step 3: Add the drift-band note** - -In `updateActiveDrift`, in the `if (drift.status === 'drifting')` branch, add the note after the `drift-reason` line so it reads: - -```js - el.innerHTML = `
    Drift
    -
    ${drift.reason || 'This looks off task.'}
    - ${drift.enforced ? '
    Off-task window minimized.
    ' : ''} -
    - - - -
    `; -``` - -- [ ] **Step 4: Add styling** - -In `internal/web/static/app.css`, add: - -```css -.enforce-note { opacity: 0.8; font-size: 0.85em; } -.enforce-toggle { display: flex; align-items: center; gap: 0.4em; } -.hint { opacity: 0.7; font-size: 0.85em; margin: 0.2em 0 0.6em; } -``` - -(If a `.hint` rule already exists, keep the existing one and drop the duplicate here.) - -- [ ] **Step 5: Add the README paragraph** - -In `README.md`, at the top of the `## Status` section, add an M8 paragraph: - -```markdown -**M8 (Tier A) β€” Enforcement (window-minimize).** Drift finally costs something. -A planning-screen "Enforce focus" toggle arms the new `enforce.Guard` port: when -the drift judge confirms the active window is off-task, the daemon minimizes that -window (native X11, no `xdotool`). It is unprivileged, per-session (the chosen -enforcement level rides the snapshot), and degrades to today's advisory behavior -when off, unwired, or on a platform without the X11 adapter. -``` - -- [ ] **Step 6: Verify the build embeds the assets** - -Run: `go build ./... && go test ./internal/web/` -Expected: PASS (the `go:embed` static assets still build; existing web tests stay green). - -- [ ] **Step 7: Commit** - -```bash -git add internal/web/static/app.js internal/web/static/app.css README.md -git commit -m "Add the enforce toggle and drift-band minimize note" -``` - ---- - -## Final verification (after all tasks) - -- [ ] Run: `go build ./... && go vet ./... && go test -race ./...` -- [ ] Expected: all packages PASS, no race warnings. -- [ ] Manual (human): start the daemon, plan a session with **Enforce focus** checked and an allowed app, switch to an unrelated window, confirm it minimizes after the drift judge fires and the band shows "Off-task window minimized." diff --git a/docs/superpowers/plans/2026-06-01-m9-tame-session.md b/docs/superpowers/plans/2026-06-01-m9-tame-session.md deleted file mode 100644 index 52fddd8..0000000 --- a/docs/superpowers/plans/2026-06-01-m9-tame-session.md +++ /dev/null @@ -1,680 +0,0 @@ -# M9 β€” Tame `session.go` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Split the 1278-line `internal/session/session.go` into five focused files and consolidate the 4Γ—-duplicated async-fetch boilerplate into one helper, with **zero behavior change**. - -**Architecture:** Everything stays in `package session` (one `Controller` behind one `sync.Mutex` β€” sub-packages would force private state to be exported). Phase 1 moves declarations into focused files with no logic edits. Phase 2 adds a `runFetchAsync(timeout, fetch, stale, apply)` helper and migrates the four async roles to it one at a time. The existing `session_test.go` + `web_test.go` suites are the safety net; the contract is **green-to-green under `-race`** after every commit. - -**Tech Stack:** Go 1.26, stdlib `testing`, `go test -race`. No new dependencies. - ---- - -## Critical rules for every task - -- **No behavior change.** Move and re-shape code; never alter what it does. No exported symbol is renamed, removed, or has its signature changed. -- **Locate declarations by name** (`grep -n`), not by the line numbers in this plan β€” line numbers shift as earlier tasks move code. The line numbers here are hints from the pre-refactor file. -- **Move bodies verbatim.** In Phase 1, cut each declaration exactly as written and paste it into the new file. Do not edit logic. -- **Resolve imports with the compiler.** After moving declarations, run `go build ./internal/session/`. Add the imports the new file needs; delete imports the compiler reports as now-unused in `session.go`. If `goimports` is available (`go run golang.org/x/tools/cmd/goimports@latest -w internal/session/`), it does both automatically. The "expected imports" lists below are guidance, not gospel. -- **Green gate after every commit:** `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/`. All must pass. Never commit red. -- All commands run from the repo root `/home/felixm/dev/antidrift`. Commit messages end with the `Co-Authored-By` trailer used in this repo. - ---- - -## File Structure (target) - -All `package session`: - -- `session.go` β€” `Controller` struct, `New`, `SetClock`/`SetOnChange`/`notify`, `State`/`Deadline`, `persistLocked`, lifecycle transitions (`EnterPlanning`, `StartManualCommitment`, `Complete`/`Expire`/`enterReview`, `End`, `buildSummaryLocked`), `ErrNotPlanning`/`ErrNotActive`, the `unavailableTitle`/`sessionRetention` consts, `AllowedClassesForTest`/`EnforcementLevelForTest`. -- `views.go` β€” the 11 `*View` types, the `State` type, `stateLocked`, `bucketViews`. -- `roles.go` β€” `runFetchAsync` + coach/tasks/knowledge/reflection (`Set*`, `start*FetchLocked`/`RequestCoach`, `composedGroundingLocked`, `coachErrorMessage`, `buildReflectionFinishedLocked`, `buildReflectionHistory`) + their timeout/status consts. -- `drift.go` β€” `RecordWindow`, `evaluateDriftLocked`, `maybeNudgeLocked`, `enforceActionLocked`, `applyVerdictLocked`, `resetDriftLocked`, `OnTask`, `Refocus`, `recordTitleLocked`, `commitmentLineLocked`, `Set{DriftJudge,Guard,Nudge}`, `recentTitlesForTest`, the drift/nudge/enforce consts (`driftDebounce`/`driftTimeout`/`enforceTimeout`/`nudgeDebounce`/`nudgeTimeout`, the `drift*` status consts, `recentTitlesMax`). -- `stats.go` β€” `EvidenceStats`, `bucketKey`, `applyEvent`, `replayStats`, `keyFor`, `focusEvent`, `snapFromEvent`. - ---- - -## Task 1: Close the coverage gap β€” knowledge stale-discard test - -The audit found one gap: knowledge has no dedicated stale-discard test, while coach/tasks/reflection do. Add one **before** refactoring so the shared helper's stale path is covered for every role. This is a **characterization test** β€” it documents existing behavior and must pass against the current (un-refactored) code. - -**Files:** -- Modify: `internal/session/session_test.go` - -- [ ] **Step 1: Add a gate channel to `fakeSource`** - -The knowledge test double `fakeSource` (defined around line 1027 of `internal/session/session_test.go`) currently has no gate, so a load can't be held in flight. Add one, mirroring `fakeProvider` (line ~885) and `fakeCoach` (line ~251). Replace: - -```go -type fakeSource struct { - profile knowledge.Profile - err error -} - -func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { - return f.profile, f.err -} -``` - -with: - -```go -type fakeSource struct { - profile knowledge.Profile - err error - gate chan struct{} // if non-nil, Load blocks until it receives -} - -func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) { - if f.gate != nil { - <-f.gate - } - return f.profile, f.err -} -``` - -This is behavior-preserving for the existing knowledge tests (they construct `fakeSource` without a `gate`, so it stays nil and `Load` never blocks). - -- [ ] **Step 2: Write the characterization test** - -Add to `internal/session/session_test.go`, mirroring `TestStaleTasksFetchDiscardedAfterLeavingPlanning` (line ~964). The `State.Knowledge` field is a `*KnowledgeView` accessed as `st.Knowledge.Path`/`st.Knowledge.Chars` in the existing knowledge tests: - -```go -// TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning exercises the -// "left planning" arm of the discard guard in startKnowledgeFetchLocked: -// a slow knowledge load that returns after the user has left planning must -// not clobber fresh state. -func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) { - c, _ := newTestController(t) - staleGate := make(chan struct{}) - stale := &fakeSource{ - profile: knowledge.Profile{Text: "STALE", Path: "/stale"}, - gate: staleGate, - } - c.SetKnowledge(stale) - - if err := c.EnterPlanning(); err != nil { // launches the gated load (gen 1) - t.Fatal(err) - } - // Leave planning while the gen-1 load is still blocked on the gate. - if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil { - t.Fatal(err) - } - - close(staleGate) // release gen 1; it must be discarded (left planning) - // Give the released goroutine time to attempt its commit. - time.Sleep(50 * time.Millisecond) - - st := c.State() - if st.Knowledge != nil && st.Knowledge.Chars != 0 { - t.Fatalf("stale knowledge fetch clobbered state: %+v", st.Knowledge) - } -} -``` - -- [ ] **Step 3: Run it β€” expect PASS (characterizes current behavior)** - -Run: `go test -race ./internal/session/ -run TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning -v` -Expected: PASS. (It documents existing behavior; if it fails, the test is wrong or the fake's gate is mis-wired β€” fix the test, not production code.) - -- [ ] **Step 4: Full green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/session/session_test.go -git commit -m "Add knowledge stale-discard characterization test - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Extract `views.go` (file split, no logic change) - -**Files:** -- Create: `internal/session/views.go` -- Modify: `internal/session/session.go` - -- [ ] **Step 1: Create the new file with the package clause** - -Create `internal/session/views.go`: - -```go -package session -``` - -- [ ] **Step 2: Move the declarations verbatim** - -Cut these declarations from `session.go` (locate each by name with `grep -n 'type CommitmentView' internal/session/session.go` etc.) and paste them, unedited, into `views.go`: - -- the view types, in this order: `CommitmentView`, `ProposalView`, `DriftView`, `CoachView`, `TaskView`, `TasksView`, `KnowledgeView`, `ReflectionView`, `WindowView`, `BucketView`, `EvidenceView`, `State` -- the method `func (c *Controller) stateLocked() State { ... }` -- the function `func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { ... }` - -Do not change a single line of their bodies. - -- [ ] **Step 3: Fix imports** - -Run: `go build ./internal/session/` -`views.go` is expected to need: `sort`, `time`, `antidrift/internal/domain`, `antidrift/internal/evidence`, `antidrift/internal/tasks`. Add whatever the compiler reports as undefined, and remove from `session.go` any import the compiler now reports as unused. (Or run `goimports -w internal/session/` to do both.) - -- [ ] **Step 4: Green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS (pure code motion β€” behavior is identical). - -- [ ] **Step 5: Commit** - -```bash -git add internal/session/views.go internal/session/session.go -git commit -m "Split session views into views.go - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Extract `stats.go` (file split, no logic change) - -**Files:** -- Create: `internal/session/stats.go` -- Modify: `internal/session/session.go` - -- [ ] **Step 1: Create the new file** - -Create `internal/session/stats.go`: - -```go -package session -``` - -- [ ] **Step 2: Move the declarations verbatim** - -Cut these from `session.go` and paste unedited into `stats.go`: - -- `type bucketKey struct{ Class, Title string }` -- `type EvidenceStats struct { ... }` -- `func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot)` -- `func (c *Controller) replayStats(sessionID string)` -- `func keyFor(snap evidence.WindowSnapshot) bucketKey` -- `func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent` -- `func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot` - -- [ ] **Step 3: Fix imports** - -Run: `go build ./internal/session/` -`stats.go` is expected to need: `time`, `antidrift/internal/evidence`, `antidrift/internal/store`. Add/remove per the compiler (or `goimports -w`). - -- [ ] **Step 4: Green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/session/stats.go internal/session/session.go -git commit -m "Split evidence-stats accounting into stats.go - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Extract `drift.go` (file split, no logic change) - -**Files:** -- Create: `internal/session/drift.go` -- Modify: `internal/session/session.go` - -- [ ] **Step 1: Create the new file** - -Create `internal/session/drift.go`: - -```go -package session -``` - -- [ ] **Step 2: Move the consts verbatim** - -Cut these const groups from `session.go` and paste into `drift.go`: - -- the block `const ( driftDebounce ... driftTimeout ... enforceTimeout ... nudgeDebounce ... nudgeTimeout ... )` -- `const recentTitlesMax = 10` -- the block `const ( driftIdle ... driftPending ... driftOnTask ... driftDrifting ... )` - -- [ ] **Step 3: Move the methods verbatim** - -Cut these from `session.go` and paste unedited into `drift.go`: - -- `Set{DriftJudge,Guard,Nudge}` β€” i.e. `SetDriftJudge`, `SetGuard`, `SetNudge` -- `resetDriftLocked`, `OnTask`, `Refocus` -- `commitmentLineLocked` -- `RecordWindow`, `enforceActionLocked`, `evaluateDriftLocked`, `maybeNudgeLocked` -- `recordTitleLocked`, `applyVerdictLocked` -- `recentTitlesForTest` - -- [ ] **Step 4: Fix imports** - -Run: `go build ./internal/session/` -`drift.go` is expected to need: `context`, `log`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/enforce`, `antidrift/internal/evidence`. Add/remove per the compiler (or `goimports -w`). Pay attention: `session.go` may still need `enforce`/`ai` for fields in the `Controller` struct (the struct itself stays in `session.go`), so do not blindly delete its imports β€” let the compiler decide. - -- [ ] **Step 5: Green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. - -- [ ] **Step 6: Commit** - -```bash -git add internal/session/drift.go internal/session/session.go -git commit -m "Split drift/nudge/enforcement into drift.go - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: Extract `roles.go` (file split, no logic change) - -This isolates the four async roles so Phase 2's consolidation is a clean diff. `session.go` becomes the remainder (core controller + lifecycle). - -**Files:** -- Create: `internal/session/roles.go` -- Modify: `internal/session/session.go` - -- [ ] **Step 1: Create the new file** - -Create `internal/session/roles.go`: - -```go -package session -``` - -- [ ] **Step 2: Move the consts verbatim** - -Cut these from `session.go` into `roles.go`: - -- `const coachTimeout = 60 * time.Second` and the `const ( coachIdle ... coachPending ... coachReady ... coachError )` block -- `const tasksTimeout = 30 * time.Second` and the `const ( tasksIdle ... tasksReady ... tasksError )` block -- `const knowledgeTimeout = 10 * time.Second` and the `const ( knowledgeIdle ... knowledgeError )` block -- `const reflectionTimeout = 30 * time.Second`, `const reflectionHistoryN = 5`, `const reflectionTopBuckets = 3`, and the `const ( reflectionIdle ... reflectionAbsent )` block - -- [ ] **Step 3: Move the methods/functions verbatim** - -Cut these from `session.go` into `roles.go`: - -- coach: `SetCoach`, `resetCoachLocked`, `composedGroundingLocked`, `RequestCoach`, `coachErrorMessage` -- tasks: `SetTasks`, `startTasksFetchLocked` -- knowledge: `SetKnowledge`, `SetKnowledgePath`, `startKnowledgeFetchLocked` -- reflection: `SetReviewer`, `startReflectionFetchLocked`, `buildReflectionFinishedLocked`, `buildReflectionHistory` - -- [ ] **Step 4: Fix imports** - -Run: `go build ./internal/session/` -`roles.go` is expected to need: `context`, `fmt`, `strings`, `time`, `antidrift/internal/ai`, `antidrift/internal/domain`, `antidrift/internal/knowledge`, `antidrift/internal/store`, `antidrift/internal/tasks`. Add/remove per the compiler (or `goimports -w`). - -- [ ] **Step 5: Green gate + confirm the monolith shrank** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. -Run: `wc -l internal/session/*.go` -Expected: `session.go` is now well under ~450 lines; `views.go`/`roles.go`/`drift.go`/`stats.go` carry the rest. - -- [ ] **Step 6: Commit** - -```bash -git add internal/session/roles.go internal/session/session.go -git commit -m "Split async AI roles into roles.go - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 6: Add `runFetchAsync` and migrate the tasks role - -Phase 2 begins. Introduce the helper and convert the first role. The helper owns only the goroutine dance; the role keeps its setup, stale guard, and apply branches. - -**Files:** -- Modify: `internal/session/roles.go` - -- [ ] **Step 1: Add the helper** - -In `internal/session/roles.go`, add: - -```go -// runFetchAsync launches a generation-guarded background fetch. The caller has -// already captured its dependencies and (for the *Locked callers) holds c.mu; -// this method only spawns the goroutine, which re-acquires the lock itself. -// 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). On a non-stale completion the controller notifies. -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() - }() -} -``` - -- [ ] **Step 2: Migrate `startTasksFetchLocked`** - -Replace the body of `startTasksFetchLocked` (the `go func() { ... }()` block) so the function reads exactly: - -```go -func (c *Controller) startTasksFetchLocked() { - c.tasksList = nil - if c.tasksProvider == nil { - c.tasksStatus = tasksIdle - return - } - c.tasksGen++ - gen := c.tasksGen - c.tasksStatus = tasksPending - provider := c.tasksProvider - var list []tasks.Task - var err error - c.runFetchAsync(tasksTimeout, - func(ctx context.Context) { list, err = provider.Today(ctx) }, - func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning }, - func() { - if err != nil { - c.tasksStatus = tasksError - c.tasksList = nil - } else { - c.tasksStatus = tasksReady - c.tasksList = list - } - }) -} -``` - -- [ ] **Step 3: Targeted tests** - -Run: `go test -race ./internal/session/ -run 'Tasks' -v` -Expected: PASS β€” including `TestEnterPlanningFetchesTasks`, `TestTasksFetchError`, `TestNoProviderNoTasksView`, `TestTasksViewAbsentOutsidePlanning`, `TestStaleTasksFetchDiscardedAfterLeavingPlanning`. - -- [ ] **Step 4: Full green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/session/roles.go -git commit -m "Add runFetchAsync helper and migrate the tasks fetch - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 7: Migrate the knowledge role - -**Files:** -- Modify: `internal/session/roles.go` - -- [ ] **Step 1: Migrate `startKnowledgeFetchLocked`** - -Replace the `go func() { ... }()` block so the function reads exactly (note the three-branch apply and the `knowledgePath` write-back are preserved verbatim, now inside the `apply` closure): - -```go -func (c *Controller) startKnowledgeFetchLocked() { - c.knowledgeText = "" - c.knowledgeChars = 0 - if c.knowledgeSrc == nil { - c.knowledgeStatus = knowledgeIdle - return - } - c.knowledgeGen++ - gen := c.knowledgeGen - c.knowledgeStatus = knowledgePending - src := c.knowledgeSrc - path := c.knowledgePath - var prof knowledge.Profile - var err error - c.runFetchAsync(knowledgeTimeout, - func(ctx context.Context) { prof, err = src.Load(ctx, path) }, - func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning }, - func() { - if err != nil { - c.knowledgeStatus = knowledgeError - c.knowledgeText = "" - c.knowledgeChars = 0 - if prof.Path != "" { - c.knowledgePath = prof.Path - } - } else if prof.Text == "" { - c.knowledgeStatus = knowledgeAbsent - c.knowledgeText = "" - c.knowledgeChars = 0 - c.knowledgePath = prof.Path - } else { - c.knowledgeStatus = knowledgeReady - c.knowledgeText = prof.Text - c.knowledgeChars = len(prof.Text) - c.knowledgePath = prof.Path - } - }) -} -``` - -- [ ] **Step 2: Targeted tests** - -Run: `go test -race ./internal/session/ -run 'Knowledge' -v` -Expected: PASS β€” including `TestEnterPlanningLoadsKnowledge`, `TestKnowledgeAbsentWhenEmptyText`, `TestKnowledgeLoadError`, `TestNoSourceNoKnowledgeView`, `TestKnowledgeViewAbsentOutsidePlanning`, `TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning` (from Task 1). - -- [ ] **Step 3: Full green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. - -- [ ] **Step 4: Commit** - -```bash -git add internal/session/roles.go -git commit -m "Migrate the knowledge fetch onto runFetchAsync - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 8: Migrate the coach role - -`RequestCoach` is the request-triggered entry point: it manages its own lock and notifies the pending state **before** launching. Keep that pre-launch unlock+notify; only the trailing `go func() { ... }()` is replaced. - -**Files:** -- Modify: `internal/session/roles.go` - -- [ ] **Step 1: Migrate `RequestCoach`** - -Replace the trailing goroutine. The function must read exactly: - -```go -func (c *Controller) RequestCoach(intent string) error { - c.mu.Lock() - if c.runtimeState != domain.RuntimePlanning { - c.mu.Unlock() - return ErrNotPlanning - } - if c.coach == nil { - c.coachStatus = coachError - c.coachErr = "coach unavailable" - c.coachProposal = nil - c.mu.Unlock() - c.notify() - return nil - } - c.coachGen++ - gen := c.coachGen - c.coachStatus = coachPending - c.coachErr = "" - c.coachProposal = nil - coach := c.coach - grounding := c.composedGroundingLocked() - c.mu.Unlock() - c.notify() - - var prop ai.Proposal - var err error - c.runFetchAsync(coachTimeout, - func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) }, - func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning }, - func() { - if err != nil { - c.coachStatus = coachError - c.coachErr = coachErrorMessage(err) - c.coachProposal = nil - } else { - c.coachStatus = coachReady - c.coachProposal = &prop - c.coachErr = "" - } - }) - return nil -} -``` - -Note: `runFetchAsync` is called here **after** `c.mu.Unlock()`. That is correct β€” the helper only spawns the goroutine and touches no `c` field before the goroutine re-locks, so holding the lock is not required. - -- [ ] **Step 2: Targeted tests** - -Run: `go test -race ./internal/session/ -run 'Coach' -v` -Expected: PASS β€” including `TestRequestCoachReady`, `TestRequestCoachError`, `TestRequestCoachUnavailable`, `TestRequestCoachWrongState`, `TestRequestCoachStaleResultDiscarded`, `TestCoachReceivesCachedGrounding`, `TestLeavingPlanningClearsCoach`, `TestCarryForwardGroundsNextCoach`. - -- [ ] **Step 3: Full green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. - -- [ ] **Step 4: Commit** - -```bash -git add internal/session/roles.go -git commit -m "Migrate the coach fetch onto runFetchAsync - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 9: Migrate the reflection role - -Reflection is the subtle one: its stale guard is **gen-only** (no Planning gate β€” the carry-forward must survive `End` before the reviewer returns), it reads `history` **synchronously under the lock before** the goroutine (a happens-before requirement against `End`'s audit-chain append), and its apply calls `persistLocked`. All three must be preserved. - -**Files:** -- Modify: `internal/session/roles.go` - -- [ ] **Step 1: Migrate `startReflectionFetchLocked`** - -Replace the trailing `go func() { ... }()` block. The function must read exactly (the synchronous `history := buildReflectionHistory(c.auditPath)` stays **outside** the closures, before the `runFetchAsync` call; `persistLocked` moves **into** the `apply` closure): - -```go -func (c *Controller) startReflectionFetchLocked() { - c.reflectionRecap = "" - c.carryForward = "" - if c.reviewer == nil { - c.reflectionStatus = reflectionIdle - return - } - c.reflectionGen++ - gen := c.reflectionGen - c.reflectionStatus = reflectionPending - reviewer := c.reviewer - finished := c.buildReflectionFinishedLocked() - // Read the history synchronously, here under the lock, on purpose: it must - // happen-before End appends the just-finished session to the audit chain, so - // that session is excluded from "recent history" and not double-counted (it - // is already carried in `finished`). Moving this into the goroutine would - // race with End's append and reintroduce that double-count. The read is - // bounded to reflectionHistoryN summaries and runs once per Review entry, not - // on any hot path. - history := buildReflectionHistory(c.auditPath) - var refl ai.Reflection - var err error - c.runFetchAsync(reflectionTimeout, - func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) }, - func() bool { return gen != c.reflectionGen }, - func() { - if err != nil || strings.TrimSpace(refl.Recap) == "" { - c.reflectionStatus = reflectionAbsent - c.reflectionRecap = "" - c.carryForward = "" - } else { - c.reflectionStatus = reflectionReady - c.reflectionRecap = refl.Recap - c.carryForward = refl.CarryForward - } - _ = c.persistLocked() - }) -} -``` - -Confirm the reviewer's return type is `ai.Reflection` (grep `internal/ai` for `func (b *Backend) Review`); if the type name differs, match it in the `var refl` declaration. - -- [ ] **Step 2: Targeted tests** - -Run: `go test -race ./internal/session/ -run 'Reflection|CarryForward' -v` -Expected: PASS β€” including `TestReflectionFetchedOnReview`, `TestNoReviewerYieldsIdleReflection`, `TestCarryForwardGroundsNextCoach`, `TestReflectionStaleResultDiscarded`, `TestCarryForwardSurvivesRestart`. - -- [ ] **Step 3: Full green gate** - -Run: `go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/` -Expected: all PASS. - -- [ ] **Step 4: Commit** - -```bash -git add internal/session/roles.go -git commit -m "Migrate the reflection fetch onto runFetchAsync - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 10: Final verification - -**Files:** none (verification only). - -- [ ] **Step 1: Confirm no inline async-fetch goroutines remain** - -Run: `grep -n 'go func()' internal/session/*.go` -Expected: the only matches are inside `runFetchAsync` (in `roles.go`) and any pre-existing goroutines in `drift.go`/elsewhere that are **not** the four migrated role fetches. The coach/tasks/knowledge/reflection fetches must no longer contain their own `go func()` β€” they go through `runFetchAsync`. - -- [ ] **Step 2: Confirm the split** - -Run: `wc -l internal/session/*.go` -Expected: five focused files (`session.go`, `views.go`, `roles.go`, `drift.go`, `stats.go`), none dominating; the old 1278-line monolith is gone. - -- [ ] **Step 3: Full suite under the race detector** - -Run: `go build ./... && go vet ./... && go test -race ./...` -Expected: all packages PASS, no race warnings. - -- [ ] **Step 4: Confirm behavior contract** - -Confirm via `git log --oneline` that the milestone is a series of small commits, each green, with no change to any exported signature (spot-check: `git diff ^..HEAD -- internal/session/session.go internal/web internal/store cmd` shows no signature/behavior changes outside the moved/β–Έconsolidated session code, and no `.go` file outside `internal/session/` was modified except as already committed in prior milestones). - ---- - -## Self-review notes (for the executor) - -- Every task is **green-to-green**; if any `go test -race` goes red, stop and fix before committing β€” a red gate means a real behavior change slipped in. -- The risky preservation points, all called out in their tasks: reflection's **gen-only** stale guard, its **synchronous history read** before the goroutine, and its **`persistLocked`** in apply; coach's **pre-launch unlock+notify**; knowledge's **three-branch apply + `knowledgePath` write-back**. -- No exported symbol is renamed or moved out of `package session`; `web_test.go`'s cross-package use of `AllowedClassesForTest`/`EnforcementLevelForTest` keeps working because those stay in `session.go` as regular (non-`_test.go`) methods. diff --git a/docs/superpowers/plans/2026-06-01-settings-page.md b/docs/superpowers/plans/2026-06-01-settings-page.md deleted file mode 100644 index b2ff555..0000000 --- a/docs/superpowers/plans/2026-06-01-settings-page.md +++ /dev/null @@ -1,996 +0,0 @@ -# Settings file + settings page Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let the user edit the AI backend, Marvin tasks command, and knowledge profile path at runtime from a settings page in the web UI, persisted to a settings file, with a server-side file picker for the profile path. - -**Architecture:** A new leaf `internal/settings` package owns the `Settings` struct and its JSON file (`~/.antidrift/settings.json`). `main.go` builds an `applyFn func(settings.Settings) error` closure that constructs adapters and calls the controller's existing setters, and injects it into the web server. The server's `POST /settings` handler validates-and-applies via that closure (atomically β€” invalid backend mutates nothing), then persists. A `GET /fs/browse` endpoint backs a custom file-picker modal. `web` never imports `ai`/`tasks`/`knowledge`. - -**Tech Stack:** Go, Gin, vanilla-JS SSE frontend. Spec: `docs/superpowers/specs/2026-06-01-settings-page-design.md`. - ---- - -## File structure - -- `internal/settings/settings.go` (new) β€” `Settings` struct, `ErrInvalidBackend`, `DefaultPath`, `Load`, `Save`, `SeedFromEnv`. -- `internal/settings/settings_test.go` (new) β€” round-trip, seed, first-run. -- `internal/web/settings_handlers.go` (new) β€” server settings fields, `SetSettings`, `handleGetSettings`, `handlePostSettings`, `handleBrowse`. -- `internal/web/settings_handlers_test.go` (new) β€” GET/POST/browse tests with a fake applier. -- `internal/web/web.go` (modify) β€” register routes; drop `POST /knowledge/path` + `handleKnowledgePath`. -- `cmd/antidriftd/main.go` (modify) β€” load-or-seed settings, build `applyFn`, inject into server. -- `internal/web/static/index.html` (modify) β€” gear button + overlay container. -- `internal/web/static/app.js` (modify) β€” settings overlay + browse modal; repoint knowledge "change" link. -- `internal/web/static/app.css` (modify) β€” gear, overlay, modal, browse styles. - ---- - -## Task 1: `internal/settings` package - -**Files:** -- Create: `internal/settings/settings.go` -- Test: `internal/settings/settings_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/settings/settings_test.go`: - -```go -package settings - -import ( - "os" - "path/filepath" - "testing" -) - -func TestSaveLoadRoundTrip(t *testing.T) { - path := filepath.Join(t.TempDir(), "settings.json") - want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} - if err := Save(path, want); err != nil { - t.Fatalf("save: %v", err) - } - got, err := Load(path) - if err != nil { - t.Fatalf("load: %v", err) - } - if got != want { - t.Errorf("round trip = %+v, want %+v", got, want) - } -} - -func TestLoadMissingFileIsError(t *testing.T) { - _, err := Load(filepath.Join(t.TempDir(), "nope.json")) - if err == nil { - t.Fatal("want error loading missing file, got nil") - } -} - -func TestSeedFromEnvReadsVars(t *testing.T) { - t.Setenv("ANTIDRIFT_AI_BACKEND", "codex") - t.Setenv("ANTIDRIFT_MARVIN_CMD", "uv run am") - t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "/tmp/k.md") - got := SeedFromEnv() - want := Settings{AIBackend: "codex", MarvinCmd: "uv run am", KnowledgePath: "/tmp/k.md"} - if got != want { - t.Errorf("SeedFromEnv = %+v, want %+v", got, want) - } -} - -func TestSeedFromEnvDefaultsBackend(t *testing.T) { - t.Setenv("ANTIDRIFT_AI_BACKEND", "") - t.Setenv("ANTIDRIFT_MARVIN_CMD", "") - t.Setenv("ANTIDRIFT_KNOWLEDGE_FILE", "") - got := SeedFromEnv() - if got.AIBackend != "claude" { - t.Errorf("default backend = %q, want claude", got.AIBackend) - } -} - -func TestSaveCreatesDir(t *testing.T) { - path := filepath.Join(t.TempDir(), "sub", "settings.json") - if err := Save(path, Settings{AIBackend: "claude"}); err != nil { - t.Fatalf("save: %v", err) - } - if _, err := os.Stat(path); err != nil { - t.Fatalf("file not written: %v", err) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/settings/` -Expected: FAIL β€” `undefined: Settings`, `undefined: Save`, etc. - -- [ ] **Step 3: Write the implementation** - -Create `internal/settings/settings.go`: - -```go -// Package settings persists the daemon's user-editable configuration as a single -// JSON file (~/.antidrift/settings.json), parallel to the store snapshot. It is a -// leaf type package: it imports nothing else in the app so any layer may depend -// on the Settings value without pulling in adapters. -package settings - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" -) - -// ErrInvalidBackend marks a settings value whose ai_backend is not a known -// backend. The applier returns it (wrapped) so the HTTP layer can map it to 400 -// without importing the ai package. -var ErrInvalidBackend = errors.New("settings: invalid ai backend") - -// Settings is the user-editable configuration. Field names mirror the env vars -// they replace: ANTIDRIFT_AI_BACKEND, ANTIDRIFT_MARVIN_CMD, ANTIDRIFT_KNOWLEDGE_FILE. -type Settings struct { - AIBackend string `json:"ai_backend"` - MarvinCmd string `json:"marvin_cmd"` - KnowledgePath string `json:"knowledge_path"` -} - -// DefaultPath returns ~/.antidrift/settings.json. -func DefaultPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".antidrift", "settings.json"), nil -} - -// Load reads a settings file. A missing file is an error; callers treat that as -// "first run" and seed instead. -func Load(path string) (Settings, error) { - data, err := os.ReadFile(path) - if err != nil { - return Settings{}, err - } - var s Settings - if err := json.Unmarshal(data, &s); err != nil { - return Settings{}, err - } - return s, nil -} - -// Save writes settings atomically (temp file + rename), creating the directory -// if needed. Mirrors store.Save. -func Save(path string, s Settings) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - return err - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - return err - } - return os.Rename(tmp, path) -} - -// SeedFromEnv builds a Settings from the legacy ANTIDRIFT_* env vars, applying -// the same defaults the daemon used before the settings file existed. An unset -// AI backend defaults to "claude" (matching ai.NewBackend("")). -func SeedFromEnv() Settings { - backend := os.Getenv("ANTIDRIFT_AI_BACKEND") - if backend == "" { - backend = "claude" - } - return Settings{ - AIBackend: backend, - MarvinCmd: os.Getenv("ANTIDRIFT_MARVIN_CMD"), - KnowledgePath: os.Getenv("ANTIDRIFT_KNOWLEDGE_FILE"), - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/settings/` -Expected: PASS (all 5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/settings/ -git commit -m "Add settings package: Settings file load/save/seed - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 2: Web settings handlers (GET + POST) - -**Files:** -- Create: `internal/web/settings_handlers.go` -- Test: `internal/web/settings_handlers_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `internal/web/settings_handlers_test.go`: - -```go -package web - -import ( - "encoding/json" - "net/http" - "os" - "path/filepath" - "testing" - - "antidrift/internal/settings" -) - -// fakeApplier records the last settings it was asked to apply and can be told to -// reject with ErrInvalidBackend. -type fakeApplier struct { - called int - last settings.Settings - reject bool -} - -func (f *fakeApplier) apply(s settings.Settings) error { - if f.reject { - return settings.ErrInvalidBackend - } - f.called++ - f.last = s - return nil -} - -func TestGetSettingsReturnsCurrent(t *testing.T) { - s := newTestServer(t) - cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"} - fa := &fakeApplier{} - s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply) - r := s.Router() - - req := httptest.NewRequest(http.MethodGet, "/settings", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - var got settings.Settings - if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { - t.Fatalf("decode: %v", err) - } - if got != cur { - t.Errorf("got %+v, want %+v", got, cur) - } -} - -func TestPostSettingsValidAppliesAndSaves(t *testing.T) { - s := newTestServer(t) - path := filepath.Join(t.TempDir(), "settings.json") - fa := &fakeApplier{} - s.SetSettings(path, settings.Settings{}, fa.apply) - r := s.Router() - - body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}` - w := post(t, r, "/settings", body) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String()) - } - if fa.called != 1 { - t.Fatalf("applier called %d times, want 1", fa.called) - } - if fa.last.AIBackend != "codex" { - t.Errorf("applied backend = %q, want codex", fa.last.AIBackend) - } - if _, err := os.Stat(path); err != nil { - t.Errorf("settings file not written: %v", err) - } -} - -func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) { - s := newTestServer(t) - path := filepath.Join(t.TempDir(), "settings.json") - fa := &fakeApplier{reject: true} - s.SetSettings(path, settings.Settings{}, fa.apply) - r := s.Router() - - w := post(t, r, "/settings", `{"ai_backend":"bogus"}`) - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", w.Code) - } - if _, err := os.Stat(path); !os.IsNotExist(err) { - t.Errorf("settings file should not exist after rejected save, stat err = %v", err) - } -} -``` - -Note: `httptest`, `post`, and `newTestServer` already exist in `web_test.go` (same package), so they are reused here. - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/web/ -run TestSettings -v` (and `TestGetSettings`, `TestPostSettings`) -Run: `go test ./internal/web/ -run 'Settings' -v` -Expected: FAIL β€” `s.SetSettings undefined`. - -- [ ] **Step 3: Write the implementation** - -Create `internal/web/settings_handlers.go`: - -```go -package web - -import ( - "net/http" - - "antidrift/internal/settings" - - "github.com/gin-gonic/gin" -) - -// SetSettings injects the persisted settings, their file path, and the applier -// closure (built by main, which owns adapter construction). Mirrors the -// controller's Set* injection so web never imports ai/tasks/knowledge. -func (s *Server) SetSettings(path string, current settings.Settings, apply func(settings.Settings) error) { - s.settingsMu.Lock() - s.settingsPath = path - s.settings = current - s.applyFn = apply - s.settingsMu.Unlock() -} - -func (s *Server) handleGetSettings(c *gin.Context) { - s.settingsMu.Lock() - cur := s.settings - s.settingsMu.Unlock() - c.JSON(http.StatusOK, cur) -} - -// handlePostSettings validates-and-applies atomically, then persists. The applier -// checks the backend before mutating any state, so an invalid backend yields 400 -// with nothing saved and the prior wiring intact. -func (s *Server) handlePostSettings(c *gin.Context) { - var req settings.Settings - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) - return - } - s.settingsMu.Lock() - apply := s.applyFn - path := s.settingsPath - s.settingsMu.Unlock() - if apply == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "settings not wired"}) - return - } - if err := apply(req); err != nil { - // Apply validates the backend before mutating anything, so any error - // (invalid backend or otherwise) means nothing changed; surface it as 400. - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := settings.Save(path, req); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - s.settingsMu.Lock() - s.settings = req - s.settingsMu.Unlock() - s.broadcast() - c.JSON(http.StatusOK, req) -} -``` - -Add the server fields. In `internal/web/web.go`, modify the `Server` struct (currently lines 26-32) to: - -```go -// Server wires the session controller to HTTP and SSE. -type Server struct { - ctrl *session.Controller - bcast *Broadcaster - - mu sync.Mutex - timer *time.Timer - - settingsMu sync.Mutex - settings settings.Settings - settingsPath string - applyFn func(settings.Settings) error -} -``` - -Add the import `"antidrift/internal/settings"` to `web.go`'s import block. - -Register the routes in `internal/web/web.go` `Router()`, after the existing `r.POST(...)` lines (around line 71): - -```go - r.GET("/settings", s.handleGetSettings) - r.POST("/settings", s.handlePostSettings) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/web/ -run 'Settings' -v` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go -git commit -m "Add GET/POST /settings handlers with injected applier - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 3: `GET /fs/browse` file-list endpoint - -**Files:** -- Modify: `internal/web/settings_handlers.go` -- Test: `internal/web/settings_handlers_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `internal/web/settings_handlers_test.go`: - -```go -func TestBrowseListsDirsAndMarkdown(t *testing.T) { - dir := t.TempDir() - if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - - s := newTestServer(t) - s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) - r := s.Router() - - req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200", w.Code) - } - var resp struct { - Dir string `json:"dir"` - Parent string `json:"parent"` - Entries []struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` - } `json:"entries"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("decode: %v", err) - } - if resp.Parent != filepath.Dir(dir) { - t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir)) - } - var names []string - for _, e := range resp.Entries { - names = append(names, e.Name) - } - hasSub, hasMd, hasTxt := false, false, false - for _, e := range resp.Entries { - switch e.Name { - case "sub": - hasSub = e.IsDir - case "profile.md": - hasMd = !e.IsDir - case "notes.txt": - hasTxt = true - } - } - if !hasSub || !hasMd { - t.Errorf("missing dir or .md entry; names = %v", names) - } - if hasTxt { - t.Errorf(".txt should be filtered out; names = %v", names) - } -} - -func TestBrowseBadDirIs400(t *testing.T) { - s := newTestServer(t) - s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil }) - r := s.Router() - req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) - if w.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want 400", w.Code) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `go test ./internal/web/ -run TestBrowse -v` -Expected: FAIL β€” 404 (route not registered) / `handleBrowse undefined`. - -- [ ] **Step 3: Write the implementation** - -Append to `internal/web/settings_handlers.go` (add `"os"`, `"path/filepath"`, `"strings"` to its imports): - -```go -type browseEntry struct { - Name string `json:"name"` - Path string `json:"path"` - IsDir bool `json:"is_dir"` -} - -type browseResponse struct { - Dir string `json:"dir"` - Parent string `json:"parent"` - Entries []browseEntry `json:"entries"` -} - -// handleBrowse lists subdirectories plus .md files under dir, so the UI can build -// a file picker that returns a real server-side path. Dotfiles are NOT hidden β€” -// the default profile lives under ~/.antidrift. Localhost-only daemon; no jail -// beyond OS permissions (same trust boundary as the rest of the UI). -func (s *Server) handleBrowse(c *gin.Context) { - dir := strings.TrimSpace(c.Query("dir")) - if dir == "" { - dir = s.browseStart() - } - dir = filepath.Clean(dir) - infos, err := os.ReadDir(dir) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - resp := browseResponse{Dir: dir, Parent: filepath.Dir(dir), Entries: []browseEntry{}} - for _, e := range infos { - name := e.Name() - full := filepath.Join(dir, name) - if e.IsDir() { - resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full, IsDir: true}) - } else if strings.HasSuffix(name, ".md") { - resp.Entries = append(resp.Entries, browseEntry{Name: name, Path: full}) - } - } - c.JSON(http.StatusOK, resp) -} - -// browseStart picks the directory of the current knowledge path, else $HOME. -func (s *Server) browseStart() string { - s.settingsMu.Lock() - kp := s.settings.KnowledgePath - s.settingsMu.Unlock() - if kp != "" { - return filepath.Dir(kp) - } - if home, err := os.UserHomeDir(); err == nil { - return home - } - return "/" -} -``` - -Register the route in `internal/web/web.go` `Router()`, next to the other settings routes: - -```go - r.GET("/fs/browse", s.handleBrowse) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `go test ./internal/web/ -run TestBrowse -v` -Expected: PASS (2 tests). - -- [ ] **Step 5: Commit** - -```bash -git add internal/web/settings_handlers.go internal/web/settings_handlers_test.go internal/web/web.go -git commit -m "Add GET /fs/browse directory-listing endpoint - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 4: Remove the now-redundant `/knowledge/path` endpoint - -**Files:** -- Modify: `internal/web/web.go` - -- [ ] **Step 1: Delete the route and handler** - -In `internal/web/web.go`, remove the route registration line: - -```go - r.POST("/knowledge/path", s.handleKnowledgePath) -``` - -and delete the `knowledgePathRequest` type plus the `handleKnowledgePath` function (currently `web.go:143-159`): - -```go -type knowledgePathRequest struct { - Path string `json:"path"` -} - -// handleKnowledgePath repoints the profile file at runtime ... -func (s *Server) handleKnowledgePath(c *gin.Context) { - ... -} -``` - -The controller's `SetKnowledgePath` method stays β€” the applier (Task 5) calls it. - -- [ ] **Step 2: Update the test that exercises the removed route** - -Search for any test referencing `/knowledge/path`: - -Run: `grep -rn "knowledge/path" internal/web/` - -If `TestPlanningStatePayloadCarriesKnowledge` (or any test) POSTs to `/knowledge/path`, repoint it to `/settings`. Concretely, replace a call like -`post(t, r, "/knowledge/path", `+"`"+`{"path":"/tmp/x.md"}`+"`"+`)` -with wiring settings first and posting the full object: - -```go - s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), - settings.Settings{}, func(settings.Settings) error { return nil }) - post(t, r, "/settings", `{"ai_backend":"claude","marvin_cmd":"","knowledge_path":"/tmp/x.md"}`) -``` - -If no test references it, skip this step. - -- [ ] **Step 3: Verify build and tests** - -Run: `go build ./... && go test ./internal/web/` -Expected: PASS, no references to `handleKnowledgePath` remain. - -Run: `grep -rn "knowledge/path\|handleKnowledgePath" internal/` -Expected: no matches. - -- [ ] **Step 4: Commit** - -```bash -git add internal/web/ -git commit -m "Remove /knowledge/path; folded into /settings - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 5: Wire settings into `main.go` (applier + first-run seed) - -**Files:** -- Modify: `cmd/antidriftd/main.go` - -- [ ] **Step 1: Replace the env-var wiring with settings load-or-seed + applier** - -In `cmd/antidriftd/main.go`, replace the three wiring blocks (currently lines 39-65: the AI backend block, the `SetTasks` block, and the `SetKnowledge` block) with the following. Keep everything before (`store.DefaultPath`, `session.New`, `web.NewServer`, `srv.Init()`) and after (statusfile, guard, evidence, browser, Run) unchanged. - -```go - // Resolve and load settings, seeding from the legacy ANTIDRIFT_* env vars on - // first run. After first run the file is the sole source of truth; env is - // ignored. A missing file is the first-run signal. - settingsPath, err := settings.DefaultPath() - if err != nil { - log.Fatalf("resolve settings path: %v", err) - } - cfg, err := settings.Load(settingsPath) - if err != nil { - cfg = settings.SeedFromEnv() - if err := settings.Save(settingsPath, cfg); err != nil { - log.Printf("settings: could not write %s (continuing): %v", settingsPath, err) - } else { - log.Printf("settings: seeded %s from environment", settingsPath) - } - } - - // The knowledge source is a single file adapter whose path is driven entirely - // by SetKnowledgePath, so startup and live settings edits share one code path. - ctrl.SetKnowledge(knowledge.NewFileSource("")) - - // applyFn re-wires the running daemon from a Settings value: AI backend + - // service (coach/drift/nudge/reviewer), tasks command, and knowledge path. It - // validates the backend FIRST and mutates nothing on failure, so POST /settings - // can reject an invalid backend atomically. - applyFn := func(s settings.Settings) error { - backend, err := ai.NewBackend(s.AIBackend) - if err != nil { - return fmt.Errorf("%w: %v", settings.ErrInvalidBackend, err) - } - svc := ai.NewService(backend) - ctrl.SetCoach(svc) - ctrl.SetDriftJudge(svc) - ctrl.SetNudge(svc) - ctrl.SetReviewer(svc) - ctrl.SetTasks(tasks.NewMarvin(s.MarvinCmd)) - ctrl.SetKnowledgePath(s.KnowledgePath) - return nil - } - - // Apply once at startup. A bad persisted backend should not be fatal: fall back - // to claude (always valid), persist the correction, and re-apply. - if err := applyFn(cfg); err != nil { - log.Printf("settings: %v; falling back to claude backend", err) - cfg.AIBackend = "claude" - _ = settings.Save(settingsPath, cfg) - if err := applyFn(cfg); err != nil { - log.Fatalf("settings: apply failed even with claude: %v", err) - } - } - srv.SetSettings(settingsPath, cfg, applyFn) - log.Printf("settings: ai=%s, marvin=%q, knowledge=%q", cfg.AIBackend, cfg.MarvinCmd, cfg.KnowledgePath) -``` - -Add `"fmt"` and `"antidrift/internal/settings"` to the import block. The `"os"` import may become unused (it was only used for `os.Getenv`) β€” check: `openBrowser` does not use `os`. Remove `"os"` from the import block if `go build` reports it unused. - -- [ ] **Step 2: Verify build** - -Run: `go build ./...` -Expected: success. If it reports `"os" imported and not used`, remove the `"os"` import line and rebuild. - -- [ ] **Step 3: Run the full test suite + vet** - -Run: `go test ./... && go vet ./...` -Expected: all packages PASS, vet clean. - -- [ ] **Step 4: Manual smoke test** - -Run (no env vars needed now): - -```bash -go run ./cmd/antidriftd -``` - -Expected log lines include `settings: seeded ... from environment` (first run) or `settings: ai=claude, ...`. Confirm `~/.antidrift/settings.json` exists: - -Run: `cat ~/.antidrift/settings.json` -Expected: JSON with the three keys. - -Stop the daemon (Ctrl-C). - -- [ ] **Step 5: Commit** - -```bash -git add cmd/antidriftd/main.go -git commit -m "Drive daemon config from settings file via injected applier - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Task 6: Settings UI β€” gear, overlay, browse modal - -**Files:** -- Modify: `internal/web/static/index.html` -- Modify: `internal/web/static/app.js` -- Modify: `internal/web/static/app.css` - -This task has no Go test harness (the repo has no JS tests); verification is `go build` + manual browser check. Write the exact code below. - -- [ ] **Step 1: Add the gear button and overlay container to `index.html`** - -Replace the `
    …
    ` block (lines 12-15) with: - -```html -
    -

    AntiDrift

    -
    connecting…
    - -
    -``` - -The overlay lives OUTSIDE `#view`, so the SSE re-render (which replaces `view.innerHTML`) never wipes it. - -- [ ] **Step 2: Repoint the knowledge "change" link in `app.js`** - -In `internal/web/static/app.js`, in `updatePlanningKnowledge` (lines 184-187), replace the prompt-based handler: - -```js - document.getElementById('knowChange').onclick = () => { - const next = prompt('Profile file path (blank = default):', k.path || ''); - if (next !== null) post('/knowledge/path', { path: next.trim() }); - }; -``` - -with: - -```js - document.getElementById('knowChange').onclick = openSettings; -``` - -- [ ] **Step 3: Append the settings overlay + browse logic to `app.js`** - -Add at the end of `internal/web/static/app.js`, AFTER the `EventSource` block: - -```js -// ---- Settings overlay ---- -function openSettings() { - fetch('/settings').then(r => r.json()).then(s => { - const ov = document.getElementById('settingsOverlay'); - ov.innerHTML = ` - `; - document.getElementById('setBackend').value = s.ai_backend || 'claude'; - document.getElementById('setMarvin').value = s.marvin_cmd || ''; - document.getElementById('setKnow').value = s.knowledge_path || ''; - ov.hidden = false; - document.getElementById('setCancel').onclick = closeSettings; - document.getElementById('setSave').onclick = saveSettings; - document.getElementById('setBrowse').onclick = () => - loadBrowse(parentDir(document.getElementById('setKnow').value)); - }); -} - -function closeSettings() { - const ov = document.getElementById('settingsOverlay'); - ov.hidden = true; - ov.innerHTML = ''; -} - -function saveSettings() { - const body = { - ai_backend: document.getElementById('setBackend').value, - marvin_cmd: document.getElementById('setMarvin').value.trim(), - knowledge_path: document.getElementById('setKnow').value.trim(), - }; - post('/settings', body).then(r => { - if (r.ok) { closeSettings(); return; } - return r.json().then(e => { - document.getElementById('setError').textContent = e.error || 'save failed'; - }); - }); -} - -function parentDir(path) { - if (!path) return ''; - return path.replace(/\/[^/]*$/, ''); -} - -function loadBrowse(dir) { - const pane = document.getElementById('browsePane'); - pane.hidden = false; - fetch('/fs/browse?dir=' + encodeURIComponent(dir || '')).then(r => { - if (!r.ok) return r.json().then(e => { throw new Error(e.error || 'cannot read directory'); }); - return r.json(); - }).then(d => { - const items = []; - if (d.parent && d.parent !== d.dir) { - items.push(`
  • `); - } - for (const e of d.entries) { - if (e.is_dir) { - items.push(`
  • `); - } else { - items.push(`
  • `); - } - } - pane.innerHTML = `
    ${d.dir}
      ${items.join('')}
    `; - pane.querySelectorAll('button[data-dir]').forEach(b => - b.onclick = () => loadBrowse(b.getAttribute('data-dir'))); - pane.querySelectorAll('button[data-file]').forEach(b => - b.onclick = () => { - document.getElementById('setKnow').value = b.getAttribute('data-file'); - pane.hidden = true; - pane.innerHTML = ''; - }); - }).catch(err => { - pane.innerHTML = `
    ${err.message}
    `; - }); -} - -document.getElementById('gear').onclick = openSettings; -``` - -- [ ] **Step 4: Add styles to `app.css`** - -Append to `internal/web/static/app.css`: - -```css -/* Settings: header gear + overlay modal */ -.gear { - background: none; border: 0; color: var(--ink-dim); cursor: pointer; - font-size: 14px; padding: 0 4px; vertical-align: middle; -} -.gear:hover { color: var(--accent); } - -.overlay { - position: fixed; inset: 0; background: rgba(0,0,0,.5); - display: flex; align-items: flex-start; justify-content: center; - padding: 8vh 16px; z-index: 10; -} -.overlay[hidden] { display: none; } -.modal { - width: 100%; max-width: 520px; background: var(--panel); - border: 1px solid var(--line); border-radius: 14px; padding: 20px; -} -.modal h2 { margin: 0 0 12px; font-size: 16px; } -.modal-actions { margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end; } -.modal-actions .btn { margin-top: 0; } -.path-row { display: flex; gap: 8px; align-items: center; } -.path-row input { flex: 1; } -.path-row .btn { margin-top: 0; white-space: nowrap; } -.set-error { color: var(--danger); font-size: 13px; margin-top: 8px; min-height: 1em; } - -.browse { - margin-top: 10px; border: 1px solid var(--line); border-radius: 8px; - background: var(--bg); max-height: 260px; overflow-y: auto; -} -.browse[hidden] { display: none; } -.browse-dir { - padding: 8px 10px; font-size: 12px; color: var(--ink-dim); - font-family: ui-monospace, monospace; border-bottom: 1px solid var(--line); - position: sticky; top: 0; background: var(--bg); -} -.browse-list { list-style: none; margin: 0; padding: 4px 0; } -.browse-list button { - width: 100%; text-align: left; background: none; border: 0; cursor: pointer; - color: var(--ink); font: inherit; font-size: 13px; padding: 5px 12px; - font-family: ui-monospace, monospace; -} -.browse-list button:hover { background: var(--line); color: var(--accent); } -``` - -- [ ] **Step 5: Build and manual-verify in browser** - -Static assets are `//go:embed`-ed, so a rebuild is required for changes to load. - -Run: `go build ./... && go vet ./...` -Expected: success, vet clean. - -Then run the daemon and hard-reload the browser (Ctrl-Shift-R): - -```bash -go run ./cmd/antidriftd -``` - -Manual checklist (verify each): -1. A gear (βš™) shows next to the "AntiDrift" header. -2. Clicking it opens the settings overlay with backend/marvin/knowledge prefilled from `~/.antidrift/settings.json`. -3. Clicking **Browse…** lists the knowledge path's directory; clicking folders navigates, `../` goes up, clicking a `.md` file fills the path field and closes the browser pane. -4. Changing the backend to `codex` and Save closes the overlay; `cat ~/.antidrift/settings.json` shows `"ai_backend":"codex"`. -5. The planning screen's knowledge "change" link opens the same settings overlay. - -Stop the daemon (Ctrl-C). - -- [ ] **Step 6: Commit** - -```bash -git add internal/web/static/ -git commit -m "Add settings gear, overlay, and file-browser modal to the UI - -Co-Authored-By: Claude Opus 4.8 " -``` - ---- - -## Final verification - -- [ ] Run `go build ./... && go test ./... && go vet ./...` β€” all green. -- [ ] Run `grep -rn "os.Getenv" cmd/ internal/` β€” expect no matches (all config now flows through the settings file). -- [ ] Confirm `~/.antidrift/settings.json` round-trips a UI edit (change marvin command in UI, reload page, value persists). - diff --git a/docs/superpowers/specs/2026-05-25-commitment-os-design.md b/docs/superpowers/specs/2026-05-25-commitment-os-design.md deleted file mode 100644 index 1d14404..0000000 --- a/docs/superpowers/specs/2026-05-25-commitment-os-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-05-31-go-focus-os-design.md b/docs/superpowers/specs/2026-05-31-go-focus-os-design.md deleted file mode 100644 index 381af27..0000000 --- a/docs/superpowers/specs/2026-05-31-go-focus-os-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md b/docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md deleted file mode 100644 index 84f8650..0000000 --- a/docs/superpowers/specs/2026-05-31-m0-walking-skeleton-design.md +++ /dev/null @@ -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`). diff --git a/docs/superpowers/specs/2026-05-31-m1-evidence-audit-design.md b/docs/superpowers/specs/2026-05-31-m1-evidence-audit-design.md deleted file mode 100644 index f86605d..0000000 --- a/docs/superpowers/specs/2026-05-31-m1-evidence-audit-design.md +++ /dev/null @@ -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/.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/.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/.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: " 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/.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`) diff --git a/docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md b/docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md deleted file mode 100644 index f2f2ceb..0000000 --- a/docs/superpowers/specs/2026-05-31-m2-ai-planning-coach-design.md +++ /dev/null @@ -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 - - ``` - 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 `) 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=`, `proposal=nil`. - - On success: `status=ready`, `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 ` 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). diff --git a/docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md b/docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md deleted file mode 100644 index 0bfc6e5..0000000 --- a/docs/superpowers/specs/2026-05-31-m3-drift-interceptor-design.md +++ /dev/null @@ -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 - -[ 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. diff --git a/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md b/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md deleted file mode 100644 index f4d3220..0000000 --- a/docs/superpowers/specs/2026-05-31-m3.5-semantic-nudge-design.md +++ /dev/null @@ -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": , "message": ""} -``` - -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 | diff --git a/docs/superpowers/specs/2026-05-31-m4-look-good-design.md b/docs/superpowers/specs/2026-05-31-m4-look-good-design.md deleted file mode 100644 index dc1d928..0000000 --- a/docs/superpowers/specs/2026-05-31-m4-look-good-design.md +++ /dev/null @@ -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 `
    ` 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 (`` 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. diff --git a/docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md b/docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md deleted file mode 100644 index 86554be..0000000 --- a/docs/superpowers/specs/2026-05-31-m5-tasks-port-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-06-01-faithful-reflection-design.md b/docs/superpowers/specs/2026-06-01-faithful-reflection-design.md deleted file mode 100644 index 70b360b..0000000 --- a/docs/superpowers/specs/2026-06-01-faithful-reflection-design.md +++ /dev/null @@ -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: -Success condition: -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). diff --git a/docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md b/docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md deleted file mode 100644 index 97861f6..0000000 --- a/docs/superpowers/specs/2026-06-01-m6-knowledge-port-design.md +++ /dev/null @@ -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 `; `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. diff --git a/docs/superpowers/specs/2026-06-01-m7-reflection-design.md b/docs/superpowers/specs/2026-06-01-m7-reflection-design.md deleted file mode 100644 index d74ab44..0000000 --- a/docs/superpowers/specs/2026-06-01-m7-reflection-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-06-01-m8-enforcement-window-minimize-design.md b/docs/superpowers/specs/2026-06-01-m8-enforcement-window-minimize-design.md deleted file mode 100644 index 8e7e22c..0000000 --- a/docs/superpowers/specs/2026-06-01-m8-enforcement-window-minimize-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-06-01-m9-tame-session-design.md b/docs/superpowers/specs/2026-06-01-m9-tame-session-design.md deleted file mode 100644 index fc8b1fc..0000000 --- a/docs/superpowers/specs/2026-06-01-m9-tame-session-design.md +++ /dev/null @@ -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. diff --git a/docs/superpowers/specs/2026-06-01-settings-page-design.md b/docs/superpowers/specs/2026-06-01-settings-page-design.md deleted file mode 100644 index ebf0474..0000000 --- a/docs/superpowers/specs/2026-06-01-settings-page-design.md +++ /dev/null @@ -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 `` 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=` β†’ `{ "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: `