Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion crates/tempyr-cli/src/commands/interview_cmd.rs
Original file line number Diff line number Diff line change
@@ -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, JournalError, 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");
Expand All @@ -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!(
"{}",
Expand Down Expand Up @@ -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()
Expand All @@ -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!(
"{}",
Expand Down Expand Up @@ -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)?;

Expand All @@ -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(())
}

Expand Down Expand Up @@ -230,3 +284,38 @@ 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). 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(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(JournalError::NotAGitRepo(_)) => return,
Err(e) => {
eprintln!("warning: journal auto-emit skipped, repo_toplevel failed: {e}");
return;
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if let Err(e) = auto_emit_interview_event(&common_dir, &worktree_top, agent, event) {
eprintln!("warning: journal auto-emit for interview event failed: {e}");
}
}
21 changes: 16 additions & 5 deletions crates/tempyr-cli/src/commands/status_cmd.rs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down
48 changes: 40 additions & 8 deletions crates/tempyr-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions crates/tempyr-cli/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading