From acb37e990e0fe96a98d5b2de44a966d84765f1a6 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 25 May 2026 12:16:47 -0400 Subject: [PATCH] Add commitment kernel implementation plan --- .../2026-05-25-commitment-kernel-stage1.md | 1933 +++++++++++++++++ 1 file changed, 1933 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-25-commitment-kernel-stage1.md diff --git a/docs/superpowers/plans/2026-05-25-commitment-kernel-stage1.md b/docs/superpowers/plans/2026-05-25-commitment-kernel-stage1.md new file mode 100644 index 0000000..c61c1d7 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-commitment-kernel-stage1.md @@ -0,0 +1,1933 @@ +# 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`.