From b8f96ed45cfbeff0d6c237ae62076aae8018cbb5 Mon Sep 17 00:00:00 2001 From: Son Seong Jun <166786347+SonAIengine@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:00:56 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=ED=99=94=ED=98=95=20REPL=EC=9D=B4=20?= =?UTF-8?q?=EB=81=9D=EB=82=9C=20Step=EB=A7=88=EB=8B=A4=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EA=B3=BC=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=EC=94=A9=20=EB=B3=B4=EC=97=AC=20=EC=A4=80=EB=8B=A4=20?= =?UTF-8?q?(ADR-0070)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run이 끝날 때까지 "Reading… · 12s" 한 줄만 보여서 어떤 파일을 읽고 어떤 명령을 돌리는지 알 수 없었다. 이제 Step이 끝날 때마다 검증된 journal(Receipt, 승인된 정규화 인자, tool output, failureCode)에서 "read devhub/types.py", "patched a.py (+1 -1)", "ran bash -c pytest (exit 0)", "read gone.md failed: not_found" 같은 줄을 찍는다. DriverProgress는 경로 없는 고정 이벤트로 두고 (ADR-0033), REPL host가 run journal을 read-only로 열어 새 Receipt만 읽는다. resume는 기존 Receipt 수부터 세서 다시 찍지 않는다. - e2e: README 읽기와 git --version이 각각 한 번씩 찍힘, 단위: 검색·patch·실행·실패 표시 - tmux 실사용으로 검색/읽기/patch/pytest 줄과 live line 확인, 테스트 746개 통과 Co-Authored-By: Claude Opus 5.5 --- crates/xgen-cli/src/change_report.rs | 271 ++++++++++++++++++++++ crates/xgen-cli/src/lib.rs | 5 +- crates/xgen-cli/src/main.rs | 42 +++- crates/xgen-cli/src/material_catalog.rs | 2 +- crates/xgen-cli/src/repl.rs | 83 +++++-- crates/xgen-cli/tests/interactive_repl.rs | 11 + docs/adr/0070-live-step-activity.md | 41 ++++ 7 files changed, 436 insertions(+), 19 deletions(-) create mode 100644 docs/adr/0070-live-step-activity.md diff --git a/crates/xgen-cli/src/change_report.rs b/crates/xgen-cli/src/change_report.rs index 7fe2dab..6c73c24 100644 --- a/crates/xgen-cli/src/change_report.rs +++ b/crates/xgen-cli/src/change_report.rs @@ -144,6 +144,225 @@ pub fn inspect_local_changes(run_id: &str) -> Result, Publi Ok(changes) } +/// What one finished Step did, for the live activity log (ADR-0070). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StepActivity { + /// Short past-tense verb such as `read`, `patched`, or `ran`. + pub verb: String, + /// The workspace-relative path, command line, or query the Step acted on. + pub subject: String, + /// A short result such as `+3 -1`, `exit 1`, or `12 matches`, when one is known. + pub detail: Option, + /// The closed failure code when the Step failed. + pub failure: Option, +} + +/// Activities of the Receipts after the first `skip` ones, in Receipt order, rebuilt from the +/// verified journal like [`inspect_local_changes`]. A Step whose arguments cannot be rebuilt is +/// reported by its capability alone. +/// +/// # Errors +/// +/// Returns [`PublicRunError::Configuration`] when the Run does not exist and +/// [`PublicRunError::Integrity`] when its journal does not open. +pub fn inspect_step_activities( + run_id: &str, + skip: usize, +) -> Result, PublicRunError> { + let root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; + let layout = RunLayout::existing(&root, run_id).map_err(|_| PublicRunError::Configuration)?; + let store = SqliteRunStore::open_existing_read_only(layout.database_path()) + .map_err(|_| PublicRunError::Integrity)?; + let receipts = store + .load_execution_receipts() + .map_err(|_| PublicRunError::Integrity)?; + if receipts.len() <= skip { + return Ok(Vec::new()); + } + let state = store + .load_current() + .map_err(|_| PublicRunError::Integrity)? + .ok_or(PublicRunError::Integrity)?; + let catalog = + RunMaterialCatalog::open_existing_read_only(&layout.material_catalog_path(), run_id).ok(); + let mut activities = Vec::new(); + for receipt in receipts.iter().skip(skip) { + let step = state.steps.get(&receipt.step_id); + let arguments = step.and_then(|step| { + let intent = step.intent.as_ref()?; + let material = store.load_invocation_material(&intent.effect_id).ok()??; + let InvocationMaterialRetention::ReconstructableReference(reference) = + material.retention() + else { + return None; + }; + let catalog = catalog.as_ref()?; + catalog + .workspace_arguments( + reference, + &step.step_id, + material.material_digest(), + &receipt.capability.capability_id, + ) + .or_else(|_| { + catalog.process_family_arguments( + reference, + &step.step_id, + material.material_digest(), + &receipt.capability, + ) + }) + .ok() + }); + let output = step + .and_then(|step| step.intent.as_ref()) + .and_then(|intent| store.load_tool_output(&intent.effect_id).ok().flatten()); + let failure = step + .and_then(|step| step.failure_code) + .map(|code| code.as_str().to_owned()) + .or_else(|| (receipt.status != ReceiptStatus::Succeeded).then(|| "failed".to_owned())); + activities.push(activity_from( + &receipt.capability.capability_id, + arguments.as_ref(), + output + .as_ref() + .map(xgen_workgraph::ToolOutputRecord::output), + failure, + )); + } + Ok(activities) +} + +/// How many Receipts the Run has, so a resumed Run reports only the Steps it finishes next. +/// +/// # Errors +/// +/// Returns [`PublicRunError::Configuration`] when the Run does not exist and +/// [`PublicRunError::Integrity`] when its journal does not open. +pub fn inspect_receipt_count(run_id: &str) -> Result { + let root = discover_state_root().map_err(|_| PublicRunError::Configuration)?; + let layout = RunLayout::existing(&root, run_id).map_err(|_| PublicRunError::Configuration)?; + SqliteRunStore::open_existing_read_only(layout.database_path()) + .and_then(|store| store.load_execution_receipts()) + .map(|receipts| receipts.len()) + .map_err(|_| PublicRunError::Integrity) +} + +/// Lines removed and added by a patch's edits, as the change report counts them. +fn patch_line_counts(arguments: Option<&Value>) -> (usize, usize) { + let edits = arguments + .and_then(|arguments| arguments.get("edits")) + .and_then(Value::as_array); + edits.map_or((0, 0), |edits| { + edits.iter().fold((0, 0), |(removed, added), edit| { + let text = |key: &str| { + edit.get(key) + .and_then(Value::as_str) + .unwrap_or("") + .to_owned() + }; + let edit = TextEdit { + old_text: text("oldText"), + new_text: text("newText"), + }; + let (old, new) = edit_lines(&edit); + (removed + old.len(), added + new.len()) + }) + }) +} + +fn activity_from( + capability_id: &str, + arguments: Option<&Value>, + output: Option<&Value>, + failure: Option, +) -> StepActivity { + let text = |key: &str| { + arguments + .and_then(|arguments| arguments.get(key)) + .and_then(Value::as_str) + .map(str::to_owned) + }; + let path = || { + arguments + .and_then(display_path) + .unwrap_or_else(|| ".".to_owned()) + }; + let output_number = |key: &str| { + output + .and_then(|output| output.get(key)) + .and_then(Value::as_i64) + }; + let (verb, subject, detail) = match capability_id { + "xgeny.fs/read-text" => ("read", path(), None), + "xgeny.fs/read-text-lines" => ( + "read", + path(), + output_number("startLine") + .zip(output_number("endLine")) + .map(|(start, end)| format!("lines {start}-{end}")), + ), + "xgeny.fs/list-directory" => ("listed", path(), None), + "xgeny.fs/stat" => ("inspected", path(), None), + "xgeny.fs/search-text" => ( + "searched", + format!("{:?} in {}", text("query").unwrap_or_default(), path()), + output + .and_then(|output| output.get("matches")) + .and_then(Value::as_array) + .map(|matches| format!("{} matches", matches.len())), + ), + APPLY_PATCH => { + let (removed, added) = patch_line_counts(arguments); + ("patched", path(), Some(format!("+{added} -{removed}"))) + } + WRITE_ATOMIC => ( + "wrote", + path(), + text("content").map(|content| format!("{} lines", content.lines().count())), + ), + PROCESS_EXECUTE_CAPABILITY_ID => { + let mut command = text("executable") + .map(|executable| logical_executable(&executable).to_owned()) + .unwrap_or_default(); + for argument in arguments + .and_then(|arguments| arguments.get("args")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + { + command.push(' '); + command.push_str(argument); + } + ( + "ran", + command, + output_number("exitCode").map(|code| format!("exit {code}")), + ) + } + TERMINAL_START => ( + "started terminal", + text("executable") + .map(|executable| logical_executable(&executable).to_owned()) + .unwrap_or_default(), + None, + ), + TERMINAL_WRITE => ("typed into terminal", String::new(), None), + other => ( + "used", + other.to_owned(), + text("query").map(|query| format!("{query:?}")), + ), + }; + StepActivity { + verb: verb.to_owned(), + subject, + detail, + failure, + } +} + fn change_from( capability_id: &str, arguments: &Value, @@ -236,6 +455,58 @@ mod tests { } } + #[test] + fn activities_name_the_target_and_a_short_result() { + let search = activity_from( + "xgeny.fs/search-text", + Some(&json!({"path": "workspace:primary/src", "query": "likes"})), + Some(&json!({"matches": [{}, {}], "truncated": false})), + None, + ); + assert_eq!( + ( + search.verb.as_str(), + search.subject.as_str(), + search.detail.as_deref() + ), + ("searched", "\"likes\" in src", Some("2 matches")) + ); + let patch = activity_from( + APPLY_PATCH, + Some(&json!({"path": "workspace:primary/a.py", "edits": [ + {"oldText": "x\ny = 1", "newText": "x\ny = 2\nz = 3"}]})), + None, + None, + ); + assert_eq!(patch.detail.as_deref(), Some("+2 -1")); + let run = activity_from( + PROCESS_EXECUTE_CAPABILITY_ID, + Some( + &json!({"executable": "process:primary/executables/bash", "args": ["-c", "pytest"]}), + ), + Some(&json!({"exitCode": 1})), + None, + ); + assert_eq!( + ( + run.verb.as_str(), + run.subject.as_str(), + run.detail.as_deref() + ), + ("ran", "bash -c pytest", Some("exit 1")) + ); + let missing = activity_from( + "xgeny.fs/read-text", + Some(&json!({"path": "workspace:primary/gone.md"})), + None, + Some("not_found".to_owned()), + ); + assert_eq!( + (missing.subject.as_str(), missing.failure.as_deref()), + ("gone.md", Some("not_found")) + ); + } + #[test] fn edit_lines_drop_shared_context() { let shared = edit("a\nb\nc\n", "a\nB\nc\n"); diff --git a/crates/xgen-cli/src/lib.rs b/crates/xgen-cli/src/lib.rs index e2d4d91..e7eb311 100644 --- a/crates/xgen-cli/src/lib.rs +++ b/crates/xgen-cli/src/lib.rs @@ -18,7 +18,10 @@ mod run_layout; pub mod tools; mod usage; -pub use change_report::{CommittedChange, TextEdit, edit_lines, inspect_local_changes}; +pub use change_report::{ + CommittedChange, StepActivity, TextEdit, edit_lines, inspect_local_changes, + inspect_receipt_count, inspect_step_activities, +}; pub use composition::*; pub use driver::*; pub use manifest::MAX_HOST_MODEL_TURNS; diff --git a/crates/xgen-cli/src/main.rs b/crates/xgen-cli/src/main.rs index 4224921..d8e14fa 100644 --- a/crates/xgen-cli/src/main.rs +++ b/crates/xgen-cli/src/main.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::env; use std::io::{BufRead as _, ErrorKind, IsTerminal as _, Read as _, Write as _}; use std::path::PathBuf; @@ -609,6 +609,36 @@ impl InteractiveHost { } } +/// Forward what each finished Step did once its effect commits, read back from the verified +/// journal (ADR-0070). Progress events themselves stay redacted (ADR-0033). +fn with_activity<'a>( + run_id: &'a RefCell>, + reported: &'a Cell, + progress: &'a mut dyn FnMut(DriverProgress) -> DriverProgressControl, + activity: &'a mut dyn FnMut(xgen_cli::StepActivity), +) -> impl FnMut(DriverProgress) -> DriverProgressControl + 'a { + move |event| { + // A Receipt lands at effect commit or, for a verified effect, at verification commit. + if matches!( + event, + DriverProgress::EffectCommitted { .. } + | DriverProgress::VerificationCommitted + | DriverProgress::ModelCallStarting + | DriverProgress::CompletionCommitted + | DriverProgress::ResponseCommitted + ) && reported.get() != usize::MAX + && let Some(run_id) = run_id.borrow().as_deref() + && let Ok(activities) = xgen_cli::inspect_step_activities(run_id, reported.get()) + { + reported.set(reported.get() + activities.len()); + for finished in activities { + activity(finished); + } + } + progress(event) + } +} + impl repl::ReplHost for InteractiveHost { fn model(&mut self) -> Result { Self::selected_model_view() @@ -656,6 +686,7 @@ impl repl::ReplHost for InteractiveHost { goal: String, grants: repl::InvocationGrants, progress: &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + activity: &mut dyn FnMut(xgen_cli::StepActivity), ) -> Result { let model = resolve_model(None, None, None, None, false, RequestOptionArgs::default()) .map_err(|error| repl::ReplFailure::new(error.code()))?; @@ -666,6 +697,8 @@ impl repl::ReplHost for InteractiveHost { } _ => goal, }; + let started_run = RefCell::new(None); + let reported = Cell::new(0); run_interactive_with_process_session_progress( LocalRunRequest { goal, @@ -694,11 +727,12 @@ impl repl::ReplHost for InteractiveHost { }, &process_session, |run_id| { + started_run.replace(Some(run_id.to_owned())); if self.detailed_progress { eprintln!("XGEN_STARTED run_id={run_id}"); } }, - progress, + with_activity(&started_run, &reported, progress, activity), ) .map_err(repl::ReplFailure::from_run_error) } @@ -708,8 +742,12 @@ impl repl::ReplHost for InteractiveHost { run_id: &str, grants: repl::InvocationGrants, progress: &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + activity: &mut dyn FnMut(xgen_cli::StepActivity), ) -> Result { let process_session = self.process_session.clone(); + let resumed_run = RefCell::new(Some(run_id.to_owned())); + let reported = Cell::new(xgen_cli::inspect_receipt_count(run_id).unwrap_or(usize::MAX)); + let progress = with_activity(&resumed_run, &reported, progress, activity); let request = LocalResumeRequest { run_id: run_id.to_owned(), workspace: Some(self.workspace.clone()), diff --git a/crates/xgen-cli/src/material_catalog.rs b/crates/xgen-cli/src/material_catalog.rs index fe23d86..ffed393 100644 --- a/crates/xgen-cli/src/material_catalog.rs +++ b/crates/xgen-cli/src/material_catalog.rs @@ -264,7 +264,7 @@ impl RunMaterialCatalog { } /// Arguments of a process, terminal, or web search Step, bound to its material digest. - fn process_family_arguments( + pub(crate) fn process_family_arguments( &self, reference: &ReconstructableMaterialReference, step_id: &str, diff --git a/crates/xgen-cli/src/repl.rs b/crates/xgen-cli/src/repl.rs index ee1652b..16a1f08 100644 --- a/crates/xgen-cli/src/repl.rs +++ b/crates/xgen-cli/src/repl.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use xgen_cli::{ CommittedChange, DriverProgress, DriverProgressControl, LocalCommandResult, PauseReason, - RejectionReason, edit_lines, + RejectionReason, StepActivity, edit_lines, }; use xgen_runtime::AgentLoopQuiescence; @@ -237,6 +237,7 @@ pub(crate) trait ReplHost { goal: String, grants: InvocationGrants, progress: &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + activity: &mut dyn FnMut(StepActivity), ) -> Result; fn resume( @@ -244,6 +245,7 @@ pub(crate) trait ReplHost { run_id: &str, grants: InvocationGrants, progress: &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + activity: &mut dyn FnMut(StepActivity), ) -> Result; } @@ -605,7 +607,7 @@ pub(crate) fn run_with_display( cancellation, interactive_display, &mut tally, - |progress| host.resume(&run_id, grants, progress), + |progress, activity| host.resume(&run_id, grants, progress, activity), )?; handle_result( reader, @@ -661,7 +663,7 @@ pub(crate) fn run_with_display( cancellation, interactive_display, &mut tally, - |progress| host.start(contextual_goal, grants, progress), + |progress, activity| host.start(contextual_goal, grants, progress, activity), )?; handle_result( reader, @@ -795,7 +797,7 @@ fn handle_result( cancellation, interactive_display, &mut leg, - |progress| host.resume(&run_id, grants, progress), + |progress, activity| host.resume(&run_id, grants, progress, activity), )?; tally.committed_steps = tally.committed_steps.saturating_add(leg.committed_steps); tally.elapsed = tally.elapsed.saturating_add(leg.elapsed); @@ -952,31 +954,40 @@ fn invoke_with_progress( where F: FnOnce( &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + &mut dyn FnMut(StepActivity), ) -> Result, { if interactive_display { return invoke_with_thinking(output, cancellation, tally, invoke); } let started = Instant::now(); - let mut output_failure = None; + let output = std::cell::RefCell::new(output); + let output_failure = std::cell::RefCell::new(None); let mut progress = |event| { if matches!(event, DriverProgress::EffectCommitted { .. }) { tally.committed_steps = tally.committed_steps.saturating_add(1); } - if output_failure.is_none() - && let Err(error) = print_progress(output, event) + if output_failure.borrow().is_none() + && let Err(error) = print_progress(&mut **output.borrow_mut(), event) { - output_failure = Some(error); + *output_failure.borrow_mut() = Some(error); } - if cancellation.is_requested() || output_failure.is_some() { + if cancellation.is_requested() || output_failure.borrow().is_some() { DriverProgressControl::Cancel } else { DriverProgressControl::Continue } }; - let result = invoke(&mut progress); + let mut activity = |activity: StepActivity| { + if output_failure.borrow().is_none() + && let Err(error) = write_activity(&mut **output.borrow_mut(), &activity, false) + { + *output_failure.borrow_mut() = Some(error); + } + }; + let result = invoke(&mut progress, &mut activity); tally.elapsed = started.elapsed(); - if let Some(error) = output_failure { + if let Some(error) = output_failure.into_inner() { return Err(error); } Ok(result) @@ -1073,6 +1084,7 @@ fn invoke_with_thinking( where F: FnOnce( &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + &mut dyn FnMut(StepActivity), ) -> Result, { let started = Instant::now(); @@ -1123,7 +1135,21 @@ where DriverProgressControl::Continue } }; - let result = invoke(&mut progress); + let mut activity = |activity: StepActivity| { + // The live line is repainted on the display thread's next tick. + let written = output + .lock() + .map_err(|_| io::Error::other("display unavailable")) + .and_then(|mut output| { + write!(output, "\r\x1b[2K")?; + write_activity(&mut *output, &activity, true)?; + output.flush() + }); + if written.is_err() { + failed.store(true, Ordering::SeqCst); + } + }; + let result = invoke(&mut progress, &mut activity); let _ = stop.send(()); display .join() @@ -1274,6 +1300,31 @@ fn print_changes( Ok(()) } +/// One line of the live activity log: what a finished Step did (ADR-0070). +fn write_activity( + output: &mut impl Write, + activity: &StepActivity, + styled: bool, +) -> io::Result<()> { + let (dim, plain) = if styled { (DIM, PLAIN) } else { ("", "") }; + write!(output, " ")?; + write_terminal_line(output, &activity.verb)?; + if !activity.subject.is_empty() { + write!(output, " ")?; + write_terminal_line(output, &bounded_command(&activity.subject))?; + } + if let Some(failure) = &activity.failure { + write!(output, " {dim}failed: ")?; + write_terminal_line(output, failure)?; + write!(output, "{plain}")?; + } else if let Some(detail) = &activity.detail { + write!(output, " {dim}(")?; + write_terminal_line(output, detail)?; + write!(output, "){plain}")?; + } + writeln!(output) +} + const MAX_CHANGE_COMMAND_CHARS: usize = 120; /// A command line cut to one readable line; the full arguments stay in the Run journal. @@ -2117,6 +2168,7 @@ mod tests { goal: String, grants: InvocationGrants, progress: &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + _activity: &mut dyn FnMut(StepActivity), ) -> Result { self.starts.push((goal, grants)); if progress(DriverProgress::ModelCallStarting) == DriverProgressControl::Cancel { @@ -2133,6 +2185,7 @@ mod tests { run_id: &str, grants: InvocationGrants, progress: &mut dyn FnMut(DriverProgress) -> DriverProgressControl, + _activity: &mut dyn FnMut(StepActivity), ) -> Result { self.resumes.push((run_id.to_owned(), grants)); let _ = progress(DriverProgress::EffectStarting { @@ -2287,7 +2340,7 @@ mod tests { &cancellation, true, &mut RunTally::default(), - |progress| { + |progress, _| { cancellation.request(); assert_eq!( progress(DriverProgress::ModelCallStarting), @@ -2334,7 +2387,7 @@ mod tests { &cancellation, true, &mut RunTally::default(), - |progress| { + |progress, _| { progress(DriverProgress::EffectCommitted { effect_class: xgen_domain::EffectClass::Idempotent, }); @@ -2386,7 +2439,7 @@ mod tests { &cancellation, true, &mut RunTally::default(), - |_| { + |_, _| { ticks .recv_timeout(Duration::from_secs(5)) .expect("display should refresh during blocking I/O"); diff --git a/crates/xgen-cli/tests/interactive_repl.rs b/crates/xgen-cli/tests/interactive_repl.rs index bfdddff..f109156 100644 --- a/crates/xgen-cli/tests/interactive_repl.rs +++ b/crates/xgen-cli/tests/interactive_repl.rs @@ -187,6 +187,17 @@ fn bare_xgen_streams_durable_progress_prompts_separate_approvals_and_replays_off assert!(stdout.contains(progress), "missing {progress}: {stdout}"); } assert_eq!(stdout.matches(COMPLETION).count(), 2, "{stdout}"); + // ADR-0070: each finished Step is listed from the journal while the Run is in flight. + assert_eq!( + stdout.matches("\n read README.md\n").count(), + 1, + "{stdout}" + ); + assert_eq!( + stdout.matches("\n ran git --version (exit 0)\n").count(), + 1, + "{stdout}" + ); assert!(stdout.contains("status: completed")); assert!(!stdout.contains("session cleared")); assert!(stderr.contains("XGEN_STARTED run_id=")); diff --git a/docs/adr/0070-live-step-activity.md b/docs/adr/0070-live-step-activity.md new file mode 100644 index 0000000..1291106 --- /dev/null +++ b/docs/adr/0070-live-step-activity.md @@ -0,0 +1,41 @@ +# ADR-0070: 진행 중에 끝난 Step의 대상과 결과를 한 줄씩 보여 준다 + +- 상태: Accepted +- 기준일: 2026-10-27 +- 변경 분류: 범용 REPL 표시. protocol·journal·planning context 변경 없음. + +## 문제 + +대화형 `xgen`은 Run이 끝날 때까지 `Reading… · 12s · 3 committed` 한 줄만 보였다. 어떤 파일을 읽고 어떤 명령을 +실행하는지는 Run이 끝난 뒤 `Changes` 블록(ADR-0057)에서 쓰기와 실행만 볼 수 있었다. 읽기·검색은 끝까지 보이지 +않았다. 40~100단계짜리 작업에서 사용자는 모델이 같은 파일을 반복해 읽는지, 다른 길로 가는지 알 수 없었다. +Claude Code·Codex는 도구 호출을 하나씩 화면에 남긴다. + +## 결정 + +REPL은 Step이 끝날 때마다 그 Step이 한 일을 한 줄로 남긴다. + +``` + searched "likes" in . (6 matches) + read devhub/types.py + patched devhub/types.py (+1 -1) + ran bash -c .venv/bin/pytest -q tests/test_types.py (exit 0) + read gone.md failed: not_found +``` + +- 출처는 ADR-0057과 같은 검증된 기록이다. Receipt, 승인된 material recipe의 정규화 인자, receipt가 끝난 tool + output, Step의 `failureCode`를 쓴다. 모델의 objective 문장은 쓰지 않는다. +- `DriverProgress`는 그대로 경로·인자 없는 고정 이벤트다(ADR-0033). REPL host가 이벤트를 받을 때 run journal을 + read-only로 열어, 아직 보고하지 않은 Receipt만 읽는다. 실행 Step의 Receipt는 검증 뒤에 붙으므로, effect commit + 말고 검증 commit, 다음 model call, 완료 때도 확인한다. +- resume는 그때까지의 Receipt 수부터 센다. 이미 보고한 Step을 다시 찍지 않는다. +- 인자를 복원하지 못한 Step은 capability 이름만 보인다. 표시에 실패해도 Run은 계속된다. +- 모든 문자열은 제어 문자를 escape하고, 명령은 120자로 자른다. Run 끝의 `Changes` 블록은 그대로다. +- `--debug`의 `progress:` 줄과 pipe 입력 모드에도 같은 줄이 찍힌다. + +## 확인 + +- e2e: README 읽기와 `git --version` 실행이 각각 `read README.md`, `ran git --version (exit 0)` 한 번씩 찍힌다. +- 단위: 검색·patch·실행·실패 Step의 표시 문자열. +- tmux 실사용(deepseek flash, --trust): 검색, 읽기, patch, pytest 실행이 끝나는 대로 한 줄씩 쌓이고, 아래 live + line이 계속 다시 그려진다.