From a2f4e9acab2244269ce597088bf8a06011279d14 Mon Sep 17 00:00:00 2001 From: Caleb Leak Date: Wed, 29 Apr 2026 10:40:50 -0700 Subject: [PATCH 1/2] feat: auto-emit journal entries on interview lifecycle (slice 4b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of Phase 4: hook the CLI `interview start/answer/commit` commands and the MCP `interview_start`/`_answer`/`_adjust`/`_commit` tools into the journal so each lifecycle moment of an AI-assisted design interview gets captured automatically. Per `docs/journal-spec.md` §9 Phase 4: | Event | Kind | Notes | |-------------------|----------|------------------------------------| | Started | plan | provisional | | AnswerRecorded | finding | provisional | | PhaseAdvanced | finding | provisional; emitted when reanaly- | | | | sis bumps the phase | | Adjusted | finding | provisional; covers adjust/add/ | | | | edge tentative-graph mutations | | Committed | outcome | passed = true, final = true | Rollback is in the spec but the interview engine has no rollback operation today — when/if that lands, this module gains a sixth variant. Until then, "all provisional until session commit" is honored end-to-end: every event during the interview is provisional, the commit emits one non-provisional `outcome` that finalizes the journal session and triggers publish. Code structure: split the auto_emit module into a directory so task and interview event mappings live alongside without crowding one file. New layout: auto_emit/ mod.rs - re-exports summary.rs - shared clamp_summary helper (200-char floor, UTF-8-safe) task.rs - Phase 4a transitions (no behavior change) interview.rs - Phase 4b events (new) CLI plumbing: `interview start/answer/commit` each grow an `--agent` flag mirroring `tempyr status` (default "claude"). MCP plumbing: each handler builds an `InterviewEvent` after the operation succeeds and routes through `self.emit_interview_event(graph_dir, event)`, which anchors on the resolved project root (NOT the server cwd) and returns Option for soft-warning callers — same pattern as the task transition helper. Tests: 7 new unit tests in `auto_emit::interview::tests` covering the mapping; 1 new CLI integration test `test_interview_lifecycle_auto_emits_journal_entries` driving start → answer → commit and asserting plan/finding/outcome entries land plus the .ready marker is written on the final outcome. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tempyr-cli/src/commands/interview_cmd.rs | 76 ++++- crates/tempyr-cli/src/main.rs | 48 ++- crates/tempyr-cli/tests/integration.rs | 98 ++++++ .../tempyr-journal/src/auto_emit/interview.rs | 315 ++++++++++++++++++ crates/tempyr-journal/src/auto_emit/mod.rs | 25 ++ .../tempyr-journal/src/auto_emit/summary.rs | 58 ++++ .../src/{auto_emit.rs => auto_emit/task.rs} | 87 ++--- crates/tempyr-journal/src/lib.rs | 4 +- crates/tempyr-mcp/src/handler.rs | 109 +++++- docs/journal-spec.md | 2 +- 10 files changed, 739 insertions(+), 83 deletions(-) create mode 100644 crates/tempyr-journal/src/auto_emit/interview.rs create mode 100644 crates/tempyr-journal/src/auto_emit/mod.rs create mode 100644 crates/tempyr-journal/src/auto_emit/summary.rs rename crates/tempyr-journal/src/{auto_emit.rs => auto_emit/task.rs} (66%) diff --git a/crates/tempyr-cli/src/commands/interview_cmd.rs b/crates/tempyr-cli/src/commands/interview_cmd.rs index c276767..7115e58 100644 --- a/crates/tempyr-cli/src/commands/interview_cmd.rs +++ b/crates/tempyr-cli/src/commands/interview_cmd.rs @@ -1,13 +1,16 @@ use crate::config::ProjectContext; +use std::path::Path; use tempyr_core::graph::Graph; use tempyr_interview::phases; use tempyr_interview::proposer; use tempyr_interview::session::InterviewSession; +use tempyr_journal::{InterviewEvent, auto_emit_interview_event, path as jpath}; pub fn run_start( ctx: &ProjectContext, brain_dump: &str, root_type: &str, + agent: &str, json: bool, ) -> anyhow::Result<()> { let sessions_dir = ctx.tempyr_dir.join("sessions"); @@ -28,6 +31,18 @@ pub fn run_start( let session = result.session; session.save(&sessions_dir)?; + // Phase 4b: best-effort journal entry on interview lifecycle. + emit_interview_event( + &ctx.root, + agent, + &InterviewEvent::Started { + session_id: &session.id, + root_node_id: &session.root_node.id, + root_type: &session.root_type, + phase: session.phase.display_name(), + }, + ); + if json { println!( "{}", @@ -66,11 +81,13 @@ pub fn run_answer( ctx: &ProjectContext, session_id: &str, answer: &str, + agent: &str, json: bool, ) -> anyhow::Result<()> { let sessions_dir = ctx.tempyr_dir.join("sessions"); let mut session = InterviewSession::load_by_id(&sessions_dir, session_id)?; + let prior_phase = session.phase; let question = session .remaining_gaps .first() @@ -81,6 +98,30 @@ pub fn run_answer( session.save(&sessions_dir)?; + // Phase 4b: emit AnswerRecorded; if the reanalysis advanced the + // phase, also emit PhaseAdvanced. Both best-effort. + emit_interview_event( + &ctx.root, + agent, + &InterviewEvent::AnswerRecorded { + session_id: &session.id, + answer, + phase: session.phase.display_name(), + filled_gap_count: result.filled_gaps.len(), + }, + ); + if result.phase_changed { + emit_interview_event( + &ctx.root, + agent, + &InterviewEvent::PhaseAdvanced { + session_id: &session.id, + from: prior_phase.display_name(), + to: session.phase.display_name(), + }, + ); + } + if json { println!( "{}", @@ -179,7 +220,7 @@ pub fn run_show(ctx: &ProjectContext, session_id: &str, json: bool) -> anyhow::R Ok(()) } -pub fn run_commit(ctx: &ProjectContext, session_id: &str) -> anyhow::Result<()> { +pub fn run_commit(ctx: &ProjectContext, session_id: &str, agent: &str) -> anyhow::Result<()> { let sessions_dir = ctx.tempyr_dir.join("sessions"); let session = InterviewSession::load_by_id(&sessions_dir, session_id)?; @@ -198,6 +239,19 @@ pub fn run_commit(ctx: &ProjectContext, session_id: &str) -> anyhow::Result<()> } super::warn_if_index_refresh_fails(ctx); + // Phase 4b: emit Committed (final outcome) so the journal session + // gets finalized and picked up by the publisher. + emit_interview_event( + &ctx.root, + agent, + &InterviewEvent::Committed { + session_id: &session.id, + node_count: result.node_count, + edge_count: result.edge_count, + files_created: result.created_files.len(), + }, + ); + Ok(()) } @@ -230,3 +284,23 @@ pub fn run_list(ctx: &ProjectContext, json: bool) -> anyhow::Result<()> { Ok(()) } + +/// Best-effort wrapper around [`auto_emit_interview_event`]. Anchors +/// on the resolved project root (NOT shell cwd, which can point at a +/// different repo when `--graph-dir` is passed) and silently skips +/// when the project isn't inside a git repository. Failures are +/// reported on stderr but never propagate — the underlying interview +/// operation has already mutated state on disk. +fn emit_interview_event(project_root: &Path, agent: &str, event: &InterviewEvent<'_>) { + let common_dir = match jpath::git_common_dir(project_root) { + Ok(c) => c, + Err(_) => return, + }; + let worktree_top = match jpath::repo_toplevel(project_root) { + Ok(w) => w, + Err(_) => return, + }; + if let Err(e) = auto_emit_interview_event(&common_dir, &worktree_top, agent, event) { + eprintln!("warning: journal auto-emit for interview event failed: {e}"); + } +} diff --git a/crates/tempyr-cli/src/main.rs b/crates/tempyr-cli/src/main.rs index 6a10a5e..10509f0 100644 --- a/crates/tempyr-cli/src/main.rs +++ b/crates/tempyr-cli/src/main.rs @@ -283,13 +283,30 @@ pub enum InterviewAction { /// Root node type (default: feature) #[arg(long, default_value = "feature")] root_type: String, + /// Agent name recorded on the auto-emitted journal entry. + /// Mirrors the `--agent` flag on `tempyr journal log` / + /// `tempyr status`; set this when running from a non-Claude + /// agent so journal attribution is correct. + #[arg(long, default_value = "claude")] + agent: String, }, /// Process an answer in an active interview - Answer { session_id: String, answer: String }, + Answer { + session_id: String, + answer: String, + /// See `interview start --agent`. + #[arg(long, default_value = "claude")] + agent: String, + }, /// Show tentative graph state Show { session_id: String }, /// Commit tentative nodes to disk - Commit { session_id: String }, + Commit { + session_id: String, + /// See `interview start --agent`. + #[arg(long, default_value = "claude")] + agent: String, + }, /// Resume an interrupted session Resume { session_id: String }, /// List active sessions @@ -681,15 +698,30 @@ fn run(cli: Cli) -> anyhow::Result<()> { InterviewAction::Start { brain_dump, root_type, - } => commands::interview_cmd::run_start(&ctx, &brain_dump, &root_type, cli.json), - InterviewAction::Answer { session_id, answer } => { - commands::interview_cmd::run_answer(&ctx, &session_id, &answer, cli.json) - } + agent, + } => commands::interview_cmd::run_start( + &ctx, + &brain_dump, + &root_type, + &agent, + cli.json, + ), + InterviewAction::Answer { + session_id, + answer, + agent, + } => commands::interview_cmd::run_answer( + &ctx, + &session_id, + &answer, + &agent, + cli.json, + ), InterviewAction::Show { session_id } => { commands::interview_cmd::run_show(&ctx, &session_id, cli.json) } - InterviewAction::Commit { session_id } => { - commands::interview_cmd::run_commit(&ctx, &session_id) + InterviewAction::Commit { session_id, agent } => { + commands::interview_cmd::run_commit(&ctx, &session_id, &agent) } InterviewAction::Resume { session_id } => { commands::interview_cmd::run_show(&ctx, &session_id, cli.json) diff --git a/crates/tempyr-cli/tests/integration.rs b/crates/tempyr-cli/tests/integration.rs index 54c0111..fe5c9c0 100644 --- a/crates/tempyr-cli/tests/integration.rs +++ b/crates/tempyr-cli/tests/integration.rs @@ -1737,6 +1737,104 @@ fn test_interview_full_flow() { .success(); } +/// Phase 4b: `tempyr interview start/answer/commit` should each +/// auto-emit a journal entry. Drive the full flow and assert the +/// journal landed: +/// - one `plan` (provisional) entry on start +/// - one `finding` (provisional) entry per answer +/// - one final `outcome` on commit, which finalizes the session +#[test] +fn test_interview_lifecycle_auto_emits_journal_entries() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + init_project(&tmp); + + let open_dir = tmp.path().join(".git/tempyr/journals/open"); + + // Start + let output = tempyr() + .current_dir(tmp.path()) + .args([ + "--json", + "interview", + "start", + "We need a session replay feature for debugging", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let started: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let session_id = started["session_id"].as_str().unwrap().to_string(); + let root_id = started["root_id"].as_str().unwrap().to_string(); + + let entries = read_journal_entries(&open_dir); + assert_eq!(entries.len(), 1, "start should emit one entry"); + assert_eq!(entries[0]["kind"], "plan"); + assert_eq!(entries[0]["provisional"], true); + assert!(entries[0]["summary"].as_str().unwrap().contains(&root_id)); + + // Answer + tempyr() + .current_dir(tmp.path()) + .args([ + "interview", + "answer", + &session_id, + "Platform engineers are the target users", + ]) + .assert() + .success(); + let entries = read_journal_entries(&open_dir); + assert!( + entries.len() >= 2, + "answer should emit at least one entry, got {}", + entries.len() + ); + assert!( + entries + .iter() + .any(|e| e["kind"] == "finding" && e["provisional"] == true), + "answer should emit a provisional finding" + ); + + // Commit + tempyr() + .current_dir(tmp.path()) + .args(["interview", "commit", &session_id]) + .assert() + .success(); + let entries = read_journal_entries(&open_dir); + let outcome = entries + .iter() + .find(|e| e["kind"] == "outcome") + .expect("commit should emit an outcome entry"); + assert_eq!(outcome["passed"], true); + assert_eq!(outcome["final"], true); + assert!( + outcome["summary"] + .as_str() + .unwrap() + .contains("interview committed"), + "outcome summary should mark the commit, got: {}", + outcome["summary"] + ); + + // The `final = true` outcome should have triggered finalization. + // The publisher's `.ready` marker is named after the JOURNAL + // session id, not the interview session id, so just scan the dir + // for any `.ready` file rather than constructing the name. + let any_ready = std::fs::read_dir(&open_dir) + .unwrap() + .flatten() + .any(|e| e.path().extension().and_then(|s| s.to_str()) == Some("ready")); + assert!( + any_ready, + "commit's final=true outcome should have written a .ready marker" + ); +} + #[test] fn test_doctor_text_output_lists_paths_and_provider() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tempyr-journal/src/auto_emit/interview.rs b/crates/tempyr-journal/src/auto_emit/interview.rs new file mode 100644 index 0000000..691d988 --- /dev/null +++ b/crates/tempyr-journal/src/auto_emit/interview.rs @@ -0,0 +1,315 @@ +//! Auto-emit on interview lifecycle events (§9 Phase 4b). +//! +//! Maps the five interview-lifecycle events the spec calls out to +//! journal entries. The entries are all marked `provisional = true` +//! while the interview is in flight; the `Committed` entry is the +//! one non-provisional terminator and is also marked +//! `is_final = true` so the publisher picks up the journal session. +//! +//! | Event | Kind | Notes | +//! |-------------------|----------|-------------------------------------------| +//! | [`Started`] | `plan` | provisional; brain-dump → root summary | +//! | [`AnswerRecorded`]| `finding`| provisional | +//! | [`PhaseAdvanced`] | `finding`| provisional | +//! | [`Adjusted`] | `finding`| provisional; covers adjust/add/edge ops | +//! | [`Committed`] | `outcome`| `passed = true`, `is_final = true` | +//! +//! [`Started`]: InterviewEvent::Started +//! [`AnswerRecorded`]: InterviewEvent::AnswerRecorded +//! [`PhaseAdvanced`]: InterviewEvent::PhaseAdvanced +//! [`Adjusted`]: InterviewEvent::Adjusted +//! [`Committed`]: InterviewEvent::Committed +//! +//! Rollback is in the spec but isn't wired here — the interview +//! engine has no rollback operation today, so there's no call site +//! to hook. When/if rollback lands, this module gains a sixth +//! variant. + +use std::path::Path; + +use super::summary::clamp_summary; +use crate::Result; +use crate::kind::Kind; +use crate::session::Session; +use crate::writer::{EntryDraft, WriteOutcome, write_entry}; + +/// One lifecycle moment of an interview, captured with whatever +/// scalar state is useful in a journal entry. Borrowed strings keep +/// the call sites allocation-free; the variants intentionally don't +/// carry the full `InterviewSession` so this stays cheap to build. +#[derive(Debug, Clone, Copy)] +pub enum InterviewEvent<'a> { + /// A new interview was started from a brain dump. + Started { + session_id: &'a str, + root_node_id: &'a str, + root_type: &'a str, + phase: &'a str, + }, + /// The user answered a question; gap analysis re-ran. + AnswerRecorded { + session_id: &'a str, + answer: &'a str, + phase: &'a str, + filled_gap_count: usize, + }, + /// Gap analysis advanced the interview to a new phase. Emitted + /// alongside the [`AnswerRecorded`] / [`Adjusted`] event whose + /// reanalysis triggered the move. + /// + /// [`AnswerRecorded`]: InterviewEvent::AnswerRecorded + /// [`Adjusted`]: InterviewEvent::Adjusted + PhaseAdvanced { + session_id: &'a str, + from: &'a str, + to: &'a str, + }, + /// Tentative graph state was modified — covers `interview_adjust`, + /// `interview_add_node`, and `interview_add_edge` MCP tools. The + /// `operation` string distinguishes them in the journal summary. + Adjusted { + session_id: &'a str, + operation: &'a str, + target: &'a str, + }, + /// The session was committed — tentative nodes/edges were written + /// to the graph. Triggers session finalization. + Committed { + session_id: &'a str, + node_count: usize, + edge_count: usize, + files_created: usize, + }, +} + +/// Map an interview lifecycle event to a journal entry and write it. +/// Errors propagate to the caller, which downgrades them to non-fatal +/// warnings — the interview operation has already mutated state on +/// disk by the time we get here. +pub fn auto_emit_interview_event( + common_dir: &Path, + worktree_top: &Path, + agent: &str, + event: &InterviewEvent<'_>, +) -> Result { + let draft = build_draft(event); + let session = Session::open_or_resume(common_dir, worktree_top, agent)?; + write_entry(&session, worktree_top, draft) +} + +fn build_draft(e: &InterviewEvent<'_>) -> EntryDraft { + match *e { + InterviewEvent::Started { + session_id, + root_node_id, + root_type, + phase, + } => { + let summary = clamp_summary(format!( + "interview started ({phase}): root {root_node_id} ({root_type})" + )); + let mut d = EntryDraft::new(Kind::Plan, summary); + d.provisional = true; + d.references = vec![root_node_id.to_string()]; + d.tags = vec!["interview".into(), session_id.to_string()]; + d + } + InterviewEvent::AnswerRecorded { + session_id, + answer, + phase, + filled_gap_count, + } => { + let summary = clamp_summary(format!( + "interview answer ({phase}, {filled_gap_count} gap(s) filled): {answer}" + )); + let mut d = EntryDraft::new(Kind::Finding, summary); + d.provisional = true; + d.tags = vec!["interview".into(), session_id.to_string()]; + d + } + InterviewEvent::PhaseAdvanced { + session_id, + from, + to, + } => { + let summary = clamp_summary(format!("interview phase advanced: {from} → {to}")); + let mut d = EntryDraft::new(Kind::Finding, summary); + d.provisional = true; + d.tags = vec!["interview".into(), session_id.to_string()]; + d + } + InterviewEvent::Adjusted { + session_id, + operation, + target, + } => { + // The leading literal here is at least 22 chars so even a + // 1-char node id clears the 20-char minimum the writer + // enforces; real node ids are slug + 6-char suffix and + // never that short, but the floor matters for tests and + // for any future short-id corner case. + let summary = clamp_summary(format!("interview tentative graph {operation}: {target}")); + let mut d = EntryDraft::new(Kind::Finding, summary); + d.provisional = true; + d.references = vec![target.to_string()]; + d.tags = vec!["interview".into(), session_id.to_string()]; + d + } + InterviewEvent::Committed { + session_id, + node_count, + edge_count, + files_created, + } => { + let summary = clamp_summary(format!( + "interview committed: {node_count} node(s), {edge_count} edge(s), \ + {files_created} file(s) created" + )); + let mut d = EntryDraft::new(Kind::Outcome, summary); + d.passed = Some(true); + d.is_final = true; + d.tags = vec!["interview".into(), session_id.to_string()]; + d + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Kind; + use crate::auto_emit::summary::MAX_SUMMARY_CHARS; + + #[test] + fn started_emits_provisional_plan_with_root_reference() { + let d = build_draft(&InterviewEvent::Started { + session_id: "sess-abc", + root_node_id: "feat-something-aaaaaa", + root_type: "feature", + phase: "Discovery", + }); + assert_eq!(d.kind, Kind::Plan); + assert!(d.provisional); + assert!(!d.is_final); + assert!(d.summary.contains("feat-something-aaaaaa")); + assert!(d.summary.contains("Discovery")); + assert!(d.references.iter().any(|r| r == "feat-something-aaaaaa")); + assert!(d.tags.iter().any(|t| t == "interview")); + assert!(d.tags.iter().any(|t| t == "sess-abc")); + } + + #[test] + fn answer_recorded_emits_provisional_finding() { + let d = build_draft(&InterviewEvent::AnswerRecorded { + session_id: "sess-abc", + answer: "the persona is mid-market PMs", + phase: "Product", + filled_gap_count: 2, + }); + assert_eq!(d.kind, Kind::Finding); + assert!(d.provisional); + assert!(!d.is_final); + assert!(d.summary.contains("Product")); + assert!(d.summary.contains("mid-market PMs")); + } + + #[test] + fn phase_advanced_emits_provisional_finding() { + let d = build_draft(&InterviewEvent::PhaseAdvanced { + session_id: "sess-abc", + from: "Discovery", + to: "Product", + }); + assert_eq!(d.kind, Kind::Finding); + assert!(d.provisional); + assert!(d.summary.contains("Discovery")); + assert!(d.summary.contains("Product")); + } + + #[test] + fn adjusted_emits_provisional_finding_with_target_reference() { + let d = build_draft(&InterviewEvent::Adjusted { + session_id: "sess-abc", + operation: "adjust", + target: "feat-thing-aaaaaa", + }); + assert_eq!(d.kind, Kind::Finding); + assert!(d.provisional); + assert!(d.references.iter().any(|r| r == "feat-thing-aaaaaa")); + } + + #[test] + fn committed_emits_final_outcome_with_passed() { + let d = build_draft(&InterviewEvent::Committed { + session_id: "sess-abc", + node_count: 4, + edge_count: 3, + files_created: 4, + }); + assert_eq!(d.kind, Kind::Outcome); + assert_eq!(d.passed, Some(true)); + assert!(d.is_final); + assert!(!d.provisional); + assert!(d.summary.contains("4 node")); + assert!(d.summary.contains("3 edge")); + } + + #[test] + fn long_answer_is_truncated() { + let answer = "x".repeat(500); + let d = build_draft(&InterviewEvent::AnswerRecorded { + session_id: "sess-abc", + answer: &answer, + phase: "Discovery", + filled_gap_count: 0, + }); + assert!(d.summary.chars().count() <= MAX_SUMMARY_CHARS); + } + + #[test] + fn every_event_satisfies_minimum_summary_length() { + // Every variant's summary must clear the 20-char floor enforced + // by `validate_entry`. Probe each with the smallest-plausible + // inputs to lock that in. + let cases = [ + InterviewEvent::Started { + session_id: "s", + root_node_id: "n", + root_type: "t", + phase: "Discovery", + }, + InterviewEvent::AnswerRecorded { + session_id: "s", + answer: "a", + phase: "Product", + filled_gap_count: 0, + }, + InterviewEvent::PhaseAdvanced { + session_id: "s", + from: "Discovery", + to: "Product", + }, + InterviewEvent::Adjusted { + session_id: "s", + operation: "adjust", + target: "n", + }, + InterviewEvent::Committed { + session_id: "s", + node_count: 0, + edge_count: 0, + files_created: 0, + }, + ]; + for c in cases { + let d = build_draft(&c); + assert!( + d.summary.chars().count() >= 20, + "summary too short ({} chars) for {:?}", + d.summary.chars().count(), + c + ); + } + } +} diff --git a/crates/tempyr-journal/src/auto_emit/mod.rs b/crates/tempyr-journal/src/auto_emit/mod.rs new file mode 100644 index 0000000..b03ba13 --- /dev/null +++ b/crates/tempyr-journal/src/auto_emit/mod.rs @@ -0,0 +1,25 @@ +//! Auto-emit journal entries for tempyr lifecycle events. +//! +//! Phase 4 hooks the CLI and MCP transports into the journal so the +//! moments that matter — task status transitions, interview lifecycle +//! events — get captured automatically, without an agent having to +//! call `journal_log` explicitly. +//! +//! Submodules: +//! - [`task`] — the 3 task status transitions from §9 Phase 4a +//! (`backlog → in_progress`, `in_progress → done`, +//! `in_progress → blocked`). +//! - [`interview`] — the 5 interview lifecycle events from §9 +//! Phase 4b (start, answer, phase advance, adjust, commit). +//! +//! **Best-effort by contract**: every entry point in this module +//! returns its error to the caller, but callers (CLI handlers, MCP +//! tools) wrap that as a soft warning. The journal write must never +//! fail the surrounding graph mutation. + +pub mod interview; +mod summary; +pub mod task; + +pub use interview::{InterviewEvent, auto_emit_interview_event}; +pub use task::{TaskTransition, auto_emit_task_transition}; diff --git a/crates/tempyr-journal/src/auto_emit/summary.rs b/crates/tempyr-journal/src/auto_emit/summary.rs new file mode 100644 index 0000000..4be6d08 --- /dev/null +++ b/crates/tempyr-journal/src/auto_emit/summary.rs @@ -0,0 +1,58 @@ +//! Summary clamping shared by the auto-emit submodules. +//! +//! Synthesized journal summaries reuse user-supplied text (node +//! titles, interview answers) verbatim. The journal validator caps +//! summary length at 200 chars, so each call site needs to truncate +//! before constructing the [`crate::EntryDraft`]. Doing the truncation +//! at *Unicode scalar* granularity (not bytes) keeps multi-byte +//! codepoints intact; the trailing marker makes truncation visible to +//! anyone reading the entry. + +/// Largest summary length the journal validator will accept (matches +/// the upper bound enforced by `kind::validate_entry`). +pub(super) const MAX_SUMMARY_CHARS: usize = 200; + +/// Trailing marker appended when a summary is shortened. Picked to +/// fit inside the budget and read clearly in a CLI listing. +pub(super) const TRUNCATION_MARKER: &str = " […cut]"; + +pub(super) fn clamp_summary(s: String) -> String { + let len = s.chars().count(); + if len <= MAX_SUMMARY_CHARS { + return s; + } + let marker_chars = TRUNCATION_MARKER.chars().count(); + let keep = MAX_SUMMARY_CHARS.saturating_sub(marker_chars); + let mut out: String = s.chars().take(keep).collect(); + out.push_str(TRUNCATION_MARKER); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn under_budget_passes_through() { + let s = "short".to_string(); + assert_eq!(clamp_summary(s.clone()), s); + } + + #[test] + fn over_budget_gets_marker() { + let s = "x".repeat(500); + let out = clamp_summary(s); + assert!(out.chars().count() <= MAX_SUMMARY_CHARS); + assert!(out.ends_with(TRUNCATION_MARKER)); + } + + #[test] + fn multibyte_safe() { + // Each crab is 4 bytes / 1 char. A naive byte-cut at MAX*1 + // would split the codepoint; clamp_summary works in chars. + let s: String = "🦀".repeat(300); + let out = clamp_summary(s); + assert!(out.chars().count() <= MAX_SUMMARY_CHARS); + assert!(out.ends_with(TRUNCATION_MARKER)); + } +} diff --git a/crates/tempyr-journal/src/auto_emit.rs b/crates/tempyr-journal/src/auto_emit/task.rs similarity index 66% rename from crates/tempyr-journal/src/auto_emit.rs rename to crates/tempyr-journal/src/auto_emit/task.rs index 9b9ca58..89e1cae 100644 --- a/crates/tempyr-journal/src/auto_emit.rs +++ b/crates/tempyr-journal/src/auto_emit/task.rs @@ -1,41 +1,35 @@ -//! Auto-emit journal entries on task status transitions. +//! Auto-emit on task status transitions (§9 Phase 4a). //! -//! Phase 4a hooks the CLI `tempyr status` and the MCP `graph_update_node` -//! tool into the journal. When a node of type `task` moves between statuses -//! the indexer would care about, we synthesize one entry that captures the -//! transition. Per the spec (`docs/journal-spec.md` §9 Phase 4): +//! When a node of type `task` moves between statuses the indexer +//! cares about, we synthesize one entry capturing the transition: //! -//! | From → To | Kind | Notes | -//! |--------------------------|----------|----------------------------------------| -//! | `backlog` → `in_progress`| `plan` | provisional | -//! | `in_progress` → `done` | `outcome`| `passed = true`, `final = true` | -//! | `in_progress` → `blocked`| `risk` | `severity = blocker` | +//! | From → To | Kind | Notes | +//! |--------------------------|----------|------------------------------------| +//! | `backlog` → `in_progress`| `plan` | provisional | +//! | `in_progress` → `done` | `outcome`| `passed = true`, `final = true` | +//! | `in_progress` → `blocked`| `risk` | `severity = blocker` | //! -//! Anything else (e.g. status changes on non-task nodes, or transitions -//! outside the table) is a no-op — the function returns `Ok(None)`. -//! -//! **Best-effort only**: callers wrap errors as warnings rather than -//! failing the underlying status change. That's the contract: a journal -//! hiccup must never block a graph mutation. +//! Anything else (non-task nodes, transitions outside the table) is +//! a no-op — the function returns `Ok(None)`. use std::path::Path; +use super::summary::clamp_summary; use crate::kind::Kind; use crate::session::Session; use crate::writer::{EntryDraft, WriteOutcome, write_entry}; use crate::{Result, Severity}; -/// Snapshot of a graph-node status change captured by the caller before -/// and after [`tempyr_core::ops::update_status`] / `update_node` runs. -/// Borrowed strings keep this allocation-free for the common case -/// (the caller already owns these as `String`s on the parsed `Node`). +/// Snapshot of a graph-node status change captured by the caller +/// before and after [`tempyr_core::ops::update_status`] / +/// `update_node` runs. Borrowed strings keep this allocation-free +/// for the common case (the caller already owns these as `String`s +/// on the parsed `Node`). #[derive(Debug, Clone, Copy)] pub struct TaskTransition<'a> { pub node_id: &'a str, pub node_type: &'a str, /// First H1 in the node body or the id (matches `Node::title`). - /// Used in the journal entry summary so the line reads as "starting - /// task " instead of a bare slug. pub title: &'a str, /// Status the node had on disk before the update. `None` if the /// node had no status set previously — those transitions still @@ -46,12 +40,7 @@ pub struct TaskTransition<'a> { /// Map a status transition on a task node to a journal entry and write /// it. Returns `Ok(None)` for any input that doesn't match one of the -/// three spec'd rules — including non-task nodes, no-op transitions -/// (`prior == new`), and any transition outside the rule table. -/// -/// Errors propagate to the caller, which is responsible for downgrading -/// them to non-fatal warnings (the auto-emit must never fail the -/// surrounding status-change operation). +/// three spec'd rules. pub fn auto_emit_task_transition( common_dir: &Path, worktree_top: &Path, @@ -104,36 +93,11 @@ fn build_draft(t: &TaskTransition<'_>) -> Option<EntryDraft> { Some(d) } -/// Largest summary length the journal validator will accept (matches -/// the upper bound enforced by `kind::validate_entry`). Synthesized -/// summaries reuse the user-supplied node title verbatim, so a long -/// H1 — or a future schema with relaxed title bounds — could blow the -/// limit. Truncate at this many *Unicode scalars* so we never split a -/// multi-byte char, and append a marker so the truncation is visible -/// in the journal. -const MAX_SUMMARY_CHARS: usize = 200; - -/// Truncation marker appended when a summary is shortened. Six chars -/// is enough for `…` plus a `[cut]` tag — picked so the marker fits -/// inside the budget and reads clearly in a CLI listing. -const TRUNCATION_MARKER: &str = " […cut]"; - -fn clamp_summary(s: String) -> String { - let len = s.chars().count(); - if len <= MAX_SUMMARY_CHARS { - return s; - } - let marker_chars = TRUNCATION_MARKER.chars().count(); - let keep = MAX_SUMMARY_CHARS.saturating_sub(marker_chars); - let mut out: String = s.chars().take(keep).collect(); - out.push_str(TRUNCATION_MARKER); - out -} - #[cfg(test)] mod tests { use super::*; use crate::Kind; + use crate::auto_emit::summary::{MAX_SUMMARY_CHARS, TRUNCATION_MARKER}; fn t<'a>(node_type: &'a str, prior: Option<&'a str>, new: &'a str) -> TaskTransition<'a> { TaskTransition { @@ -204,9 +168,6 @@ mod tests { #[test] fn long_title_is_truncated_below_summary_limit() { - // Construct a title pushing the formatted summary well past the - // 200-char ceiling. Without truncation this would fail the - // writer's `validate_entry` length check at runtime. let long_title = "x".repeat(500); let transition = TaskTransition { node_id: "task-overflow-aaaaaa", @@ -216,20 +177,12 @@ mod tests { new_status: "in_progress", }; let draft = build_draft(&transition).unwrap(); - assert!( - draft.summary.chars().count() <= MAX_SUMMARY_CHARS, - "summary still over limit: {} chars", - draft.summary.chars().count() - ); + assert!(draft.summary.chars().count() <= MAX_SUMMARY_CHARS); assert!(draft.summary.ends_with(TRUNCATION_MARKER)); } #[test] fn truncation_does_not_split_multibyte_characters() { - // Padding from the front with a 4-byte emoji ensures the - // boundary cuts inside what would be a multi-byte sequence - // for a byte-oriented truncation. Char-aware truncation must - // produce a valid UTF-8 string regardless. let title: String = "🦀".repeat(300); let transition = TaskTransition { node_id: "task-utf8-aaaaaa", @@ -239,8 +192,6 @@ mod tests { new_status: "in_progress", }; let draft = build_draft(&transition).unwrap(); - // Round-trip through validation surrogate: every char boundary - // is intact (this would have panicked on a byte-split slice). assert!(draft.summary.chars().count() <= MAX_SUMMARY_CHARS); let reparsed: String = draft.summary.chars().collect(); assert_eq!(reparsed, draft.summary); diff --git a/crates/tempyr-journal/src/lib.rs b/crates/tempyr-journal/src/lib.rs index 509347b..d749313 100644 --- a/crates/tempyr-journal/src/lib.rs +++ b/crates/tempyr-journal/src/lib.rs @@ -21,7 +21,9 @@ pub mod session; pub mod state; pub mod writer; -pub use auto_emit::{TaskTransition, auto_emit_task_transition}; +pub use auto_emit::{ + InterviewEvent, TaskTransition, auto_emit_interview_event, auto_emit_task_transition, +}; pub use config::JournalConfig; pub use entry::{Confidence, Entry, Polarity, SCHEMA_VERSION, Severity}; pub use kind::{Kind, validate_entry}; diff --git a/crates/tempyr-mcp/src/handler.rs b/crates/tempyr-mcp/src/handler.rs index 7a9d7fc..82e9ca8 100644 --- a/crates/tempyr-mcp/src/handler.rs +++ b/crates/tempyr-mcp/src/handler.rs @@ -33,8 +33,8 @@ use tempyr_interview::session::{ use tempyr_journal::path as jpath; use tempyr_journal::{ - Confidence, EntryDraft, Kind, Polarity, Session, Severity, TaskTransition, - auto_emit_task_transition, write_entry, + Confidence, EntryDraft, InterviewEvent, Kind, Polarity, Session, Severity, TaskTransition, + auto_emit_interview_event, auto_emit_task_transition, write_entry, }; use tempyr_linear::client::LinearClient; use tempyr_linear::config::LinearConfig; @@ -1162,6 +1162,24 @@ impl TempyrServer { } } + /// Best-effort journal auto-emit for interview lifecycle events + /// (Phase 4b). Mirrors [`Self::emit_journal_for_transition`] for + /// the interview side: anchors on `graph_dir.parent()` (NOT the + /// server's cwd), swallows missing-git-repo errors silently, and + /// returns `Some(warning_line)` on real failure so the caller can + /// append it to the tool response. The interview operation has + /// already mutated session state on disk by the time we get here, + /// so a write failure must not bubble up as a tool error. + fn emit_interview_event(&self, graph_dir: &Path, event: &InterviewEvent<'_>) -> Option<String> { + let project_root = graph_dir.parent()?; + let common_dir = jpath::git_common_dir(project_root).ok()?; + let worktree_top = jpath::repo_toplevel(project_root).ok()?; + match auto_emit_interview_event(&common_dir, &worktree_top, &self.agent_id, event) { + Ok(_) => None, + Err(e) => Some(format!("journal auto-emit for interview event failed: {e}")), + } + } + #[tool( name = "graph_add_edge", description = "Add a directed edge between two existing nodes. The reverse edge is written automatically." @@ -1298,6 +1316,19 @@ impl TempyrServer { result.session.save(&sessions).map_err(|e| e.to_string())?; + // Phase 4b: best-effort journal entry on interview start. + if let Some(w) = self.emit_interview_event( + &graph_dir, + &InterviewEvent::Started { + session_id: &result.session.id, + root_node_id: &result.session.root_node.id, + root_type: &result.session.root_type, + phase: result.session.phase.display_name(), + }, + ) { + eprintln!("warning: {w}"); + } + let state = session_state_json(&result.session, &schema); serde_json::to_string_pretty(&state).map_err(|e| e.to_string()) } @@ -1317,6 +1348,7 @@ impl TempyrServer { let mut session = InterviewSession::load_by_id(&sessions, &p.session_id).map_err(|e| e.to_string())?; + let prior_phase = session.phase; let question_context: String = next_questions(&session, 3) .iter() .map(|g| g.suggested_question.as_str()) @@ -1328,6 +1360,32 @@ impl TempyrServer { session.save(&sessions).map_err(|e| e.to_string())?; + // Phase 4b: emit AnswerRecorded; if phase advanced, also emit + // PhaseAdvanced. Both best-effort. + if let Some(w) = self.emit_interview_event( + &graph_dir, + &InterviewEvent::AnswerRecorded { + session_id: &session.id, + answer: &p.answer, + phase: session.phase.display_name(), + filled_gap_count: update.filled_gaps.len(), + }, + ) { + eprintln!("warning: {w}"); + } + if update.phase_changed + && let Some(w) = self.emit_interview_event( + &graph_dir, + &InterviewEvent::PhaseAdvanced { + session_id: &session.id, + from: prior_phase.display_name(), + to: session.phase.display_name(), + }, + ) + { + eprintln!("warning: {w}"); + } + serde_json::to_string_pretty(&json!({ "session_id": session.id, "filled_gaps": update.filled_gaps, @@ -1392,6 +1450,22 @@ impl TempyrServer { all_warnings.push(warning); } + // Phase 4b: emit Committed (final outcome) so the journal + // session gets finalized and picked up by the publisher. The + // commit response already has a `warnings` array, so a journal + // failure is surfaced there rather than only on stderr. + if let Some(w) = self.emit_interview_event( + &graph_dir, + &InterviewEvent::Committed { + session_id: &p.session_id, + node_count: result.node_count, + edge_count: result.edge_count, + files_created: result.created_files.len(), + }, + ) { + all_warnings.push(w); + } + serde_json::to_string_pretty(&json!({ "files_created": result.created_files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(), "files_modified": result.modified_files.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(), @@ -1410,11 +1484,12 @@ impl TempyrServer { &self, Parameters(p): Parameters<InterviewAdjustParams>, ) -> Result<String, String> { - let (_, gf_dir, schema) = self.find_project()?; + let (graph_dir, gf_dir, schema) = self.find_project()?; let sessions = sessions_dir(&gf_dir); let mut session = InterviewSession::load_by_id(&sessions, &p.session_id).map_err(|e| e.to_string())?; + let prior_phase = session.phase; let patch = NodePatch { id: p.new_id.clone(), body: p.body, @@ -1437,9 +1512,35 @@ impl TempyrServer { } } - let _update = proposer::reanalyze(&mut session, &schema); + let update = proposer::reanalyze(&mut session, &schema); session.save(&sessions).map_err(|e| e.to_string())?; + // Phase 4b: emit Adjusted; if reanalysis advanced the phase, + // also emit PhaseAdvanced. Both best-effort. + let target = p.new_id.as_deref().unwrap_or(&p.node_id); + if let Some(w) = self.emit_interview_event( + &graph_dir, + &InterviewEvent::Adjusted { + session_id: &session.id, + operation: "adjust", + target, + }, + ) { + eprintln!("warning: {w}"); + } + if update.phase_changed + && let Some(w) = self.emit_interview_event( + &graph_dir, + &InterviewEvent::PhaseAdvanced { + session_id: &session.id, + from: prior_phase.display_name(), + to: session.phase.display_name(), + }, + ) + { + eprintln!("warning: {w}"); + } + let state = session_state_json(&session, &schema); serde_json::to_string_pretty(&state).map_err(|e| e.to_string()) } diff --git a/docs/journal-spec.md b/docs/journal-spec.md index dbbe4db..19d6b3e 100644 --- a/docs/journal-spec.md +++ b/docs/journal-spec.md @@ -370,7 +370,7 @@ Once search lands, the journal is useful when agents query it. Phase 4 closes th - `in_progress → done` → emit `outcome` with `passed = true` and `final = true` - `in_progress → blocked` → emit `risk` with `severity = blocker` - Implementation lives in [`tempyr_journal::auto_emit`](../crates/tempyr-journal/src/auto_emit.rs); both call sites treat write failures as soft warnings, never aborting the underlying status change. -- Auto-emit on interview lifecycle: start, answer, adjust, phase, commit, rollback. All provisional until session commit. +- Auto-emit on interview lifecycle: start, answer, adjust, phase, commit, rollback. All provisional until session commit. **Implemented in slice 4b** for the five operations that exist today (start / answer / phase / adjust / commit); rollback is deferred until the interview engine grows a corresponding operation. Implementation lives in [`tempyr_journal::auto_emit::interview`](../crates/tempyr-journal/src/auto_emit/interview.rs); both call sites treat write failures as soft warnings, never aborting the underlying interview operation. - `.claude/settings.json.example` template with `SessionStart`/`SessionEnd` hooks invoking `tempyr journal bootstrap` and `tempyr journal finalize`. - MCP annotations (`read_only`/`destructive`/`idempotent`/`open_world`) across all existing tempyr tools (orthogonal but worth bundling). - README "Session journal" section + mirror to `CLAUDE.md` and `AGENTS.md`. From 4f0e6d7de5f4e4148fbab4fb65ac03866f21a544 Mon Sep 17 00:00:00 2001 From: Caleb Leak <caleb.leak@gmail.com> Date: Wed, 29 Apr 2026 10:57:38 -0700 Subject: [PATCH 2/2] review: distinguish NotAGitRepo + surface MCP warnings via response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings on PR #30 (Phase 4b): 1. The CLI auto-emit helpers were swallowing all errors from `jpath::git_common_dir` / `repo_toplevel` with `Err(_) => return`. `NotAGitRepo` is a distinct enum variant — silently skipping it is the right call (tempyr supports operating outside a git repo) but bundling Io / Git failures into the same arm hides real bugs. Match `NotAGitRepo` explicitly and log anything else to stderr with context. Apply the same fix to `status_cmd.rs` (slice 4a code), which has the identical pattern. 2. MCP `interview_start`, `interview_answer`, and `interview_adjust` were calling `eprintln!` for journal auto-emit failures. MCP clients don't see server stderr, so a write failure was effectively invisible. `interview_commit` already exposes warnings via the response's `warnings` field; mirror that on the other three handlers. Adds a small `attach_warnings` helper that inserts `warnings` into the JSON value `session_state_json` returns; `interview_answer` uses an inline `json!` literal so the field gets added there directly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../tempyr-cli/src/commands/interview_cmd.rs | 25 ++++++++--- crates/tempyr-cli/src/commands/status_cmd.rs | 21 +++++++--- crates/tempyr-mcp/src/handler.rs | 42 +++++++++++++++---- 3 files changed, 69 insertions(+), 19 deletions(-) diff --git a/crates/tempyr-cli/src/commands/interview_cmd.rs b/crates/tempyr-cli/src/commands/interview_cmd.rs index 7115e58..eeeda23 100644 --- a/crates/tempyr-cli/src/commands/interview_cmd.rs +++ b/crates/tempyr-cli/src/commands/interview_cmd.rs @@ -4,7 +4,7 @@ use tempyr_core::graph::Graph; use tempyr_interview::phases; use tempyr_interview::proposer; use tempyr_interview::session::InterviewSession; -use tempyr_journal::{InterviewEvent, auto_emit_interview_event, path as jpath}; +use tempyr_journal::{InterviewEvent, JournalError, auto_emit_interview_event, path as jpath}; pub fn run_start( ctx: &ProjectContext, @@ -287,18 +287,33 @@ pub fn run_list(ctx: &ProjectContext, json: bool) -> anyhow::Result<()> { /// Best-effort wrapper around [`auto_emit_interview_event`]. Anchors /// on the resolved project root (NOT shell cwd, which can point at a -/// different repo when `--graph-dir` is passed) and silently skips -/// when the project isn't inside a git repository. Failures are +/// different repo when `--graph-dir` is passed). Failures are /// reported on stderr but never propagate — the underlying interview /// operation has already mutated state on disk. +/// +/// Error policy: +/// - [`JournalError::NotAGitRepo`] is swallowed silently. Tempyr +/// supports operating outside a git repo; "no journal" is the +/// expected fallthrough, not an error worth logging. +/// - Anything else (IO, git binary missing, redaction block, lock +/// contention, etc.) is logged to stderr with context so a real +/// bug isn't invisible. fn emit_interview_event(project_root: &Path, agent: &str, event: &InterviewEvent<'_>) { let common_dir = match jpath::git_common_dir(project_root) { Ok(c) => c, - Err(_) => return, + Err(JournalError::NotAGitRepo(_)) => return, + Err(e) => { + eprintln!("warning: journal auto-emit skipped, git_common_dir failed: {e}"); + return; + } }; let worktree_top = match jpath::repo_toplevel(project_root) { Ok(w) => w, - Err(_) => return, + Err(JournalError::NotAGitRepo(_)) => return, + Err(e) => { + eprintln!("warning: journal auto-emit skipped, repo_toplevel failed: {e}"); + return; + } }; if let Err(e) = auto_emit_interview_event(&common_dir, &worktree_top, agent, event) { eprintln!("warning: journal auto-emit for interview event failed: {e}"); diff --git a/crates/tempyr-cli/src/commands/status_cmd.rs b/crates/tempyr-cli/src/commands/status_cmd.rs index f687acd..e5db284 100644 --- a/crates/tempyr-cli/src/commands/status_cmd.rs +++ b/crates/tempyr-cli/src/commands/status_cmd.rs @@ -1,7 +1,7 @@ use crate::config::ProjectContext; use std::path::Path; use tempyr_core::ops; -use tempyr_journal::{TaskTransition, auto_emit_task_transition, path as jpath}; +use tempyr_journal::{JournalError, TaskTransition, auto_emit_task_transition, path as jpath}; pub fn run(ctx: &ProjectContext, id: &str, new_status: &str, agent: &str) -> anyhow::Result<()> { // Resolve up front so the user-printed message, the file lookup @@ -34,15 +34,26 @@ fn emit_journal_for_transition( // can sit in a totally different repo — a cwd-based lookup would // either skip the journal write or, worse, target the wrong repo's // refs entirely. + // + // Error policy: silently swallow `NotAGitRepo` (tempyr supports + // operating outside a git repo, so "no journal" is the expected + // fallthrough). Surface any other error (IO, git binary missing, + // etc.) on stderr so real bugs aren't invisible. let common_dir = match jpath::git_common_dir(project_root) { Ok(c) => c, - // Project is not inside a git repo. Tempyr supports that mode - // (no journals, no publisher) — silently skip the auto-emit. - Err(_) => return, + Err(JournalError::NotAGitRepo(_)) => return, + Err(e) => { + eprintln!("warning: journal auto-emit skipped, git_common_dir failed: {e}"); + return; + } }; let worktree_top = match jpath::repo_toplevel(project_root) { Ok(w) => w, - Err(_) => return, + Err(JournalError::NotAGitRepo(_)) => return, + Err(e) => { + eprintln!("warning: journal auto-emit skipped, repo_toplevel failed: {e}"); + return; + } }; let transition = TaskTransition { diff --git a/crates/tempyr-mcp/src/handler.rs b/crates/tempyr-mcp/src/handler.rs index 82e9ca8..f6b01bd 100644 --- a/crates/tempyr-mcp/src/handler.rs +++ b/crates/tempyr-mcp/src/handler.rs @@ -499,6 +499,19 @@ fn sessions_dir(gf_dir: &Path) -> PathBuf { gf_dir.join("sessions") } +/// Insert a `warnings` array into a session-state JSON object built +/// by [`session_state_json`]. Used by `interview_start` and +/// `interview_adjust` to surface soft-failure messages from the +/// journal auto-emit through the response — same channel +/// `interview_commit` already uses, so MCP clients see the warning +/// instead of just stderr. Empty input is still attached as `[]` so +/// the field is always present (clients can rely on it). +fn attach_warnings(state: &mut Value, warnings: Vec<String>) { + if let Value::Object(map) = state { + map.insert("warnings".to_string(), json!(warnings)); + } +} + fn session_state_json(session: &InterviewSession, _schema: &Schema) -> Value { let questions = next_questions(session, 3); let progress = proposer::compute_progress(session); @@ -1317,6 +1330,10 @@ impl TempyrServer { result.session.save(&sessions).map_err(|e| e.to_string())?; // Phase 4b: best-effort journal entry on interview start. + // Failures get surfaced via the response's `warnings` field + // — same channel `interview_commit` uses — so MCP clients + // see them, not just the server's stderr. + let mut warnings: Vec<String> = Vec::new(); if let Some(w) = self.emit_interview_event( &graph_dir, &InterviewEvent::Started { @@ -1326,10 +1343,11 @@ impl TempyrServer { phase: result.session.phase.display_name(), }, ) { - eprintln!("warning: {w}"); + warnings.push(w); } - let state = session_state_json(&result.session, &schema); + let mut state = session_state_json(&result.session, &schema); + attach_warnings(&mut state, warnings); serde_json::to_string_pretty(&state).map_err(|e| e.to_string()) } @@ -1361,7 +1379,9 @@ impl TempyrServer { session.save(&sessions).map_err(|e| e.to_string())?; // Phase 4b: emit AnswerRecorded; if phase advanced, also emit - // PhaseAdvanced. Both best-effort. + // PhaseAdvanced. Failures collected into the response's + // `warnings` field so MCP clients can surface them. + let mut warnings: Vec<String> = Vec::new(); if let Some(w) = self.emit_interview_event( &graph_dir, &InterviewEvent::AnswerRecorded { @@ -1371,7 +1391,7 @@ impl TempyrServer { filled_gap_count: update.filled_gaps.len(), }, ) { - eprintln!("warning: {w}"); + warnings.push(w); } if update.phase_changed && let Some(w) = self.emit_interview_event( @@ -1383,7 +1403,7 @@ impl TempyrServer { }, ) { - eprintln!("warning: {w}"); + warnings.push(w); } serde_json::to_string_pretty(&json!({ @@ -1399,6 +1419,7 @@ impl TempyrServer { }, "tentative_nodes_count": session.tentative_nodes.len() + 1, "tentative_edges_count": session.tentative_edges.len(), + "warnings": warnings, })) .map_err(|e| e.to_string()) } @@ -1516,7 +1537,9 @@ impl TempyrServer { session.save(&sessions).map_err(|e| e.to_string())?; // Phase 4b: emit Adjusted; if reanalysis advanced the phase, - // also emit PhaseAdvanced. Both best-effort. + // also emit PhaseAdvanced. Failures collected into the + // response's `warnings` field so clients see them. + let mut warnings: Vec<String> = Vec::new(); let target = p.new_id.as_deref().unwrap_or(&p.node_id); if let Some(w) = self.emit_interview_event( &graph_dir, @@ -1526,7 +1549,7 @@ impl TempyrServer { target, }, ) { - eprintln!("warning: {w}"); + warnings.push(w); } if update.phase_changed && let Some(w) = self.emit_interview_event( @@ -1538,10 +1561,11 @@ impl TempyrServer { }, ) { - eprintln!("warning: {w}"); + warnings.push(w); } - let state = session_state_json(&session, &schema); + let mut state = session_state_json(&session, &schema); + attach_warnings(&mut state, warnings); serde_json::to_string_pretty(&state).map_err(|e| e.to_string()) }