diff --git a/ROADMAP.md b/ROADMAP.md index 9689735a..c8085dfa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7717,3 +7717,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 774. **`claw agents bogus`, `claw plugins bogus`, `claw mcp bogus` returned `hint: null`** — dogfooded 2026-05-27 on `727a1ea4`. Three "unknown subcommand" envelopes had `error_kind` correctly set but `hint: null`: (1) `unknown_agents_subcommand` — both text and JSON handler emitted single-line error with inline remediation after `.`, no `\n`; (2) `unknown_plugins_action` — same, period-delimited remediation; (3) `unknown_mcp_action` — `render_mcp_usage_json` never included a `hint` field at all. Fixes: (1)+(2) added `\n` before remediation suffix in `commands/src/lib.rs`; (3) added `hint` field to `render_mcp_usage_json` pointing at supported actions. All three now return non-null `hint`. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori envelope-consistency probe on `727a1ea4`, 2026-05-27. 775. **Missing integration tests for #769-#771 interactive-only guards and #774 hint fields** — dogfooded 2026-05-27 on `c760a49c`. Fixes #769-#771 (session/cost/clear/memory/ultraplan/model/usage/stats/fork interactive-only guards) and #774 (agents/plugins/mcp unknown-subcommand hints) had no integration tests — a regression in any of those 10+ match arms would go undetected. Also: classify_error_kind unit test for `unknown_agents_subcommand` used the old single-line format string, not the `\n`-delimited format emitted after #774. Fixed: (1) updated unit test string to match new `\n`-delimited emission; (2) added `agents_plugins_mcp_unknown_subcommand_have_hint_774` asserting `error_kind` + non-null `hint` for all three; (3) added `interactive_only_guard_batch_769_to_771` asserting `interactive_only` + non-null `hint` for 10 cases. 38 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori test-coverage sweep on `c760a49c`, 2026-05-27. + +776. **Resume-mode JSON errors had opaque `error_kind:"resume_command_error"` + `hint:null`** — dogfooded 2026-05-27 on `028998d0` (pinpoint identified by Gaebal-gajae). `run_resume_command` returned errors (e.g. from `parse_history_count`) with hardcoded `error_kind:"resume_command_error"` and the full error string in `error` with no hint extraction. Wrappers had to regex prose instead of switching on typed fields. Three co-located gaps fixed: (1) `resume_session` JSON error path now applies `classify_error_kind` + `split_error_hint` so errors get specific `error_kind` (e.g. `invalid_history_count`) and non-null `hint`; (2) `parse_history_count` errors now use `invalid_history_count:` prefix + `\n` usage hint; (3) `/session exists|delete|switch|fork` missing-arg and unsupported-action errors now use `\n`-delimited format with `unsupported_resumed_command:` prefix. Existing test updated to match new error message format. 38 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `028998d0`, 2026-05-27. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index bffbf190..18dbf8bf 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -323,6 +323,8 @@ fn classify_error_kind(message: &str) -> &'static str { "unsupported_config_section" } else if message.contains("unknown_plugins_action") { "unknown_plugins_action" + } else if message.starts_with("invalid_history_count:") || message.contains("invalid count") { + "invalid_history_count" } else if message.starts_with("missing_prompt:") { "missing_prompt" } else if message.contains("has been removed.") { @@ -3370,14 +3372,20 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu } Err(error) => { if output_format == CliOutputFormat::Json { + // #776: classify + split so wrappers get typed fields instead of + // hardcoded "resume_command_error" + prose in the error field + let full_error = error.to_string(); + let error_kind = classify_error_kind(&full_error); + let (short_reason, hint) = split_error_hint(&full_error); eprintln!( "{}", serde_json::json!({ - "kind": "resume_command_error", + "kind": error_kind, "action": "resume", "status": "error", - "error_kind": "resume_command_error", - "error": error.to_string(), + "error_kind": error_kind, + "error": short_reason, + "hint": hint, "exit_code": 2, "command": raw_command, }) @@ -6847,7 +6855,7 @@ fn run_resumed_session_command( } Some("exists") => { let Some(target) = target else { - return Err("/session exists requires a session id".into()); + return Err("/session exists requires a session id.\nUsage: claw --resume /session exists ".into()); }; let value = session_exists_json(target, &session.session_id)?; let exists = value @@ -6866,7 +6874,7 @@ fn run_resumed_session_command( } Some("delete") => { let Some(target) = target else { - return Err("/session delete requires a session id".into()); + return Err("/session delete requires a session id.\nUsage: claw --resume /session delete --force".into()); }; Ok(ResumeCommandOutcome { session: session.clone(), @@ -6883,7 +6891,7 @@ fn run_resumed_session_command( } Some("delete-force") => { let Some(target) = target else { - return Err("/session delete requires a session id".into()); + return Err("/session delete requires a session id.\nUsage: claw --resume /session delete --force".into()); }; let handle = resolve_session_reference(target)?; if handle.id == session.session_id || handle.path == session_path { @@ -6911,8 +6919,8 @@ fn run_resumed_session_command( })), }) } - Some("switch" | "fork") => Err("unsupported resumed slash command".into()), - Some(other) => Err(format!("unsupported resumed /session action: {other}").into()), + Some("switch" | "fork") => Err("unsupported_resumed_command: /session switch and /session fork require an interactive REPL.\nUsage: claw (then /session switch ) or claw --resume ".into()), + Some(other) => Err(format!("unsupported_resumed_command: /session {other} is not supported in resume mode.\nSupported: list, exists, delete").into()), } } @@ -8413,11 +8421,12 @@ fn parse_history_count(raw: Option<&str>) -> Result { let Some(raw) = raw else { return Ok(DEFAULT_HISTORY_LIMIT); }; + // #776: use \n-delimited format so split_error_hint extracts hint into JSON envelopes let parsed: usize = raw .parse() - .map_err(|_| format!("history: invalid count '{raw}'. Expected a positive integer."))?; + .map_err(|_| format!("invalid_history_count: '{raw}' is not a positive integer.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})"))?; if parsed == 0 { - return Err("history: count must be greater than 0.".to_string()); + return Err(format!("invalid_history_count: count must be greater than 0.\nUsage: /history [count] (default: {DEFAULT_HISTORY_LIMIT})")); } Ok(parsed) } @@ -15148,8 +15157,9 @@ UU conflicted.rs", let parsed = parse_history_count(raw); // then - assert!(parsed.is_err()); - assert!(parsed.unwrap_err().contains("invalid count 'abc'")); + // #776: updated to match new invalid_history_count: prefix format + let err = parsed.expect_err("non-numeric count should fail"); + assert!(err.contains("invalid_history_count:") && err.contains("'abc'")); } #[test]