diff --git a/ROADMAP.md b/ROADMAP.md index f7111c6d..dbb89406 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7731,3 +7731,5 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 781. **`api_http_error` was a single bucket for all HTTP errors; 401 auth and 429 rate-limit returned `hint:null` with no distinction** — dogfooded 2026-05-27 on `d9844cfe`. `classify_error_kind` had a single `api_http_error` arm for all API failures. 401 Unauthorized and 429 rate-limit errors emitted `error_kind:"api_http_error"` + `hint:null`, making it impossible for automation to distinguish auth misconfiguration from transient rate-limiting. Fixes: (1) added `api_auth_error` sub-classifier arm for 401/Unauthorized/authentication_error messages; (2) added `api_rate_limit_error` arm for 429/rate_limit messages; (3) added `fallback_hint_for_error_kind()` that derives a stable hint from the error kind when `split_error_hint` returns `None` (API layer never emits `\n`-delimited hints); (4) main JSON error emission path now calls `fallback_hint_for_error_kind` as fallback. Auth errors now return `api_auth_error` + env-var hint; rate-limit returns `api_rate_limit_error` + retry hint. Unit tests updated. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori API error opacity probe on `d9844cfe`, 2026-05-27. 782. **`claw acp start` returned `error_kind:"unsupported_acp_invocation"` + `hint:null` — remediation text was on same line** — dogfooded 2026-05-27 on `16c1117a` (pinpoint by Gaebal-gajae). The error message `"unsupported ACP invocation. Use `claw acp`, `claw acp serve`, `claw --acp`, or `claw -acp`."` had no `\n` delimiter, so `split_error_hint` returned `hint:null`. Automation could tell ACP was unsupported but could not read the remediation structurally. Fix: inserted a `\n` before the remediation text: `"unsupported ACP invocation. Use ... claw -acp.\nACP/Zed editor integration is currently a discoverability alias only; ..."`. Integration test `acp_unsupported_invocation_has_hint_782` added. 41 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `16c1117a`, 2026-05-27. + +783. **`claw --output-format json init` success envelope was missing `hint` field; idempotent re-init was not structurally detectable** — dogfooded 2026-05-27 on `32c9276f`. The init JSON envelope had no `hint` field (absent, not null), and no field to distinguish a fresh init from a re-init without checking `created.len() == 0`. Orchestrators had to inspect `created` array length to detect idempotent behavior. Fix: (1) added `hint` field to init JSON envelope — fresh path points at `CLAUDE.md + doctor`; idempotent path says "already initialised, run doctor"; (2) added `already_initialized: bool` field — `true` when `created` and `updated` are both empty (all artifacts skipped). Both test cases (fresh + re-init) covered by `init_json_envelope_has_hint_and_already_initialized_783`. 42 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori init-envelope probe on `32c9276f`, 2026-05-27. diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 268d7f44..118f42c6 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -8179,15 +8179,26 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso // Derive top-level status: "ok" when all artifacts succeeded (created or // skipped = idempotent); no failure path exists today so always "ok". let status = "ok"; + // #783: already_initialized lets orchestrators detect the idempotent case + // without checking created.len() == 0; hint gives a stable next-action pointer. + let already_initialized = report.artifacts_with_status(InitStatus::Created).is_empty() + && report.artifacts_with_status(InitStatus::Updated).is_empty(); + let hint = if already_initialized { + "Workspace already initialised. Run `claw doctor` to verify health, or edit CLAUDE.md to customise guidance." + } else { + "Review and tailor CLAUDE.md to your project, then run `claw doctor` to verify the workspace." + }; json!({ "kind": "init", "action": "init", "status": status, + "already_initialized": already_initialized, "project_path": report.project_root.display().to_string(), "created": report.artifacts_with_status(InitStatus::Created), "updated": report.artifacts_with_status(InitStatus::Updated), "skipped": report.artifacts_with_status(InitStatus::Skipped), "artifacts": report.artifact_json_entries(), + "hint": hint, "next_step": crate::init::InitReport::NEXT_STEP, "message": message, }) 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 739cb503..85a6b0bc 100644 --- a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -2364,3 +2364,75 @@ fn acp_unsupported_invocation_has_hint_782() { "hint should explain the discoverability-only status, got: {hint:?}" ); } + +#[test] +fn init_json_envelope_has_hint_and_already_initialized_783() { + // #783: claw --output-format json init was missing the hint field entirely. + // Also added already_initialized: bool so orchestrators can detect the idempotent + // case without checking created.len() == 0. + let root = unique_temp_dir("init-hint-783"); + fs::create_dir_all(&root).expect("temp dir"); + std::process::Command::new("git") + .args(["init", "-q"]) + .current_dir(&root) + .output() + .ok(); + + // Fresh init — already_initialized should be false, hint should mention CLAUDE.md + let output = run_claw(&root, &["--output-format", "json", "init"], &[]); + assert!(output.status.success(), "init should succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = if stdout.trim_start().starts_with('{') { + &*stdout + } else { + &*stderr + }; + let parsed: serde_json::Value = serde_json::from_str(raw.trim()).unwrap_or_else(|_| { + // multi-line JSON; find the whole block + serde_json::from_str(raw).expect("should emit valid JSON") + }); + + assert_eq!(parsed["status"], "ok", "init should succeed"); + assert!( + parsed.get("already_initialized").is_some(), + "init JSON must include already_initialized field (#783)" + ); + assert_eq!( + parsed["already_initialized"], false, + "first init: already_initialized must be false" + ); + let hint = parsed["hint"] + .as_str() + .expect("hint must be present and non-null (#783)"); + assert!(!hint.is_empty(), "hint must not be empty"); + assert!( + hint.contains("CLAUDE.md") || hint.contains("doctor"), + "fresh-init hint should mention CLAUDE.md or doctor, got: {hint:?}" + ); + + // Idempotent re-init — already_initialized should be true + let output2 = run_claw(&root, &["--output-format", "json", "init"], &[]); + assert!(output2.status.success(), "re-init should succeed"); + let stdout2 = String::from_utf8_lossy(&output2.stdout); + let stderr2 = String::from_utf8_lossy(&output2.stderr); + let raw2 = if stdout2.trim_start().starts_with('{') { + &*stdout2 + } else { + &*stderr2 + }; + let parsed2: serde_json::Value = serde_json::from_str(raw2.trim()) + .or_else(|_| serde_json::from_str(raw2)) + .expect("re-init should emit valid JSON"); + assert_eq!( + parsed2["already_initialized"], true, + "re-init: already_initialized must be true" + ); + let hint2 = parsed2["hint"] + .as_str() + .expect("hint must be present on re-init"); + assert!( + hint2.contains("already") || hint2.contains("doctor"), + "re-init hint should acknowledge workspace exists, got: {hint2:?}" + ); +}