diff --git a/ROADMAP.md b/ROADMAP.md index ff5eca2..2873050 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -311,6 +311,8 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = 22. **Opaque failure surface for session/runtime crashes** — repeated dogfood-facing failures can currently collapse to generic wrappers like `Something went wrong while processing your request. Please try again, or use /new to start a fresh session.` without exposing whether the fault was provider auth, session corruption, slash-command dispatch, render failure, or transport/runtime panic. This blocks fast self-recovery and turns actionable clawability bugs into blind retries. **Action:** preserve a short user-safe failure class (`provider_auth`, `session_load`, `command_dispatch`, `render`, `runtime_panic`, etc.), attach a local trace/session id, and ensure operators can jump from the chat-visible error to the exact failure log quickly. 23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`. 24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution. +26. **Resumed `/sandbox` JSON parity gap** — **done**: direct `claw sandbox --output-format json` already emitted structured JSON, but resumed `claw --output-format json --resume /sandbox` still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. The resumed `/sandbox` path now reuses the same JSON envelope as the direct CLI command, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs`. +27. **Resumed inventory JSON parity gap for `/mcp` and `/skills`** — **done**: resumed slash-command inventory calls now honor `--output-format json` via the same structured renderers as direct `claw mcp` / `claw skills`, with regression coverage for resumed `list` output under an isolated config home. **P3 — Swarm efficiency** 13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation 14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 6d2c9b7..3a42f71 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1688,23 +1688,25 @@ fn print_system_prompt( } fn print_version(output_format: CliOutputFormat) -> Result<(), Box> { - let report = render_version_report(); match output_format { - CliOutputFormat::Text => println!("{report}"), - CliOutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(&json!({ - "kind": "version", - "message": report, - "version": VERSION, - "git_sha": GIT_SHA, - "target": BUILD_TARGET, - }))? - ), + CliOutputFormat::Text => println!("{}", render_version_report()), + CliOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&version_json_value())?); + } } Ok(()) } +fn version_json_value() -> serde_json::Value { + json!({ + "kind": "version", + "message": render_version_report(), + "version": VERSION, + "git_sha": GIT_SHA, + "target": BUILD_TARGET, + }) +} + fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { let resolved_path = if session_path.exists() { session_path.to_path_buf() @@ -1752,33 +1754,21 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu Ok(ResumeCommandOutcome { session: next_session, message, + json, }) => { session = next_session; - if let Some(message) = message { - if output_format == CliOutputFormat::Json - && matches!(command, SlashCommand::Status) - { - let tracker = UsageTracker::from_session(&session); - let context = status_context(Some(&resolved_path)).expect("status context"); - let value = status_json_value( - "restored-session", - StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: tracker.cumulative_usage(), - estimated_tokens: 0, - }, - default_permission_mode().as_str(), - &context, - ); + if output_format == CliOutputFormat::Json { + if let Some(value) = json { println!( "{}", - serde_json::to_string_pretty(&value).expect("status json") + serde_json::to_string_pretty(&value) + .expect("resume command json output") ); - } else { + } else if let Some(message) = message { println!("{message}"); } + } else if let Some(message) = message { + println!("{message}"); } } Err(error) => { @@ -1793,6 +1783,7 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu struct ResumeCommandOutcome { session: Session, message: Option, + json: Option, } #[derive(Debug, Clone)] @@ -2127,6 +2118,7 @@ fn run_resume_command( SlashCommand::Help => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_repl_help()), + json: None, }), SlashCommand::Compact => { let result = runtime::compact_session( @@ -2143,6 +2135,7 @@ fn run_resume_command( Ok(ResumeCommandOutcome { session: result.compacted_session, message: Some(format_compact_report(removed, kept, skipped)), + json: None, }) } SlashCommand::Clear { confirm } => { @@ -2152,6 +2145,7 @@ fn run_resume_command( message: Some( "clear: confirmation required; rerun with /clear --confirm".to_string(), ), + json: None, }); } let backup_path = write_session_clear_backup(session, session_path)?; @@ -2167,11 +2161,13 @@ fn run_resume_command( backup_path.display(), session_path.display() )), + json: None, }) } SlashCommand::Status => { let tracker = UsageTracker::from_session(session); let usage = tracker.cumulative_usage(); + let context = status_context(Some(session_path))?; Ok(ResumeCommandOutcome { session: session.clone(), message: Some(format_status_report( @@ -2184,7 +2180,19 @@ fn run_resume_command( estimated_tokens: 0, }, default_permission_mode().as_str(), - &status_context(Some(session_path))?, + &context, + )), + json: Some(status_json_value( + "restored-session", + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &context, )), }) } @@ -2192,12 +2200,11 @@ fn run_resume_command( let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; + let status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(format_sandbox_report(&resolve_sandbox_status( - runtime_config.sandbox(), - &cwd, - ))), + message: Some(format_sandbox_report(&status)), + json: Some(sandbox_json_value(&status)), }) } SlashCommand::Cost => { @@ -2205,11 +2212,13 @@ fn run_resume_command( Ok(ResumeCommandOutcome { session: session.clone(), message: Some(format_cost_report(usage)), + json: None, }) } SlashCommand::Config { section } => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_config_report(section.as_deref())?), + json: None, }), SlashCommand::Mcp { action, target } => { let cwd = env::current_dir()?; @@ -2222,25 +2231,33 @@ fn run_resume_command( Ok(ResumeCommandOutcome { session: session.clone(), message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_mcp_slash_command_json(args.as_deref(), &cwd)?), }) } SlashCommand::Memory => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_memory_report()?), + json: None, }), - SlashCommand::Init => Ok(ResumeCommandOutcome { - session: session.clone(), - message: Some(init_claude_md()?), - }), + SlashCommand::Init => { + let message = init_claude_md()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(message.clone()), + json: Some(init_json_value(&message)), + }) + } SlashCommand::Diff => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_diff_report_for( session_path.parent().unwrap_or_else(|| Path::new(".")), )?), + json: None, }), SlashCommand::Version => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_version_report()), + json: Some(version_json_value()), }), SlashCommand::Export { path } => { let export_path = resolve_export_path(path.as_deref(), session)?; @@ -2252,6 +2269,7 @@ fn run_resume_command( export_path.display(), session.messages.len(), )), + json: None, }) } SlashCommand::Agents { args } => { @@ -2259,6 +2277,7 @@ fn run_resume_command( Ok(ResumeCommandOutcome { session: session.clone(), message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + json: None, }) } SlashCommand::Skills { args } => { @@ -2266,11 +2285,13 @@ fn run_resume_command( Ok(ResumeCommandOutcome { session: session.clone(), message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?), }) } SlashCommand::Doctor => Ok(ResumeCommandOutcome { session: session.clone(), message: Some(render_doctor_report()?.render()), + json: None, }), SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), SlashCommand::Bughunter { .. } @@ -4259,27 +4280,31 @@ fn print_sandbox_status_snapshot( CliOutputFormat::Text => println!("{}", format_sandbox_report(&status)), CliOutputFormat::Json => println!( "{}", - serde_json::to_string_pretty(&json!({ - "kind": "sandbox", - "enabled": status.enabled, - "active": status.active, - "supported": status.supported, - "in_container": status.in_container, - "requested_namespace": status.requested.namespace_restrictions, - "active_namespace": status.namespace_active, - "requested_network": status.requested.network_isolation, - "active_network": status.network_active, - "filesystem_mode": status.filesystem_mode.as_str(), - "filesystem_active": status.filesystem_active, - "allowed_mounts": status.allowed_mounts, - "markers": status.container_markers, - "fallback_reason": status.fallback_reason, - }))? + serde_json::to_string_pretty(&sandbox_json_value(&status))? ), } Ok(()) } +fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value { + json!({ + "kind": "sandbox", + "enabled": status.enabled, + "active": status.active, + "supported": status.supported, + "in_container": status.in_container, + "requested_namespace": status.requested.namespace_restrictions, + "active_namespace": status.namespace_active, + "requested_network": status.requested.network_isolation, + "active_network": status.network_active, + "filesystem_mode": status.filesystem_mode.as_str(), + "filesystem_active": status.filesystem_active, + "allowed_mounts": status.allowed_mounts, + "markers": status.container_markers, + "fallback_reason": status.fallback_reason, + }) +} + fn render_help_topic(topic: LocalHelpTopic) -> String { match topic { LocalHelpTopic::Status => "Status @@ -4436,15 +4461,19 @@ fn run_init(output_format: CliOutputFormat) -> Result<(), Box println!("{message}"), CliOutputFormat::Json => println!( "{}", - serde_json::to_string_pretty(&json!({ - "kind": "init", - "message": message, - }))? + serde_json::to_string_pretty(&init_json_value(&message))? ), } Ok(()) } +fn init_json_value(message: &str) -> serde_json::Value { + json!({ + "kind": "init", + "message": message, + }) +} + fn normalize_permission_mode(mode: &str) -> Option<&'static str> { match mode.trim() { "read-only" => Some("read-only"), diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs index 03c1c5c..d0eb809 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -259,6 +259,104 @@ fn doctor_and_resume_status_emit_json_when_requested() { assert!(resumed["sandbox"]["filesystem_mode"].as_str().is_some()); } +#[test] +fn resumed_inventory_commands_emit_structured_json_when_requested() { + let root = unique_temp_dir("resume-inventory-json"); + let config_home = root.join("config-home"); + let home = root.join("home"); + fs::create_dir_all(&config_home).expect("config home should exist"); + fs::create_dir_all(&home).expect("home should exist"); + + let session_path = root.join("session.jsonl"); + fs::write( + &session_path, + "{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-inventory-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"inventory\"}]}}\n", + ) + .expect("session should write"); + + let mcp = assert_json_command_with_env( + &root, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 session path"), + "/mcp", + ], + &[ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ], + ); + assert_eq!(mcp["kind"], "mcp"); + assert_eq!(mcp["action"], "list"); + assert!(mcp["servers"].is_array()); + + let skills = assert_json_command_with_env( + &root, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 session path"), + "/skills", + ], + &[ + ( + "CLAW_CONFIG_HOME", + config_home.to_str().expect("utf8 config home"), + ), + ("HOME", home.to_str().expect("utf8 home")), + ], + ); + assert_eq!(skills["kind"], "skills"); + assert_eq!(skills["action"], "list"); + assert!(skills["summary"]["total"].is_number()); + assert!(skills["skills"].is_array()); +} + +#[test] +fn resumed_version_and_init_emit_structured_json_when_requested() { + let root = unique_temp_dir("resume-version-init-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let session_path = root.join("session.jsonl"); + fs::write( + &session_path, + "{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-version-init-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n", + ) + .expect("session should write"); + + let version = assert_json_command( + &root, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 session path"), + "/version", + ], + ); + assert_eq!(version["kind"], "version"); + assert_eq!(version["version"], env!("CARGO_PKG_VERSION")); + + let init = assert_json_command( + &root, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 session path"), + "/init", + ], + ); + assert_eq!(init["kind"], "init"); + assert!(root.join("CLAUDE.md").exists()); +} + fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value { assert_json_command_with_env(current_dir, args, &[]) } diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs index 273cfa7..640765d 100644 --- a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -275,6 +275,49 @@ fn resumed_status_command_emits_structured_json_when_requested() { assert!(parsed["sandbox"]["filesystem_mode"].as_str().is_some()); } +#[test] +fn resumed_sandbox_command_emits_structured_json_when_requested() { + // given + let temp_dir = unique_temp_dir("resume-sandbox-json"); + fs::create_dir_all(&temp_dir).expect("temp dir should exist"); + let session_path = temp_dir.join("session.jsonl"); + + Session::new() + .save_to_path(&session_path) + .expect("session should persist"); + + // when + let output = run_claw( + &temp_dir, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 path"), + "/sandbox", + ], + ); + + // then + assert!( + output.status.success(), + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let parsed: Value = + serde_json::from_str(stdout.trim()).expect("resume sandbox output should be json"); + assert_eq!(parsed["kind"], "sandbox"); + assert!(parsed["enabled"].is_boolean()); + assert!(parsed["active"].is_boolean()); + assert!(parsed["supported"].is_boolean()); + assert!(parsed["filesystem_mode"].as_str().is_some()); + assert!(parsed["allowed_mounts"].is_array()); + assert!(parsed["markers"].is_array()); +} + fn run_claw(current_dir: &Path, args: &[&str]) -> Output { run_claw_with_env(current_dir, args, &[]) }