From eebd2c159a78830885435d161cffab66ec5f8c01 Mon Sep 17 00:00:00 2001 From: lazy Date: Tue, 15 Sep 2026 16:44:33 -0400 Subject: [PATCH] Fix TUI sync and receipt-backed activation reporting --- Cargo.lock | 6 +- Cargo.toml | 2 +- README.md | 17 +- .../src/actions/adapters.rs | 15 +- .../src/actions/integrations.rs | 41 +- .../tree-ring-memory-cli/src/tui/actions.rs | 43 +- crates/tree-ring-memory-cli/src/tui/app.rs | 278 +++++++++++- crates/tree-ring-memory-cli/src/tui/render.rs | 399 ++++++++++++++++-- .../tests/activation_outcome_acceptance.rs | 172 ++++++++ docs/protocol/harness-activation.md | 7 + 10 files changed, 916 insertions(+), 64 deletions(-) create mode 100644 crates/tree-ring-memory-cli/tests/activation_outcome_acceptance.rs diff --git a/Cargo.lock b/Cargo.lock index f995e13..9403eb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1690,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tree-ring-memory-cli" -version = "0.15.9" +version = "0.15.10" dependencies = [ "chrono", "clap", @@ -1709,7 +1709,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-core" -version = "0.15.9" +version = "0.15.10" dependencies = [ "chrono", "libc", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-sqlite" -version = "0.15.9" +version = "0.15.10" dependencies = [ "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index b3d75b7..67ad2df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.15.9" +version = "0.15.10" edition = "2021" license = "MIT" authors = ["TerminallyLazy"] diff --git a/README.md b/README.md index f925ca7..3e41a38 100644 --- a/README.md +++ b/README.md @@ -375,7 +375,7 @@ Command ownership is Rust-native: normal sensitivity only, bounded cambium/scar/seed classifications, and required checkpoint identity, idempotency, and provenance. - `evidence` is the Revolve-inspired improvement-loop entry point for evaluated outcomes. -- `dox sync` and `revolve sync` are read-only source adapters that summarize and point back to authoritative files. +- `dox sync` and `revolve sync` read source files without changing them and save source-linked summaries to the memory store. Use `--dry-run` to preview without saving. - `integrations scan` discovers nearby agent-framework markers and suggests setup paths without changing their config. - `export`, `import`, `audit`, `consolidate`, and `maintain` are local maintenance surfaces over the same SQLite store. - `policy` manages optional coordinated multi-agent write authorization and its @@ -669,12 +669,27 @@ Store-watch polling updates persisted counts from SQLite, while the optional event stream lights rings in real time without treating stream events as durable truth. +The dashboard counts memories saved in the selected project's store. A successful +harness recall can return zero results from an empty store; its activation receipt +does not create a memory. Automatic capture adds only durable outcomes selected by +an agent working in that project. + +`/sync` previews DOX summaries from the project's `AGENTS.md` files, including +local instruction files that may not be tracked in Git. Review the source paths, +project, destination store and candidates before confirming. Confirmation saves +that preview and refreshes the dashboard; cancellation leaves memories unchanged. +Repeated sync updates the same source-linked records rather than duplicating them. +In Coordinated mode, saving requires the coordinator capability in the terminal +environment when the TUI starts; preview remains available without it. + Useful keys and commands: - `s` focuses search, `/` opens the slash command palette, `r` opens exploded ring view, `q` quits. - `i` toggles sensitive-memory visibility, `u` toggles superseded-memory visibility. +- In the `/sync` preview, `j`/`k` select candidates, Left/Right or Page Up/Down + scroll the preview, `y` saves, and `n` or Escape cancels. - Slash commands include `/rings`, `/search `, `/remember `, `/forget`, `/redact`, `/promote`, `/scar`, `/seed`, `/supersede `, `/consolidate`, `/export `, `/sync`, `/integrations`, `/stream`, and diff --git a/crates/tree-ring-memory-cli/src/actions/adapters.rs b/crates/tree-ring-memory-cli/src/actions/adapters.rs index 2b77e8f..e31309a 100644 --- a/crates/tree-ring-memory-cli/src/actions/adapters.rs +++ b/crates/tree-ring-memory-cli/src/actions/adapters.rs @@ -45,9 +45,7 @@ pub fn sync_dox( let store = store.ok_or_else(|| { "DOX sync action requires an open writable store when dry_run=false".to_string() })?; - store - .put_many(&report.events) - .map_err(|err| err.to_string())?; + apply_dox_preview(store, &report)?; } Ok(DoxSyncActionReport { report, @@ -55,6 +53,17 @@ pub fn sync_dox( }) } +/// Persist exactly the candidates returned by a reviewed DOX dry run. Store +/// validation, atomic batch writes, and coordinated policy still apply. +pub fn apply_dox_preview( + store: &mut SQLiteMemoryStore, + report: &DoxSyncReport, +) -> ActionResult<()> { + store + .put_many(&report.events) + .map_err(|err| err.to_string()) +} + pub fn sync_revolve( store: Option<&mut SQLiteMemoryStore>, request: RevolveSyncActionRequest, diff --git a/crates/tree-ring-memory-cli/src/actions/integrations.rs b/crates/tree-ring-memory-cli/src/actions/integrations.rs index decae41..8a71cbb 100644 --- a/crates/tree-ring-memory-cli/src/actions/integrations.rs +++ b/crates/tree-ring-memory-cli/src/actions/integrations.rs @@ -220,7 +220,7 @@ pub fn status(request: IntegrationStatusRequest) -> Result String { +fn next_step_for_state( + state: ActivationState, + harness_name: &str, + detected_next_step: &str, +) -> String { match state { ActivationState::Active => "No action required for the receipt-backed session.".to_string(), ActivationState::ActiveIsolated => { @@ -725,7 +756,7 @@ fn next_step_for_state(state: ActivationState, detected_next_step: &str) -> Stri .to_string() } ActivationState::ConfiguredAwaitingProof => { - "Run the adapter preflight at the start of a new harness session.".to_string() + format!("Open a new {harness_name} session in this project, then run `tree-ring integrations status --verbose` to verify lifecycle recall.") } _ => detected_next_step.to_string(), } diff --git a/crates/tree-ring-memory-cli/src/tui/actions.rs b/crates/tree-ring-memory-cli/src/tui/actions.rs index f794fe7..3a95675 100644 --- a/crates/tree-ring-memory-cli/src/tui/actions.rs +++ b/crates/tree-ring-memory-cli/src/tui/actions.rs @@ -1,8 +1,8 @@ -use std::path::PathBuf; +use std::{cell::Cell, path::PathBuf}; -use tree_ring_memory_core::ConsolidationRequest; +use tree_ring_memory_core::{ConsolidationRequest, DoxSyncReport}; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum ActionKind { Delete, Redact, @@ -22,13 +22,17 @@ pub enum ActionKind { include_sensitive: bool, include_superseded: bool, }, - Sync, + SyncDox { + preview: Box, + selected_candidate: usize, + preview_scroll: Cell, + }, RefreshCertification { command: String, }, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct PendingAction { pub kind: ActionKind, pub memory_id: Option, @@ -111,11 +115,31 @@ impl PendingAction { } } - pub fn sync_placeholder() -> Self { + pub fn sync_dox(preview: DoxSyncReport, project: &str, store_path: &std::path::Path) -> Self { + let sensitive_count = preview + .events + .iter() + .filter(|event| event.sensitivity != "normal") + .count(); + let summary = format!( + "Import or update {} DOX summaries from {} AGENTS.md files.\nSource: {}\nStore: {}\nProject: {}\nIncludes {} sensitive; skips {} secret sections; {} warnings.\nStable IDs update existing summaries; source files stay authoritative.", + preview.memory_count, + preview.source_count, + preview.root.display(), + store_path.display(), + project, + sensitive_count, + preview.skipped_secret_count, + preview.warnings.len(), + ); Self { - kind: ActionKind::Sync, + kind: ActionKind::SyncDox { + preview: Box::new(preview), + selected_candidate: 0, + preview_scroll: Cell::new(0), + }, memory_id: None, - summary: "Run integration sync".to_string(), + summary, } } @@ -129,8 +153,9 @@ impl PendingAction { } } + #[cfg(test)] pub fn confirmation_prompt(&self) -> String { - format!("{} - press y to confirm, n/Esc to cancel", self.summary) + format!("{}\npress y to confirm, n/Esc to cancel", self.summary) } } diff --git a/crates/tree-ring-memory-cli/src/tui/app.rs b/crates/tree-ring-memory-cli/src/tui/app.rs index a68bf16..5224a2a 100644 --- a/crates/tree-ring-memory-cli/src/tui/app.rs +++ b/crates/tree-ring-memory-cli/src/tui/app.rs @@ -4,6 +4,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use tree_ring_memory_core::{ConsolidationRequest, MemoryEvent}; use tree_ring_memory_sqlite::{MemoryRetriever, RecallResult, SQLiteMemoryStore, WriteContext}; +use crate::actions::adapters::{apply_dox_preview, sync_dox, DoxSyncActionRequest}; use crate::actions::export_import::{export_jsonl, ExportActionRequest}; use crate::actions::integrations::{scan as scan_integrations_action, IntegrationScanRequest}; use crate::actions::remember::{remember, RememberRequest}; @@ -162,6 +163,65 @@ impl App { } fn handle_pending_key(&mut self, key: KeyEvent) -> Result<(), String> { + if let Some(PendingAction { + kind: + ActionKind::SyncDox { + preview, + selected_candidate, + preview_scroll, + }, + .. + }) = self.pending_action.as_mut() + { + let last = preview.events.len().saturating_sub(1); + match key.code { + KeyCode::Char('i') => { + self.include_sensitive = !self.include_sensitive; + preview_scroll.set(0); + // Cancellation can redraw the cached browse/search view + // before the next tick; apply privacy filtering now. + self.refresh_store()?; + return Ok(()); + } + KeyCode::Down | KeyCode::Char('j') => { + *selected_candidate = selected_candidate.saturating_add(1).min(last); + preview_scroll.set(0); + return Ok(()); + } + KeyCode::Up | KeyCode::Char('k') => { + *selected_candidate = selected_candidate.saturating_sub(1); + preview_scroll.set(0); + return Ok(()); + } + KeyCode::PageDown => { + preview_scroll.set(preview_scroll.get().saturating_add(8)); + return Ok(()); + } + KeyCode::PageUp => { + preview_scroll.set(preview_scroll.get().saturating_sub(8)); + return Ok(()); + } + KeyCode::Right => { + preview_scroll.set(preview_scroll.get().saturating_add(1)); + return Ok(()); + } + KeyCode::Left => { + preview_scroll.set(preview_scroll.get().saturating_sub(1)); + return Ok(()); + } + KeyCode::Home => { + *selected_candidate = 0; + preview_scroll.set(0); + return Ok(()); + } + KeyCode::End => { + *selected_candidate = last; + preview_scroll.set(0); + return Ok(()); + } + _ => {} + } + } match key.code { KeyCode::Char('y') | KeyCode::Char('Y') => { if let Err(error) = self.confirm_pending_action() { @@ -283,7 +343,7 @@ impl App { self.pending_action = Some(PendingAction::consolidate(request)); } SlashCommand::Export(target) => self.pending_export(target), - SlashCommand::Sync => self.pending_action = Some(PendingAction::sync_placeholder()), + SlashCommand::Sync => self.preview_dox_sync()?, SlashCommand::Integrations => self.show_integrations(), SlashCommand::Evidence(argument) => { if argument.eq_ignore_ascii_case("refresh") { @@ -435,8 +495,22 @@ impl App { ); } } - ActionKind::Sync => { - self.status = "sync adapters are available through CLI commands".to_string(); + ActionKind::SyncDox { preview, .. } => { + apply_dox_preview(&mut self.store, &preview).map_err(|error| { + if error.contains("coordinator capability required") { + format!("{error}. Relaunch the TUI with TREE_RING_COORDINATOR_TOKEN supplied by your existing secure environment.") + } else { + error + } + })?; + self.status = format!( + "DOX sync: {} summaries from {} files saved", + preview.memory_count, preview.source_count + ); + self.search_query.clear(); + self.results.clear(); + self.selected_result = 0; + self.mode = AppMode::Default; } ActionKind::RefreshCertification { command } => { self.status = format!("run externally: {command}"); @@ -478,6 +552,54 @@ impl App { self.mode = AppMode::Integrations; } + fn preview_dox_sync(&mut self) -> Result<(), String> { + self.pending_action = None; + let source_root = std::path::absolute(project_root_for_memory_root(&self.root)) + .map_err(|error| error.to_string())?; + let identity_root = + std::fs::canonicalize(&source_root).map_err(|error| error.to_string())?; + let project = identity_root + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| "DOX sync project identity is unavailable".to_string())? + .to_string(); + let preview = sync_dox( + None, + DoxSyncActionRequest { + source_root, + project: Some(project.clone()), + dry_run: true, + }, + )? + .report; + if preview.memory_count == 0 { + self.status = format!( + "DOX sync: no eligible summaries in {} ({} files, {} secret sections skipped, {} warnings)", + preview.root.display(), + preview.source_count, + preview.skipped_secret_count, + preview.warnings.len(), + ); + } else { + self.status = format!( + "DOX preview: {} summaries; nothing saved yet", + preview.memory_count + ); + self.pending_action = Some(PendingAction::sync_dox( + preview, + &project, + &self.store_path(), + )); + } + Ok(()) + } + + pub fn store_path(&self) -> PathBuf { + let path = self.root.join("memory.sqlite"); + std::path::absolute(&path).unwrap_or(path) + } + fn show_evidence(&mut self) { let project_root = project_root_for_memory_root(&self.root); let evidence_dir = certification_dir_for_project(&project_root); @@ -594,7 +716,10 @@ fn resolve_export_path(root: &Path, target: &str) -> Result { fn project_root_for_memory_root(root: &Path) -> PathBuf { if root.file_name().and_then(|name| name.to_str()) == Some(".tree-ring") { - root.parent().unwrap_or(root).to_path_buf() + root.parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")) + .to_path_buf() } else { root.to_path_buf() } @@ -621,6 +746,151 @@ mod tests { .unwrap(); } + #[test] + fn dox_sync_previews_without_writes_and_cancel_discards_candidates() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Project rules\n\nRead source contracts before editing.\n", + ) + .unwrap(); + let mut app = app(&dir); + + app.execute_slash_command("/sync").unwrap(); + + let pending = app.pending_action.as_ref().unwrap(); + assert!(pending + .summary + .contains("1 DOX summaries from 1 AGENTS.md files")); + assert!(pending.summary.contains("Project:")); + assert!(pending + .summary + .contains(&app.store_path().display().to_string())); + assert_eq!(app.dashboard.total, 0); + assert!(app.store.list_all(true).unwrap().is_empty()); + app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + assert!(app.pending_action.is_none()); + assert!(app.store.list_all(true).unwrap().is_empty()); + } + + #[test] + fn dox_sync_applies_the_reviewed_snapshot_and_repeated_sync_does_not_duplicate() { + let dir = tempdir().unwrap(); + let source = dir.path().join("AGENTS.md"); + fs::write( + &source, + "# Project rules\n\nRead source contracts before editing.\n", + ) + .unwrap(); + let mut app = app(&dir); + app.execute_slash_command("/search nonexistent").unwrap(); + app.execute_slash_command("/sync").unwrap(); + let reviewed = match &app.pending_action.as_ref().unwrap().kind { + ActionKind::SyncDox { preview, .. } => preview.events.clone(), + other => panic!("unexpected action {other:?}"), + }; + fs::write( + &source, + "# Project rules\n\nChanged instructions must not enter an earlier preview.\n", + ) + .unwrap(); + + confirm(&mut app); + + let memories = app.store.list_all(true).unwrap(); + assert_eq!(memories.len(), 1); + assert_eq!(memories[0].summary, reviewed[0].summary); + assert_eq!(memories[0].project, reviewed[0].project); + assert_eq!(app.dashboard.total, 1); + assert_eq!(app.memories.len(), 1); + assert!(app.search_query.is_empty()); + assert!(app.status.contains("1 summaries from 1 files saved")); + + for _ in 0..2 { + app.execute_slash_command("/sync").unwrap(); + confirm(&mut app); + assert_eq!(app.dashboard.total, 1); + assert_eq!(app.store.list_all(true).unwrap()[0].id, reviewed[0].id); + } + assert!(app.store.list_all(true).unwrap()[0] + .summary + .contains("Changed instructions")); + } + + #[test] + fn dox_sync_with_no_sources_reports_no_candidates_without_confirmation() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + app.execute_slash_command("/sync").unwrap(); + assert!(app.pending_action.is_none()); + assert!(app.status.contains("no eligible summaries")); + assert_eq!(app.dashboard.total, 0); + } + + #[test] + fn coordinated_dox_sync_denial_is_visible_and_atomic() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("AGENTS.md"), + "# Project rules\n\nRead source contracts before editing.\n", + ) + .unwrap(); + let root = dir.path().join(".tree-ring"); + let mut setup = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap(); + setup + .enable_coordinated_policy(Some("test-coordinator")) + .unwrap(); + drop(setup); + let context = WriteContext::new(Some("worker".to_string()), None, "tui-test").unwrap(); + let mut app = + App::new_with_context(root, None, context, Some("worker".to_string())).unwrap(); + app.execute_slash_command("/sync").unwrap(); + assert!(app.pending_action.is_some()); + assert!(app.store.list_all(true).unwrap().is_empty()); + + confirm(&mut app); + + assert!(app.status.contains("action failed: authorization denied")); + assert!(app.status.contains("TREE_RING_COORDINATOR_TOKEN")); + assert!(app.pending_action.is_none()); + assert!(!app.should_quit); + assert!(app.store.list_all(true).unwrap().is_empty()); + assert_eq!(app.dashboard.total, 0); + } + + #[test] + fn dox_preview_failure_is_visible_without_exiting_or_writing() { + let dir = tempdir().unwrap(); + let mut app = app(&dir); + app.root = dir.path().join("missing-project/.tree-ring"); + app.mode = AppMode::Command; + app.command_buffer = "/sync".to_string(); + app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + assert!(app.status.starts_with("command failed:")); + assert!(app.pending_action.is_none()); + assert!(!app.should_quit); + assert_eq!(app.mode, AppMode::Default); + assert!(app.store.list_all(true).unwrap().is_empty()); + } + + #[test] + fn relative_memory_root_resolves_to_current_project_for_source_sync() { + assert_eq!( + project_root_for_memory_root(Path::new(".tree-ring")), + Path::new(".") + ); + assert_eq!( + project_root_for_memory_root(Path::new("./.tree-ring")), + Path::new(".") + ); + assert_eq!( + project_root_for_memory_root(Path::new("/project/.tree-ring")), + Path::new("/project") + ); + } + #[test] fn slash_remember_stores_and_pulses_cambium() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-cli/src/tui/render.rs b/crates/tree-ring-memory-cli/src/tui/render.rs index e807ef5..370e0c9 100644 --- a/crates/tree-ring-memory-cli/src/tui/render.rs +++ b/crates/tree-ring-memory-cli/src/tui/render.rs @@ -4,6 +4,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, List, ListItem, Paragraph, Wrap}; use ratatui::Frame; +use super::actions::{ActionKind, PendingAction}; use super::app::{App, AppMode}; use super::input::command_help; use super::rings::{ambient_corner_lines, ambient_tree_lines, exploded_ring_lines, ring_style}; @@ -36,8 +37,9 @@ pub fn render(frame: &mut Frame<'_>, app: &App) { if let Some(pending) = &app.pending_action { render_confirmation( frame, - centered_rect(70, 22, area), - &pending.confirmation_prompt(), + confirmation_rect(area), + pending, + app.include_sensitive, ); } } @@ -81,8 +83,9 @@ fn render_narrow(frame: &mut Frame<'_>, area: Rect, app: &App) { if let Some(pending) = &app.pending_action { render_confirmation( frame, - centered_rect(78, 30, area), - &pending.confirmation_prompt(), + confirmation_rect(area), + pending, + app.include_sensitive, ); } } @@ -230,12 +233,23 @@ fn render_results(frame: &mut Frame<'_>, area: Rect, app: &App) { render_integrations(frame, area, app); return; } - let title = if app.search_query.is_empty() { + let title = if app.search_query.trim().is_empty() { "Memories".to_string() } else { format!("Results: {}", app.search_query) }; - let items: Vec> = if app.search_query.is_empty() { + let items: Vec> = if app.search_query.trim().is_empty() && app.memories.is_empty() + { + let message = if app.dashboard.total == 0 { + "No stored memories yet. /sync previews DOX; /remember saves a lesson." + } else { + "Memories are hidden by visibility filters. i: sensitive, u: superseded." + }; + vec![ListItem::new(Line::from(Span::styled( + message, + theme::dim(), + )))] + } else if app.search_query.trim().is_empty() { app.memories .iter() .enumerate() @@ -522,8 +536,24 @@ fn render_detail(frame: &mut Frame<'_>, area: Rect, app: &App) { lines.push(Line::from(details)); } } else { - lines.push(Line::from("No matching memory yet.")); - lines.push(Line::from("Use /remember or /search .")); + if app.dashboard.total == 0 { + lines.push(Line::from("No stored memories yet.")); + lines.push(Line::from("A recall receipt does not create memory.")); + lines.push(Line::from( + "Use /sync to review DOX summaries or /remember .", + )); + } else { + lines.push(Line::from("No visible matching memory.")); + lines.push(Line::from("Clear search or review i/u visibility filters.")); + } + lines.push(Line::from(format!("Store: {}", app.store_path().display()))); + } + + if app.status.starts_with("action failed:") + || app.status.starts_with("command failed:") + || app.status.starts_with("DOX sync:") + { + lines.insert(0, Line::from(app.status.clone())); } if app.mode == AppMode::Command { @@ -738,51 +768,128 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { frame.render_widget(footer, area); } -fn render_confirmation(frame: &mut Frame<'_>, area: Rect, prompt: &str) { +fn render_confirmation( + frame: &mut Frame<'_>, + area: Rect, + pending: &PendingAction, + include_sensitive: bool, +) { frame.render_widget(Clear, area); - let paragraph = Paragraph::new(vec![ - Line::from(Span::styled( - "Confirm Tree Ring Memory action", - theme::warning(), - )), - Line::from(""), - Line::from(prompt.to_string()), - ]) - .block(theme::plain_panel().border_style(theme::warning())) - .wrap(Wrap { trim: true }); - frame.render_widget(paragraph, area); + let block = theme::panel("Confirm Tree Ring Memory action").border_style(theme::warning()); + let inner = block.inner(area); + frame.render_widget(block, area); + if let ActionKind::SyncDox { + preview, + selected_candidate, + preview_scroll, + } = &pending.kind + { + let regions = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).split(inner); + if let Some(event) = preview.events.get(*selected_candidate) { + let detail = if event.sensitivity == "normal" || include_sensitive { + format!( + "Candidate {}/{} [{}] source: {}\n{}", + selected_candidate + 1, + preview.events.len(), + event.ring, + event.source.ref_, + event.summary, + ) + } else { + format!("Candidate {}/{} [{}]\nSensitive candidate hidden. Press i to review. Confirm all includes this candidate.", selected_candidate + 1, preview.events.len(), event.ring) + }; + let lines = wrap_preview_text( + &format!("{}\n\n{detail}", pending.summary), + regions[0].width, + ); + let max_scroll = lines + .len() + .saturating_sub(usize::from(regions[0].height)) + .min(usize::from(u16::MAX)) as u16; + let offset = preview_scroll.get().min(max_scroll); + // This is viewport state only. Clamp after resize/content changes + // so a single reverse-scroll key always moves the visible text. + preview_scroll.set(offset); + frame.render_widget(Paragraph::new(lines).scroll((offset, 0)), regions[0]); + } + frame.render_widget( + Paragraph::new( + "j/k item; Left/Right/Pg scroll; i sensitive\ny confirm all; n/Esc cancel; Home/End item", + ) + .style(theme::warning()), + regions[1], + ); + } else { + let regions = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).split(inner); + frame.render_widget( + Paragraph::new(pending.summary.clone()).wrap(Wrap { trim: true }), + regions[0], + ); + frame.render_widget( + Paragraph::new("press y to confirm, n/Esc to cancel") + .wrap(Wrap { trim: true }) + .style(theme::warning()), + regions[1], + ); + } } fn render_compact(frame: &mut Frame<'_>, area: Rect, app: &App) { let mut lines = vec![Line::from(Span::styled("TREE RING MEMORY", theme::brand()))]; - lines.extend(ambient_tree_lines(&app.dashboard, app.tick)); lines.push(Line::from(format!( "total {} | q quit | / command", app.dashboard.total ))); + if app.mode == AppMode::Command { + lines.push(Line::from(format!("/{}", app.command_buffer))); + } + lines.push(Line::from(app.status.clone())); + if app.mode != AppMode::Command && app.dashboard.total == 0 { + lines.push(Line::from( + "No stored memories. /sync previews DOX; /remember saves a lesson.", + )); + } + lines.extend(ambient_tree_lines(&app.dashboard, app.tick)); let paragraph = Paragraph::new(lines) .block(theme::plain_panel()) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); + if let Some(pending) = &app.pending_action { + render_confirmation(frame, area, pending, app.include_sensitive); + } } -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] +// Wrap once into explicit display rows so preview scrolling has an exact +// bound, including long source paths without whitespace. +fn wrap_preview_text(text: &str, width: u16) -> Vec> { + let width = usize::from(width.max(1)); + let mut output = Vec::new(); + for line in text.lines() { + let mut row = String::new(); + let mut columns = 0; + for character in line.chars() { + let character_width = Span::raw(character.to_string()).width(); + if columns + character_width > width && !row.is_empty() { + output.push(Line::from(std::mem::take(&mut row))); + columns = 0; + } + row.push(character); + columns += character_width; + } + output.push(Line::from(row)); + } + output +} + +fn confirmation_rect(area: Rect) -> Rect { + let width = area.width.saturating_sub(4).min(110); + let height = area.height.saturating_sub(2); + Rect::new( + area.x + (area.width - width) / 2, + area.y + (area.height - height) / 2, + width, + height, + ) } fn truncate(value: &str, max: usize) -> String { @@ -805,6 +912,222 @@ mod tests { use super::*; use crate::tui::app::App; + #[test] + fn long_dox_candidate_can_scroll_to_its_end_with_fixed_confirmation_keys() { + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + "# Rules\n\nReview source contracts.\n", + ) + .unwrap(); + let mut app = App::new(dir.path().join(".tree-ring"), None).unwrap(); + app.execute_slash_command("/sync").unwrap(); + if let ActionKind::SyncDox { preview, .. } = &mut app.pending_action.as_mut().unwrap().kind + { + preview.events[0].source.ref_ = + format!("{}AGENTS.md#rules", "long-source-directory/".repeat(8)); + preview.events[0].summary = format!( + "{}\nFINAL-REVIEW-SENTINEL", + "Review bounded source guidance. ".repeat(40) + ); + } + let mut terminal = Terminal::new(TestBackend::new(60, 18)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + assert!(!terminal + .backend() + .to_string() + .contains("FINAL-REVIEW-SENTINEL")); + let mut reached_end = false; + for _ in 0..10 { + app.handle_key(ratatui::crossterm::event::KeyEvent::new( + ratatui::crossterm::event::KeyCode::PageDown, + ratatui::crossterm::event::KeyModifiers::NONE, + )) + .unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("y confirm all; n/Esc cancel")); + if output.contains("FINAL-REVIEW-SENTINEL") { + reached_end = true; + break; + } + } + assert!( + reached_end, + "full candidate must be reviewable before confirmation" + ); + assert!(app.store.list_all(true).unwrap().is_empty()); + } + + #[test] + fn compact_layout_prioritizes_command_and_actionable_errors_over_art() { + let dir = tempdir().unwrap(); + let mut app = App::new(dir.path().join(".tree-ring"), None).unwrap(); + app.status = "action failed: authorization denied: coordinator capability required by coordinated store policy. Relaunch the TUI with TREE_RING_COORDINATOR_TOKEN supplied by your existing secure environment.".to_string(); + app.mode = AppMode::Command; + app.command_buffer = "sync".to_string(); + let mut terminal = Terminal::new(TestBackend::new(60, 14)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("/sync")); + assert!(output.contains("Relaunch"), "{output}"); + assert!(output.contains("TREE_RING_COORDINATOR_TOKEN")); + assert!(output.contains("secure"), "{output}"); + assert!(output.contains("environment"), "{output}"); + } + + #[test] + fn dox_preview_shows_sources_candidates_and_confirmation_at_all_layout_sizes() { + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + "# Project rules\n\nRead source contracts before editing.\n", + ) + .unwrap(); + std::fs::create_dir(dir.path().join(".spynel")).unwrap(); + std::fs::write( + dir.path().join(".spynel/AGENTS.md"), + "# Local rules\n\nVerify local configuration.\n", + ) + .unwrap(); + let mut app = App::new(dir.path().join(".tree-ring"), None).unwrap(); + app.execute_slash_command("/sync").unwrap(); + for (width, height) in [(120, 36), (80, 24), (64, 20)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!( + output.contains("2 DOX summaries"), + "{width}x{height}: {output}" + ); + assert!(output.contains("Source:")); + assert!(output.contains("Store:")); + assert!(output.contains("Project:")); + assert!(output.contains("Candidate 1/2")); + assert!(output.contains(".spynel/AGENTS.md")); + assert!(output.contains("Verify"), "{width}x{height}: {output}"); + assert!( + output.contains("DOX verification"), + "{width}x{height}: {output}" + ); + assert!(output.contains("y confirm all; n/Esc cancel")); + } + app.handle_key(ratatui::crossterm::event::KeyEvent::new( + ratatui::crossterm::event::KeyCode::End, + ratatui::crossterm::event::KeyModifiers::NONE, + )) + .unwrap(); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("Candidate 2/2")); + assert!(output.contains("Read source contracts")); + assert!(app.store.list_all(true).unwrap().is_empty()); + } + + #[test] + fn dox_preview_hides_sensitive_candidate_and_source_until_explicit_opt_in() { + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + "# Rules\n\nReview source contracts.\n", + ) + .unwrap(); + let mut app = App::new(dir.path().join(".tree-ring"), None).unwrap(); + app.execute_slash_command("/sync").unwrap(); + if let ActionKind::SyncDox { preview, .. } = &mut app.pending_action.as_mut().unwrap().kind + { + preview.events[0].sensitivity = "sensitive".to_string(); + preview.events[0].summary = "SENSITIVE-CANDIDATE-SENTINEL".to_string(); + preview.events[0].source.ref_ = "SENSITIVE-SOURCE-SENTINEL".to_string(); + } + for (width, height) in [(120, 36), (80, 24), (64, 20)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("Sensitive candidate hidden")); + assert!(!output.contains("SENSITIVE-CANDIDATE-SENTINEL")); + assert!(!output.contains("SENSITIVE-SOURCE-SENTINEL")); + } + app.handle_key(ratatui::crossterm::event::KeyEvent::new( + ratatui::crossterm::event::KeyCode::Char('i'), + ratatui::crossterm::event::KeyModifiers::NONE, + )) + .unwrap(); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("SENSITIVE-CANDIDATE-SENTINEL")); + assert!(output.contains("SENSITIVE-SOURCE-SENTINEL")); + assert!(app.store.list_all(true).unwrap().is_empty()); + } + + #[test] + fn hiding_sensitive_preview_clears_cached_memories_before_cancel_redraw() { + let dir = tempdir().unwrap(); + std::fs::write( + dir.path().join("AGENTS.md"), + "# Rules\n\nReview source contracts.\n", + ) + .unwrap(); + let mut app = App::new(dir.path().join(".tree-ring"), None).unwrap(); + let mut private = + tree_ring_memory_core::MemoryEvent::new("PRIVATE-CACHE-SENTINEL", "lesson").unwrap(); + private.sensitivity = "private".to_string(); + private.source.ref_ = "PRIVATE-SOURCE-CACHE-SENTINEL".to_string(); + app.store.put(&private).unwrap(); + app.include_sensitive = true; + app.refresh_store().unwrap(); + assert_eq!(app.memories.len(), 1); + app.execute_slash_command("/sync").unwrap(); + + for code in [ + ratatui::crossterm::event::KeyCode::Char('i'), + ratatui::crossterm::event::KeyCode::Esc, + ] { + app.handle_key(ratatui::crossterm::event::KeyEvent::new( + code, + ratatui::crossterm::event::KeyModifiers::NONE, + )) + .unwrap(); + } + + assert!(!app.include_sensitive); + assert!(app.pending_action.is_none()); + // Render before any event-loop tick can refresh the cached view. + let mut terminal = Terminal::new(TestBackend::new(120, 36)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(!output.contains("PRIVATE-CACHE-SENTINEL")); + assert!(!output.contains("PRIVATE-SOURCE-CACHE-SENTINEL")); + assert!(app.memories.is_empty()); + assert_eq!(app.store.list_all(true).unwrap().len(), 1); + } + + #[test] + fn empty_store_explains_population_and_preserves_sensitive_filter_distinction() { + let dir = tempdir().unwrap(); + let mut app = App::new(dir.path().join(".tree-ring"), None).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(120, 36)).unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("No stored memories yet")); + assert!(output.contains("recall receipt does not create memory")); + assert!(output.contains("/sync")); + assert!(output.contains("Store:")); + + let mut private = + tree_ring_memory_core::MemoryEvent::new("PRIVATE-CONTENT-SENTINEL", "lesson").unwrap(); + private.sensitivity = "private".to_string(); + app.store.put(&private).unwrap(); + app.refresh_store().unwrap(); + terminal.draw(|frame| render(frame, &app)).unwrap(); + let output = terminal.backend().to_string(); + assert!(output.contains("visibility filters")); + assert!(!output.contains("PRIVATE-CONTENT-SENTINEL")); + assert!(!output.contains("No stored memories yet")); + } + #[test] fn render_buffer_contains_ambient_rings_and_actions() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-cli/tests/activation_outcome_acceptance.rs b/crates/tree-ring-memory-cli/tests/activation_outcome_acceptance.rs new file mode 100644 index 0000000..7caecbe --- /dev/null +++ b/crates/tree-ring-memory-cli/tests/activation_outcome_acceptance.rs @@ -0,0 +1,172 @@ +#![cfg(unix)] + +use serde_json::{json, Value}; +use std::{fs, path::PathBuf, process::Command}; +use tempfile::{tempdir, TempDir}; + +struct Project { + temp: TempDir, + root: PathBuf, +} + +impl Project { + fn new() -> Self { + let temp = tempdir().unwrap(); + let root = temp.path().join("Activation Outcome Project"); + fs::create_dir_all(root.join(".codex")).unwrap(); + fs::create_dir_all(root.join(".pi")).unwrap(); + let project = Self { temp, root }; + project.run(&["init"]); + project + } + + fn run(&self, args: &[&str]) -> Value { + let output = Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .current_dir(&self.root) + .env("PATH", "/usr/bin:/bin") + .env("HOME", self.temp.path().join("fixture-home")) + .env_remove("TREE_RING_AGENT_PROFILE") + .env_remove("TREE_RING_WORKFLOW_ID") + .env_remove("TREE_RING_SESSION_ID") + .env_remove("TREE_RING_COORDINATOR_TOKEN") + .env_remove("TREE_RING_AGENT_ZERO_PLUGIN_MANIFEST") + .arg("--json") + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() + } + + fn preflight(&self) { + let result = self.run(&[ + "integrations", + "preflight", + "--harness", + "codex", + "--agent-profile", + "outcome-worker", + "--workflow-id", + "outcome-flow", + "--session-id", + "outcome-session", + ]); + assert_eq!(result["state"], "active"); + } + + fn receipt(&self) -> PathBuf { + let harness = self.root.join(".tree-ring/activation/receipts/codex"); + let worker = fs::read_dir(harness) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + fs::read_dir(worker) + .unwrap() + .next() + .unwrap() + .unwrap() + .path() + } +} + +#[test] +fn applied_activation_points_to_a_new_session_while_dry_run_remains_a_plan() { + let project = Project::new(); + let manifest = fs::read(project.root.join(".tree-ring/activation.json")).unwrap(); + for command in ["activate", "link"] { + let report = project.run(&["integrations", command, "--harness", "codex"]); + assert_eq!(report["state"], "configured-awaiting-proof"); + assert_eq!(report["changed_paths"], json!([])); + let step = report["next_step"].as_str().unwrap(); + assert!(step.contains("new Codex session"), "{step}"); + assert!(step.contains("integrations status --verbose"), "{step}"); + assert!(!step.contains("Apply the reviewed bridge plan"), "{step}"); + } + let preview = project.run(&[ + "integrations", + "activate", + "--harness", + "codex", + "--dry-run", + ]); + assert_eq!(preview["dry_run"], true); + assert!(preview["next_step"] + .as_str() + .unwrap() + .contains("Apply the reviewed bridge plan")); + assert_eq!( + fs::read(project.root.join(".tree-ring/activation.json")).unwrap(), + manifest + ); + assert!(!project.root.join(".tree-ring/activation/receipts").exists()); +} + +#[test] +fn unchanged_activation_retains_real_preflight_proof_without_writing_a_receipt() { + let project = Project::new(); + project.preflight(); + let receipt = project.receipt(); + let receipt_bytes = fs::read(&receipt).unwrap(); + let manifest = fs::read(project.root.join(".tree-ring/activation.json")).unwrap(); + for command in ["activate", "link"] { + let report = project.run(&["integrations", command, "--harness", "codex"]); + assert_eq!(report["state"], "active"); + assert_eq!(report["changed_paths"], json!([])); + assert!(report["next_step"] + .as_str() + .unwrap() + .contains("No action required")); + assert_eq!(fs::read(&receipt).unwrap(), receipt_bytes); + assert_eq!( + fs::read(project.root.join(".tree-ring/activation.json")).unwrap(), + manifest + ); + } +} + +#[test] +fn mismatched_receipt_does_not_turn_successful_configuration_into_active() { + let project = Project::new(); + project.preflight(); + let receipt = project.receipt(); + let mut content: Value = serde_json::from_slice(&fs::read(&receipt).unwrap()).unwrap(); + content["store_id"] = json!("different-store"); + let mismatched = serde_json::to_vec(&content).unwrap(); + fs::write(&receipt, &mismatched).unwrap(); + let report = project.run(&["integrations", "activate", "--harness", "codex"]); + assert_eq!(report["state"], "configured-awaiting-proof"); + assert!(report["next_step"] + .as_str() + .unwrap() + .contains("new Codex session")); + assert_eq!(fs::read(receipt).unwrap(), mismatched); +} + +#[test] +fn prior_proof_cannot_hide_bridge_review_trust_or_create_only_deactivation() { + let project = Project::new(); + project.preflight(); + let deactivation = project.run(&["integrations", "deactivate", "--harness", "codex"]); + assert_eq!(deactivation["state"], "needs-user-review"); + let trust = project.run(&["integrations", "activate", "--harness", "pi"]); + assert_eq!(trust["state"], "needs-trust"); + + let hooks = project.root.join(".codex/hooks.json"); + let original = fs::read_to_string(&hooks).unwrap(); + let changed = format!("{original}\n"); + fs::write(&hooks, &changed).unwrap(); + let report = project.run(&["integrations", "activate", "--harness", "codex"]); + assert_eq!(report["state"], "needs-user-review"); + assert_eq!(report["changed_paths"], json!([])); + assert!(!report["next_step"] + .as_str() + .unwrap() + .contains("No action required")); + assert_eq!(fs::read_to_string(hooks).unwrap(), changed); +} diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 4a2f9f8..90db86c 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -63,6 +63,13 @@ A zero-result recall can be a valid receipt: it proves the check occurred without inventing context. Hermes and any runtime without a maintained verified adapter remain non-active. +After successful `integrations activate` or `link`, the result reports any +already-valid matching receipt using the same verification as status. Without +that proof, it directs the user to open a new harness session and check +`integrations status --verbose`; it does not ask them to apply the completed +plan again. Activation does not create a receipt, and existing proof cannot +override a publication review, trust requirement, or other blocked outcome. + ## Artifacts and privacy `.tree-ring/activation.json` is the versioned manifest. It contains the schema