Compare commits

..

1 Commits

Author SHA1 Message Date
Yeachan-Heo
e5d904edaf Keep skills and agents JSON success envelopes machine-checkable
Constraint: ROADMAP #458 requires top-level status on successful skills and agents JSON outputs without changing error semantics.
Confidence: high
Scope-risk: narrow
Directive: Preserve existing error envelopes and avoid unrelated LSP/MCP changes.
Tested: cargo fmt; cargo test -p commands; cargo build -p rusty-claude-cli --bin claw; rebuilt JSON status sweep for status/mcp/skills/agents/doctor/sandbox/init/system-prompt/version.
Not-tested: full workspace cargo test.
2026-05-25 06:32:34 +00:00
27 changed files with 264 additions and 5205 deletions

View File

@@ -11,21 +11,10 @@ set -euo pipefail
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"
cd "$repo_root" cd "$repo_root"
if [[ -x scripts/roadmap-check-ids.sh ]]; then
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
scripts/roadmap-check-ids.sh
fi
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
echo "pre-push: SKIP_CLAW_PRE_PUSH_BUILD=1 set; skipping cargo workspace build" >&2
exit 0
fi
if [[ ! -f rust/Cargo.toml ]]; then if [[ ! -f rust/Cargo.toml ]]; then
echo "pre-push: rust/Cargo.toml not found; skipping cargo workspace build" >&2 echo "pre-push: rust/Cargo.toml not found; skipping cargo workspace build" >&2
exit 0 exit 0
fi fi
build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked) echo "pre-push: cargo build --manifest-path rust/Cargo.toml --workspace" >&2
echo "pre-push: ${build_cmd[*]}" >&2 cargo build --manifest-path rust/Cargo.toml --workspace
"${build_cmd[@]}"

View File

@@ -21,8 +21,6 @@ on:
- PARITY.md - PARITY.md
- PHILOSOPHY.md - PHILOSOPHY.md
- ROADMAP.md - ROADMAP.md
- scripts/roadmap-*.sh
- tests/test_roadmap_helpers.py
- docs/** - docs/**
- rust/** - rust/**
pull_request: pull_request:
@@ -43,8 +41,6 @@ on:
- PARITY.md - PARITY.md
- PHILOSOPHY.md - PHILOSOPHY.md
- ROADMAP.md - ROADMAP.md
- scripts/roadmap-*.sh
- tests/test_roadmap_helpers.py
- docs/** - docs/**
- rust/** - rust/**
workflow_dispatch: workflow_dispatch:
@@ -76,10 +72,6 @@ jobs:
run: python .github/scripts/check_doc_source_of_truth.py run: python .github/scripts/check_doc_source_of_truth.py
- name: Check release policy docs and local links - name: Check release policy docs and local links
run: python .github/scripts/check_release_readiness.py run: python .github/scripts/check_release_readiness.py
- name: Check ROADMAP ids
run: scripts/roadmap-check-ids.sh
- name: Check ROADMAP helper behavior
run: python -m unittest discover -s tests -p test_roadmap_helpers.py
fmt: fmt:
name: cargo fmt name: cargo fmt

View File

@@ -33,41 +33,6 @@ cargo build --workspace
.\target\debug\claw.exe --help .\target\debug\claw.exe --help
``` ```
## Local pre-push build gate
Install the repository-local hook to catch stale compile errors before pushing:
```bash
git config core.hooksPath .github/hooks
```
This sets the repo's Git hook directory to `.github/hooks`; if you already use a
custom `core.hooksPath`, copy or chain `.github/hooks/pre-push` instead. The hook
runs the ROADMAP id guard, then runs
`cargo build --manifest-path rust/Cargo.toml --workspace --locked` from the
repository root. If you must bypass the cargo build for a docs-only push, set
`SKIP_CLAW_PRE_PUSH_BUILD=1`; the hook still runs the ROADMAP guard and prints
when the cargo-build escape hatch is used.
## ROADMAP id allocation
Before appending a new numeric ROADMAP entry, pull/rebase onto the latest
`main`, allocate the id from the file you are about to edit, and run the duplicate
id guard before pushing:
```bash
git pull --rebase
NEXT=$(scripts/roadmap-next-id.sh)
# append "${NEXT}. **...**" to ROADMAP.md
scripts/roadmap-check-ids.sh
```
The duplicate guard currently checks helper-era ids (`>=723`) by default so it
catches new optimistic-append collisions without failing on legacy numbered lists
already present in the historical roadmap. Use `scripts/roadmap-check-ids.sh
--min-id 1` for a strict whole-file audit after those legacy collisions are
cleaned up.
## Checks before opening a pull request ## Checks before opening a pull request
Run the smallest relevant tests for your change, then the broader checks when Run the smallest relevant tests for your change, then the broader checks when

View File

@@ -7561,225 +7561,3 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
697. **`claw plugins remove <name>` silently returns `status:"ok"` with exit 0 when the named plugin does not exist — no `not_found` error, no non-zero exit, no indication the operation was a no-op; sibling: `claw agents <unknown-subcommand>` returns `action:"help"` with exit 0 instead of a typed `unknown_subcommand` error** — dogfooded 2026-05-25 on `63a5a874`. Reproduction: `claw plugins remove nonexistent-plugin --output-format json </dev/null` returns `{"kind":"plugin","action":"remove","status":"ok","error":null,...}` and exits 0. No plugin named `nonexistent-plugin` exists. A caller cannot distinguish "plugin was successfully removed" from "plugin was never there" — both produce the same `status:"ok"` envelope. Sibling: `claw agents stop nonexistent-agent --output-format json </dev/null` returns `{"kind":"agents","action":"help","unexpected":"stop nonexistent-agent",...}` and exits 0 — unknown subcommand falls through to help output rather than a typed error with exit 1. **Required fix shape:** (a) `plugins remove` must check whether the named plugin exists before reporting success; emit `{"kind":"plugin","action":"remove","status":"error","error_kind":"plugin_not_found","plugin_name":"<name>"}` with exit 1 when the plugin is absent; (b) `agents <unknown>` must emit `{"kind":"agents","action":"error","error_kind":"unknown_subcommand","subcommand":"<token>","supported":["list","help"]}` with exit 1 instead of falling back to help output with exit 0; (c) add regression tests proving both paths exit 1 with typed error envelopes. **Why this matters:** idempotent-but-silent remove is fine for infrastructure tools with explicit idempotency contracts; claw has no such contract, and `status:"ok"` for a name-miss means automation cannot audit whether a remove actually ran vs was a no-op. Source: Jobdori dogfood on `63a5a874`, 2026-05-25. 697. **`claw plugins remove <name>` silently returns `status:"ok"` with exit 0 when the named plugin does not exist — no `not_found` error, no non-zero exit, no indication the operation was a no-op; sibling: `claw agents <unknown-subcommand>` returns `action:"help"` with exit 0 instead of a typed `unknown_subcommand` error** — dogfooded 2026-05-25 on `63a5a874`. Reproduction: `claw plugins remove nonexistent-plugin --output-format json </dev/null` returns `{"kind":"plugin","action":"remove","status":"ok","error":null,...}` and exits 0. No plugin named `nonexistent-plugin` exists. A caller cannot distinguish "plugin was successfully removed" from "plugin was never there" — both produce the same `status:"ok"` envelope. Sibling: `claw agents stop nonexistent-agent --output-format json </dev/null` returns `{"kind":"agents","action":"help","unexpected":"stop nonexistent-agent",...}` and exits 0 — unknown subcommand falls through to help output rather than a typed error with exit 1. **Required fix shape:** (a) `plugins remove` must check whether the named plugin exists before reporting success; emit `{"kind":"plugin","action":"remove","status":"error","error_kind":"plugin_not_found","plugin_name":"<name>"}` with exit 1 when the plugin is absent; (b) `agents <unknown>` must emit `{"kind":"agents","action":"error","error_kind":"unknown_subcommand","subcommand":"<token>","supported":["list","help"]}` with exit 1 instead of falling back to help output with exit 0; (c) add regression tests proving both paths exit 1 with typed error envelopes. **Why this matters:** idempotent-but-silent remove is fine for infrastructure tools with explicit idempotency contracts; claw has no such contract, and `status:"ok"` for a name-miss means automation cannot audit whether a remove actually ran vs was a no-op. Source: Jobdori dogfood on `63a5a874`, 2026-05-25.
698. **Config deprecation warnings emit once per `ConfigLoader::load()` call, so surfaces that call `load()` multiple times in a single invocation emit duplicate `warning:` lines to stderr — `claw plugins list` and `claw mcp list` each print the same deprecation warning twice** — dogfooded 2026-05-25 on `c345ce6d`. Reproduction: `echo '{"enabledPlugins": {}}' > ~/.claw/settings.json && claw plugins list 2>&1 | grep warning` prints the same `field "enabledPlugins" is deprecated. Use "plugins.enabled" instead` line twice. Root cause: `config.rs:304` emits `eprintln!("warning: {warning}")` for every warning in every `loader.load()` call; surfaces like `plugins_command_payload_for` and `render_mcp_report_json_for` each trigger an independent `loader.load()` (one for runtime config, one inside the command handler), multiplying the stderr output. `skills list` emits only one warning because its command path calls `load()` once; `plugins` and `mcp` emit two. **Required fix shape:** (a) track already-emitted warning strings in a process-lifetime `std::sync::OnceLock<Mutex<HashSet<String>>>` in `config.rs` and skip re-emitting duplicates within the same process run; or (b) collect all warnings at a single call site after all config loads are complete and emit once with dedup; or (c) change `load()` to return warnings alongside the result instead of eagerly printing them, letting call sites emit once. Option (a) is a minimal one-file fix. **Why this matters:** duplicate warnings make the CLI look buggy, cause CI log noise, and — when the deprecation warning fires on every invocation — are more likely to be `tail -f`'d away than acted on. A single clean warning per invocation is the standard. Source: Jobdori dogfood on `c345ce6d`, 2026-05-25. 698. **Config deprecation warnings emit once per `ConfigLoader::load()` call, so surfaces that call `load()` multiple times in a single invocation emit duplicate `warning:` lines to stderr — `claw plugins list` and `claw mcp list` each print the same deprecation warning twice** — dogfooded 2026-05-25 on `c345ce6d`. Reproduction: `echo '{"enabledPlugins": {}}' > ~/.claw/settings.json && claw plugins list 2>&1 | grep warning` prints the same `field "enabledPlugins" is deprecated. Use "plugins.enabled" instead` line twice. Root cause: `config.rs:304` emits `eprintln!("warning: {warning}")` for every warning in every `loader.load()` call; surfaces like `plugins_command_payload_for` and `render_mcp_report_json_for` each trigger an independent `loader.load()` (one for runtime config, one inside the command handler), multiplying the stderr output. `skills list` emits only one warning because its command path calls `load()` once; `plugins` and `mcp` emit two. **Required fix shape:** (a) track already-emitted warning strings in a process-lifetime `std::sync::OnceLock<Mutex<HashSet<String>>>` in `config.rs` and skip re-emitting duplicates within the same process run; or (b) collect all warnings at a single call site after all config loads are complete and emit once with dedup; or (c) change `load()` to return warnings alongside the result instead of eagerly printing them, letting call sites emit once. Option (a) is a minimal one-file fix. **Why this matters:** duplicate warnings make the CLI look buggy, cause CI log noise, and — when the deprecation warning fires on every invocation — are more likely to be `tail -f`'d away than acted on. A single clean warning per invocation is the standard. Source: Jobdori dogfood on `c345ce6d`, 2026-05-25.
699. **`bootstrap-plan` and `dump-manifests` JSON/help probes fall through to prompt/auth instead of local command dispatch unless global flags are positioned just so; with normal subcommand-style argv they either hang behind the spinner or return `missing_credentials`, making local startup/manifest introspection non-local** — dogfooded 2026-05-25 on `11a6e081a` after the ROADMAP #458 envelope sweep. Reproduction with the freshly rebuilt debug binary: `./rust/target/debug/claw bootstrap-plan --output-format json </dev/null` times out after 10s with spinner bytes on stdout and only a config deprecation warning on stderr in the normal home env; in an isolated env without credentials it exits 1 as `missing_credentials` instead of returning the local bootstrap phase JSON. `./rust/target/debug/claw dump-manifests --output-format json </dev/null` behaves the same. The help forms `bootstrap-plan --help --output-format json` and `dump-manifests --help --output-format json` also time out behind the spinner. This is distinct from #690/#692, which assumed these help paths were intercepted and only lacked schema depth; current dogfood shows an even lower-level routing/argv-order gap on the rebuilt `11a6e081a` binary. **Why it matters:** `bootstrap-plan` and `dump-manifests` are supposed to be credential-free local introspection/preflight commands. If the parser treats `--output-format json` after the subcommand as prompt text or routes into the provider/auth path, claws cannot safely probe startup phases or manifest availability without API credentials and timeout guards. **Required fix shape:** (a) make subcommand-local `--output-format json` and `--help --output-format json` dispatch before prompt/auth for `bootstrap-plan` and `dump-manifests`; (b) guarantee these commands are local-only and do not require provider credentials; (c) add regression tests for both argv orders if global flags are supported after subcommands, or emit a fast typed `cli_parse` error with `status:"error"` and no spinner if not supported; (d) acceptance: `env -i HOME=/tmp/empty PATH=/usr/bin:/bin TERM=dumb ./rust/target/debug/claw bootstrap-plan --output-format json </dev/null | jq -e '.kind=="bootstrap-plan" and (.phases|length>0)'` and the analogous dump-manifests/help probes must return within 1s without credentials. Source: gaebal-gajae dogfood for the 2026-05-25 07:30 Clawhip nudge.
700. **`claw help --output-format json` emits `{"kind":"help","message":"<prose>"}` with no `status` field, and `claw sessions` (via `/sessions list` slash command) emits `{"kind":"session_list",...}` — both are envelope shape inconsistencies relative to the now-complete #458 sweep** — dogfooded 2026-05-25 on `eb7c14c4`. (1) `help` JSON: all 12 probed surfaces now have `status ∈ {ok,warn,error,unsupported}` after the #458 sweep; `help` is the one remaining surface that emits JSON but lacks `status`. The envelope has only `kind:"help"` and `message:"<prose blob>"` — no machine-readable status, no structured sections array (per #325/#686/#687/#688). (2) `session_list` kind: `claw sessions` and the `/sessions list` slash command emit `"kind":"session_list"` — all other surfaces use the subcommand name as the kind token (`kind:"skills"`, `kind:"agents"`, `kind:"mcp"` etc). `session_list` is a verb+noun compound that breaks the convention and makes kind-based routing require a special case. **Required fix shape:** (a) add `"status": "ok"` to all `help` JSON emission sites (`print_help` at line ~7120 and ~7167, plus inline REPL help at ~3924 in `main.rs`); (b) rename `"kind":"session_list"` to `"kind":"sessions"` at the two emission sites (lines ~3912, ~6385) and update any test assertions; keep a `"action":"list"` field for the action discriminant. Both are 13 line changes. **Why this matters:** the #458 acceptance check `for c in … ; do claw $c --output-format json | jq -e '.status | IN(…)' || echo FAIL; done` still FAILs for `help`; `session_list` kind breaks any kind-routing table that maps surface names to handler IDs. Source: Jobdori dogfood on `eb7c14c4`, 2026-05-25.
700. **Top-level `help --output-format json` hangs behind the prompt spinner instead of returning bounded help JSON or a typed parse error** — dogfooded 2026-05-25 on freshly rebuilt `f9e98a263` during the 08:30 Clawhip nudge. Reproduction: `timeout 8 ./rust/target/debug/claw help --output-format json </dev/null` exits 124; stdout only shows spinner/TUI bytes and stderr is empty or only unrelated config warning depending on HOME. Positive control in the same rebuilt binary: `version --output-format json` returns promptly with `{kind:"version",status:"ok",git_sha:"f9e98a263"}`. This is adjacent to #699 but distinct: #699 covers `bootstrap-plan`/`dump-manifests` local subcommands falling through to prompt/auth; #700 covers the root bootstrap help surface itself. **Why it matters:** `help --output-format json` is the first command a wrapper or new claw probes to discover the CLI. A hanging help path forces every orchestrator to wrap discovery in external timeouts and cannot distinguish “unsupported JSON help” from “provider/auth prompt path accidentally started.” **Required fix shape:** (a) intercept top-level `help --output-format json` before prompt/provider startup; (b) return bounded JSON with `kind:"help"`, `status:"ok"`, command list, formats, and schema/version metadata, or if this argv order is intentionally unsupported, fail fast with `kind:"cli_parse"`, `status:"error"`, no spinner; (c) add regression proving `env -i HOME=/tmp/empty PATH=/usr/bin:/bin TERM=dumb ./rust/target/debug/claw help --output-format json </dev/null` exits within 1s without credentials. Source: gaebal-gajae dogfood for the 2026-05-25 08:30 Clawhip nudge.
701. **`claw doctor --output-format json` `checks[].details[]` entries are prose strings like `"Enabled true"` — no structured key/value — so downstream claws must split on whitespace to extract scalar values from the detail table** — dogfooded 2026-05-25 on `f9e98a26`. Reproduction: `claw doctor --output-format json | jq '.checks[] | {name, details}'` returns `details: ["Repo exists true", "Worktree exists true", ...]` — each detail is a human-formatted table row. A caller wanting `sandbox.enabled` must do `split(/\s+/)` and strip padding. The `checks[].status` field is already structured (`"ok"/"warn"/"error"`), but the supporting evidence is prose-only. **Required fix shape:** (a) change `details: string[]` to `details: {key: string, value: string | bool | number | null}[]` for all doctor checks; use booleans for `true`/`false` values and numbers for counts; keep a `raw` or `label` string for human rendering; (b) update `format_doctor_detail` / `build_boot_preflight_details` etc. in `main.rs` to emit structured objects instead of padded strings; (c) update test assertions in `output_format_contract.rs` to verify `details[0].key` and `details[0].value` shapes; (d) acceptance: `claw doctor --output-format json | jq '.checks[] | .details[] | if type == "string" then error else . end'` should not error. **Why this matters:** `details[]` is the only per-check evidence available to a claw diagnosing a `"warn"` or `"error"` check; if the values are prose strings, the claw must scrape rather than parse. Source: Jobdori dogfood on `f9e98a26`, 2026-05-25.
702. **`claw agents --output-format json` per-agent entries use `source: {id, label}` while `claw skills --output-format json` per-skill entries use `origin: {id, detail_label}` — same concept, different field name and key shape, breaking any generic inventory parser** — dogfooded 2026-05-25 on `ee24ff2d`. Reproduction: `claw agents --output-format json | jq '.agents[0] | {source}` returns `{"source": {"id": "project_claw", "label": "Project roots"}}`. `claw skills --output-format json | jq '.skills[0] | {origin}` returns `{"origin": {"id": "skills_dir", "detail_label": null}}`. The provenance concept is the same (where the definition file was loaded from), but the field name (`source` vs `origin`), the human-label key (`label` vs `detail_label`), and the presence of `detail_label: null` vs no such field create two incompatible schemas. A generic claw that wants "where did this agent/skill come from?" must hard-code separate paths for agents and skills. **Required fix shape:** (a) normalise to a single shape — either `source: {id, label, detail_label?}` or `origin: {id, label, detail_label?}` — used identically in agent, skill, and any future resource listings; (b) update test assertions in `output_format_contract.rs` to verify the unified shape; (c) add a cross-resource schema test that parses both agents and skills provenance through the same JSON path. **Why it matters:** multi-resource orchestration (listing agents and skills to pick delegation targets) requires a uniform field layout; name divergence forces per-kind special-casing in every consumer. Source: Jobdori dogfood on `ee24ff2d`, 2026-05-25.
703. **`claw plugins --output-format json` list response uses prose `message` for inventory summary instead of a structured `summary: {total, enabled, disabled}` object, and leaks `reload_runtime`/`target` into the list envelope** — dogfooded 2026-05-25 on `5bca9ef0`. `claw skills --output-format json` returns `summary: {"active":81,"shadowed":47,"total":128}` — fully machine-readable. `claw plugins --output-format json` returns `message: "Plugins\n example-bundled v0.1.0 enabled\n sample-hooks v0.1.0 disabled"` — prose that requires scraping to count plugins. The envelope also includes `reload_runtime: false` and `target: null` which are operation-result fields, not list-response fields (they're only meaningful after install/enable/disable/uninstall). A generic claw computing "how many plugins are active?" cannot do so without text parsing. **Required fix shape:** (a) add `summary: {total, enabled, disabled, load_failures}` to the `plugins list` JSON envelope; (b) drop `reload_runtime` and `target` from the list response (they belong only in install/enable/disable/uninstall/update responses); (c) keep `message` as an optional human field alongside the structured summary; (d) update `output_format_contract.rs` to assert plugins list has `summary.total` and no `reload_runtime`. **Why it matters:** plugin-count queries and health checks require machine-readable inventory summaries; matching `skills` schema parity enables generic resource health checks across both subsystems. Source: Jobdori dogfood on `5bca9ef0`, 2026-05-25.
704. **`claw doctor --output-format json` all `checks[].label` fields are `null` — downstream claws cannot identify which check produced a `warn`/`error` without scraping the prose `name` or `details[]` array** — dogfooded 2026-05-25 on `1a6f54b9`. Reproduction: `claw doctor --output-format json | jq '[.checks[] | {label, status}]'` returns 7 entries all with `"label": null`. The `status` field correctly encodes `"ok"/"warn"/"error"` but there is no stable machine-readable identifier for each check. A claw automating `claw doctor` must either enumerate checks by positional index (fragile) or scrape the `name` prose string (brittle). **Required fix shape:** (a) add a stable `id` or `label` field to each `DiagnosticCheck` (e.g. `"credentials"`, `"git"`, `"sandbox"`, `"config"`, `"mcp"`, `"trust"`, `"workspace"`) that downstream parsers can key on; (b) the field should be `snake_case` and never change across releases; (c) the `name` field can remain as the human-readable title; (d) add regression asserting at least one check has a non-null `label` in `output_format_contract.rs`. **Why it matters:** doctor automation (e.g. preflight gates, CI health checks) requires routing on which check failed, not just that *a* check failed; positional-index routing breaks whenever a new check is added. Source: Jobdori dogfood on `1a6f54b9`, 2026-05-25.
705. **`status` and `export` usage JSON `estimated_cost_usd` is a string `"$0.0000"` not a number — downstream claws must strip `$` and parse float to compute costs** — dogfooded 2026-05-25 on `8f809d9a`. `claw status --output-format json | jq '.usage.estimated_cost_usd'` returns `"$0.0000"` (string). Cost aggregation or threshold checks require `parseFloat(x.replace("$",""))`. **Fix shape:** emit `estimated_cost_usd` as a JSON number and add `estimated_cost_usd_formatted` as the display string. Partial fix landed: `estimated_cost_usd_num` (float) added as a companion field at `cb...` alongside the legacy string field for backwards compatibility; `estimated_cost_usd` string preserved. Source: Jobdori dogfood on `8f809d9a`, 2026-05-25.
706. **`claw skills show <name> --output-format json` silently returns `status:"ok"` with empty `skills:[]` when the named skill does not exist — downstream claws cannot distinguish "no skill installed" from "skill name typo"** — dogfooded 2026-05-26 on `f84799c8`. Reproduction: `claw skills show nonexistent --output-format json``{kind:"skills", action:"list", status:"ok", skills:[], summary:{total:0,...}}` exit 0. A claw checking whether a skill is available treats empty success as "no skills installed anywhere" rather than "skill not found". **Fix shape:** return `{kind:"skills", action:"show", status:"error", error_kind:"skill_not_found", requested:"<name>"}` + exit 1 when `show <name>` matches nothing; landed at `...`. Source: Jobdori dogfood on `f84799c8`, 2026-05-26.
707. **`init.rs` test `temp_dir()` uses nanoseconds only — two parallel test runs in the same process can land in the same nanosecond window and collide on the same temp path, causing intermittent test failures** — dogfooded 2026-05-26 on `dedad14a`. `artifacts_with_status_partitions_fresh_and_idempotent_runs` was seen flaking in full-suite parallel runs but passing in isolation. Root cause: `temp_dir()` at `init.rs:383` used only `SystemTime::now().as_nanos()` as the uniqueness token. Two concurrent callers within the same nanosecond window produce the same path. Fix: added `AtomicU64` counter combined with nanoseconds → `rusty-claude-init-{nanos}-{id}` eliminates same-process collisions. Source: Jobdori dogfood on `dedad14a`, 2026-05-26.
708. **`claw skills show <name> --output-format json` returns `action:"list"` even on the show path — `render_skills_report_json` hardcoded `"action":"list"` for all skill responses including show/info/describe** — dogfooded 2026-05-26 on `26a50d91`. `claw skills show korea-weather --output-format json` returned `{action:"list"}` even when the show path was taken. Also had duplicate `"status":"ok"` key in the JSON object. Fix: renamed `render_skills_report_json` to `render_skills_report_json_with_action(skills, action)` and updated all call sites to pass `"list"` or `"show"` appropriately; removed duplicate status key. Source: Jobdori dogfood on `26a50d91`, 2026-05-26.
709. **`render_agents_report_json` and `render_skill_install_report_json` in `commands/src/lib.rs` contained duplicate `"status":"ok"` keys in the same JSON object literal — second key silently overwrites first in serde_json** — found during #708 dogfood sweep on `47c0226a`. Rust `serde_json::json!` macros accept duplicate keys but `serde_json` silently keeps the last occurrence; any consumer relying on the first occurrence gets the wrong value if they are ever different. Fixed by removing the duplicate `status` key from `render_agents_report_json` and `render_skill_install_report_json`. Source: Jobdori dogfood on `47c0226a`, 2026-05-26.
710. **`claw diff --output-format json` missing `action` and `working_directory` fields — both the ok and error paths in `render_diff_json_for` omitted `action` entirely (returning `null` at parse time) and omitted `working_directory`** — dogfooded 2026-05-26 on `8f8eb41e`. Both the clean/changes success path and the no-git-repo error path were missing the two envelope fields that automation uses for routing and provenance. Fix: added `action:"diff"` and `working_directory: cwd.display()` to both branches of `render_diff_json_for`; added contract-test assertions for both fields. Source: Jobdori dogfood on `8f8eb41e`, 2026-05-26.
711. **`version`, `system-prompt`, `export`, and `init` `--output-format json` responses all lacked an `action` field — returned `null` or omitted entirely** — dogfooded 2026-05-26 on `42c17bc4`. All four commands emitted `kind` + `status` but no `action`, making them inconsistent with all other JSON surfaces that include the verb. Fix: added `action:"show"` to `version` and `system-prompt`, `action:"export"` to all three `export` paths, `action:"init"` to `init`; added contract-test assertions for each. Source: Jobdori dogfood on `42c17bc4`, 2026-05-26.
712. **`doctor`, `status`, `bootstrap-plan`, and `dump-manifests` `--output-format json` responses missing `action` field — consistent with batch of missing-action fixes in #710/#711** — dogfooded 2026-05-26 on `bae0099c`. `doctor` returned no `action`; `status` and `bootstrap-plan` same. `dump-manifests` had empty string. Fix: added `action:"doctor"` to doctor JSON, `action:"show"` to status and bootstrap-plan, `action:"dump"` to dump-manifests happy path. Source: Jobdori dogfood on `bae0099c`, 2026-05-26.
713. **`acp` and `config` (bare and section-show) `--output-format json` responses missing `action` field — continues sweep from #710#712** — dogfooded 2026-05-26 on `fdde5e45`. `acp` had no action; `config` bare had no action; `config <section>` and the unknown-section error path both had no action. Fix: added `action:"status"` to acp, `action:"list"` to config bare, `action:"show"` to config section-show and unknown-section error path. Source: Jobdori dogfood on `fdde5e45`, 2026-05-26.
714. **`help --output-format json` missing `action` field; resume `/help` JSON path also missing `action` and `status`** — dogfooded 2026-05-26 on `7d6b2044`. Top-level `claw help --output-format json` returned `{kind:"help", status:"ok"}` with no `action`. The `render_export_help_json` and `render_help_topic_json` resume-path helpers were also missing `action`. The resume REPL help JSON object had neither `action` nor `status`. Fix: added `action:"help"` (and `status:"ok"` where missing) to all 4 help JSON sites. Source: Jobdori dogfood on `7d6b2044`, 2026-05-26.
715. **Resume-path slash commands (`/compact`, `/clear`, `/cost`, `/stats`, `/history`, `/session exists`, `/session delete`, `memory`) JSON responses missing `action` and `status` fields** — dogfooded 2026-05-26 on `590b5b61`. The `assert_non_empty_action` guardrail added by #3109 only covers `assert_json_command` (top-level CLI surfaces); resume-path commands that emit JSON via `ResumeCommandOutcome.json` were not covered. 8 resume-path JSON sites all lacked `action` and `status`. Fix: added `action` + `status:"ok"` to `compact`, `clear`, `cost`, `stats`, `history`, `session_exists`, `session_delete`, `memory`, and `restored`. Source: Jobdori dogfood on `590b5b61`, 2026-05-26.
716. **Resume-path error JSON used legacy `{type:"error", error:...}` shape instead of standard `{kind, action, status:"error", error_kind, exit_code}` envelope — 5 error paths affected** — dogfooded 2026-05-26 on `76c8d480`. Session load failure, unsupported command, unsupported resumed command, SlashCommand parse error, and broad-cwd abort all emitted the old two-key shape. Fix: aligned all 5 to `{kind, action:"resume"|"abort", status:"error", error_kind, error, exit_code}`. Updated `resumed_stub_command_emits_not_implemented_json` test to assert `status:"error"` + `kind:"unsupported_command"`. Source: Jobdori dogfood on `76c8d480`, 2026-05-26.
717. **`claw agents show <name>` missing — `handle_agents_slash_command_json` only accepted `list`; `show/info/describe` was unimplemented unlike skills which had parity** — dogfooded 2026-05-26 on `6a007344`. `claw agents show claw-code --output-format json` returned `unknown_agents_subcommand` error. Fix: added `show/info/describe` and `list <filter>` arms to both `handle_agents_slash_command` and `handle_agents_slash_command_json`, mirroring the skills handler; renamed `render_agents_report_json``render_agents_report_json_with_action`; not-found path returns `{kind:"agents", action:"show", status:"error", error_kind:"agent_not_found", requested:"<name>"}` + Ok; added `classify_error_kind` branch for `agent_not_found`. Updated 2 tests. Source: Jobdori dogfood on `6a007344`, 2026-05-26.
718. **`claw plugins show <name>` unimplemented — unlike `agents show` and `skills show`, `/plugins show` returned `unknown_plugins_action` error** — dogfooded 2026-05-26 on `8d80f2ff`. Fix: added `show/info/describe` arm to `handle_plugins_slash_command` that filters installed plugins by name; `print_plugins` JSON path filters `payload.plugins` when action is `show/info/describe` and emits `{kind:"plugin", action:"show", status:"error", error_kind:"plugin_not_found", requested:"<name>"}` for missing names. Updated error message in catch-all to name `show` as supported. Source: Jobdori dogfood on `8d80f2ff`, 2026-05-26.
719. **`plugins list <filter>` silently returned all plugins instead of filtering — unlike `agents list <filter>` and `skills list <filter>` which do substring filter** — dogfooded 2026-05-26 on `556a598f`. `claw plugins list nonexistent-filter-xyz --output-format json` returned both installed plugins. Fix: `handle_plugins_slash_command` `list` arm now treats `target` as a substring filter; `print_plugins` JSON path applies `target` filter for `list` action. Source: Jobdori dogfood on `556a598f`, 2026-05-26.
720. **`claw help agents` (and `help skills|plugins|mcp|config|diff|sandbox|doctor|etc.`) errored with `cli_parse: unrecognized argument` instead of routing to the subsystem's help** — dogfooded 2026-05-26 on `fe2b13a4`. `claw help agents --output-format json` returned `{status:"error", error_kind:"cli_parse"}`. The `is_diagnostic` guard for `"help"` verb rejected any trailing non-flag argument. Fix: when `verb == "help"` and exactly one topic argument follows, match it against all known `LocalHelpTopic` variants (including new `Agents`, `Skills`, `Plugins`, `Mcp`, `Config`, `Diff`) and route to `HelpTopic`; `agents` and `skills` delegate to their subsystem usage JSON in `print_help_topic`. Unknown topics fall through to generic `Help`. Source: Jobdori dogfood on `fe2b13a4`, 2026-05-26.
721. **`claw config mcp|sandbox|permissions|skills|agents` returned `{status:"error", error_kind:"unsupported_config_section"}` with error message "Use env, hooks, model, or plugins" — 5 valid config sections were not mapped in the JSON path** — dogfooded 2026-05-26 on `02d1f6a0`. `claw config agents --output-format json``{status:"error", error_kind:"unsupported_config_section", error:"...Use env, hooks, model, or plugins."}`. The `match section` arms in both text and JSON paths only handled `env/hooks/model/plugins`; all others fell to the error arm. Fix: added `mcp|mcp_servers|mcpServers`, `sandbox`, `permissions`, `skills`, `agents` arms to both text and JSON config section handlers. `unsupported_config_section` error envelope now includes `supported_sections:[]` array. Source: Jobdori dogfood on `02d1f6a0`, 2026-05-26.
722. **ROADMAP #721 re-entry after rebase conflict: `claw config mcp|sandbox|permissions|skills|agents` returned `unsupported_config_section` — code fix is in `6e44da10`** (main.rs changes preserved through rebase, only ROADMAP.md was conflict-resolved to Gaebal's version). Both text and JSON config section handlers now support `mcp`, `sandbox`, `permissions`, `skills`, `agents`; error envelope includes `supported_sections:[]`. Source: Jobdori dogfood on `02d1f6a0`, 2026-05-26.
723. **Concurrent dogfood claws allocate ROADMAP ids manually and collide — same id reused by two contributors simultaneously, causing PR ROADMAP.md conflicts and lost entries** — observed live 2026-05-26 during Jobdori+Gaebal parallel dogfood session: Gaebal filed stale-local-probe as #719; Jobdori landed `plugins list <filter>` as #719 on main first; Gaebal shifted to #720; Jobdori landed `claw help <topic>` as #720; stale-local-probe eventually landed as #721 after two forced rebase cycles. The ROADMAP append workflow has no reservation or conflict-aware id allocation. **Required fix shape:** (a) add `scripts/roadmap-next-id.sh` that reads the highest id from ROADMAP.md and prints `highest+1` — claws should call this immediately before appending any new entry; (b) document in CONTRIBUTING.md that id allocation is optimistic-append: call `roadmap-next-id.sh` immediately before the append, git-pull first, resolve collisions at push time by re-numbering the appended entry; (c) long-term: a GitHub Action that validates no duplicate ROADMAP ids on PR would catch this before merge. Added `scripts/roadmap-next-id.sh` (this commit). Source: Gaebal Gajae live observation, 2026-05-26.
724. **DONE — ROADMAP duplicate-id validation guard for helper-era append collisions** — follow-up to #723 after dogfood showed `scripts/roadmap-next-id.sh` still printed 724 and exited 0 when a temp ROADMAP copy already contained a second `723. ...` line. This PR closes the gap for new optimistic-append collisions by adding `scripts/roadmap-check-ids.sh`, wiring it into docs CI and the local pre-push hook, documenting the pre-push command in CONTRIBUTING, and mentioning the guard from `roadmap-next-id.sh`. The guard defaults to ids >=723 so current historical roadmap content and old numbered lists do not block docs-only PRs; `--min-id 1` is available for a strict whole-file audit once legacy collisions are cleaned up. **Verification:** `scripts/roadmap-check-ids.sh` passes on current ROADMAP; a temp copy with an appended duplicate `723.` fails nonzero and lists duplicate id 723 with line numbers. Source: Jobdori dogfood follow-up on origin/main `922c2398`, 2026-05-25. [SCOPE: docs/scripts]
725. **DONE — roadmap-next-id helper now fails closed on helper-era duplicate ids before printing a next id** — follow-up to #724 after dogfood on origin/main 25ee5f3d showed `scripts/roadmap-next-id.sh` could print `1000` and exit 0 when a temp ROADMAP copy already contained two `999.` helper-era entries. This PR makes `roadmap-next-id.sh` resolve `roadmap-check-ids.sh` by its own script directory, run the checker with default helper-era min-id semantics before computing `highest+1`, keep stdout reserved for the single next id on success, and fail closed with a useful error if the checker is unavailable. Added focused pytest coverage for clean next-id output, duplicate fail-fast behavior, and missing-checker fail-closed behavior. **Verification:** `scripts/roadmap-next-id.sh ROADMAP.md` prints `725`; `scripts/roadmap-check-ids.sh ROADMAP.md` passes; a temp ROADMAP with duplicate `999.` exits nonzero and lists duplicate id 999 without printing a next id; `bash -n scripts/roadmap-next-id.sh scripts/roadmap-check-ids.sh` passes; `python -m pytest tests/test_roadmap_helpers.py -q` passes. Source: Jobdori dogfood follow-up on origin/main 25ee5f3d. [SCOPE: docs/scripts]
726. **`claw export` from a workspace with a cross-workspace legacy session emits `kind:"unknown", error_kind:"unknown"` instead of a typed error — `legacy session is missing workspace binding` error propagates through the generic error handler unclassified** — dogfooded 2026-05-26 on `d8a61090`. Reproduction: `claw export --output-format json` from a fresh `git init` workspace where the most-recent managed session was created in a different workspace root returns `{kind:"unknown", action:"abort", status:"error", error_kind:"unknown"}`. The error originates in `SessionControlError::Format(format_legacy_session_missing_workspace_root(...))` in `session_control.rs:313`; `classify_error_kind` had no branch for "legacy session is missing workspace binding" and fell through to "unknown". Fix: added `legacy_session_no_workspace_binding` branch to `classify_error_kind`. Remaining gap: `kind` still shows the error_kind value instead of `"export"` — root cause is the generic error path setting `kind = error_kind` rather than the subcommand name; this is the `#422` class and requires a separate structural fix. Source: Jobdori dogfood on `d8a61090`, 2026-05-26.
727. **`branch_freshness.fresh: null` with `upstream: null` is ambiguous — automation checking `if .workspace.branch_freshness.fresh == true` treats "no upstream configured" identically to "behind by N commits", both returning falsy null** — dogfooded 2026-05-26 on `a0c6c8ba`. Reproduction: `claw status --output-format json` from a freshly `git init`'d repo with no remote returns `{upstream: null, fresh: null, ahead: 0, behind: 0}`. An automation script that gates on `.branch_freshness.fresh == true` before proceeding sees `null == true → false` and blocks — identical to the behind-by-N case. The JSON has no discriminator between "freshness unknown because no upstream" and "freshness unknown because git unavailable". Fix: added `has_upstream: bool` to `BranchFreshness.json_value()` — automation should check `has_upstream` before branching on `fresh`. Source: Jobdori dogfood on `a0c6c8ba`, 2026-05-26.
728. **`claw agents list` and `agents show` JSON responses had no `path` field — callers could not determine which on-disk `.toml` file backs each agent without re-walking the same discovery directories** — dogfooded 2026-05-26 on `9757fef8`. `claw agents list --output-format json` returned `{name, description, model, source: {id, label, detail_label: null}}` with no disk path. `AgentSummary` had no `path` field; the `entry.path()` from the `fs::read_dir` loop was discarded after frontmatter parsing. Fix: added `path: Option<PathBuf>` to `AgentSummary`; populated from `entry.path()` in the discovery loop; exposed as `"path": string|null` in `agent_summary_json`. Agents now return e.g. `{path:"/Users/.../.codex/agents/codex-ultrawork-reviewer.toml"}`. Parity gap: `skills list` still lacks `path` — tracked as a follow-on (same fix needed in `SkillSummary`). Source: Jobdori dogfood on `9757fef8`, 2026-05-26.
729. **`claw skills list/show --output-format json` had no `path` field — parity gap with `agents list` (#728): callers could not determine which on-disk directory backs each skill without re-walking discovery roots** — dogfooded 2026-05-26 on `fa29909f`. `SkillSummary` had no `path` field; both `SkillOrigin::SkillsDir` (returns `entry.path()`) and `SkillOrigin::LegacyCommandsDir` (returns `markdown_path`) push sites discarded the resolved path after parsing. Fix: added `path: Option<PathBuf>` to `SkillSummary`; `SkillsDir` branch populates `Some(entry.path())`, `LegacyCommandsDir` branch populates `Some(markdown_path)`; `skill_summary_json` exposes `"path": string|null`. Skills now return e.g. `{path:"/Users/.../.agents/skills/agent-browser"}`. Completes the path-discoverability trio started in #728 (agents) — plugins path is a remaining follow-on. Source: Jobdori dogfood on `fa29909f`, 2026-05-26.
730. **`claw plugins list/show --output-format json` had no `path` field — parity gap completing the agents (#728) / skills (#729) trio: callers could not determine which on-disk directory backs each plugin without re-walking discovery roots** — dogfooded 2026-05-26 on `8f44ad30`. `plugin_summary_json` in `rusty-claude-cli/src/main.rs` rendered all `PluginMetadata` fields except `root: Option<PathBuf>`, which was already present in the struct. Fix: added `"path": plugin.metadata.root.as_ref().map(|p| p.display().to_string())` to `plugin_summary_json`. Plugins now return e.g. `{path:"/Users/.../.claw/plugins/installed/example-bundled-bundled"}`. Completes path-discoverability across all three extension surfaces (agents, skills, plugins). Source: Jobdori dogfood on `8f44ad30`, 2026-05-26.
731. **`claw sandbox --output-format json` returned `status:"error"` when namespace isolation is unsupported on macOS but filesystem sandbox is active — automation treating `status != "ok"` as a hard error would block on a fully-functional degraded sandbox** — dogfooded 2026-05-26 on `425d94ee`. `sandbox_json_value` derived `status:"error"` when `!status.supported` regardless of whether `filesystem_active:true` (workspace-write containment working). On macOS the typical state is `{supported:false, filesystem_active:true, active_namespace:false}` — namespace isolation is unsupported but the filesystem sandbox IS active. This is degradation, not failure. Fix: added `else if status.filesystem_active { "warn" }` branch before the hard `"error"` arm — `status:"error"` is now reserved for the case where sandbox is enabled, unsupported, AND no filesystem containment is active either. macOS default now correctly returns `status:"warn"`. Source: Jobdori dogfood on `425d94ee`, 2026-05-26.
732. **`claw status --output-format json` `allowed_tools.entries` was `null` when no `--allowed-tools` flag was passed — callers doing `.allowed_tools.entries | length > 0` or trying to iterate got a null-dereference instead of an empty array** — dogfooded 2026-05-26 on `29dcd478`. `allowed_tool_entries` was computed as `allowed_tools.map(|tools| tools.iter().cloned().collect())``None` when unrestricted, serialized to JSON `null`. Fix: `.unwrap_or_default()` so unrestricted invocations emit `entries: []` instead of `entries: null`. Callers can now use `.entries | length > 0` uniformly without a null guard. Source: Jobdori dogfood on `29dcd478`, 2026-05-26.
733. **`claw diff --output-format json` returned no `changed_file_count` field — callers seeing `result:"changes"` had to parse the raw `staged`/`unstaged` diff text to count affected files** — dogfooded 2026-05-26 on `4c16a42f`. `render_diff_json_for` ran `git diff --cached` and `git diff` and exposed them as raw strings but didn't compute a file count. Fix: run two additional `git diff --name-only` passes (staged + unstaged), deduplicate across both sets using a `BTreeSet`, and expose `changed_file_count: usize` in the envelope. Clean repos emit `changed_file_count: 0`, dirty repos emit the true unique-file count. Source: Jobdori dogfood on `4c16a42f`, 2026-05-26.
734. **`agents show <name>` and `plugins show <name>` error envelopes had no `message` field when the target was not found — `skills show` had `"message": "skill 'X' not found"` but the other two omitted it, leaving callers with only `error_kind` and `requested` and no human-readable explanation in the same field shape** — dogfooded 2026-05-26 on `cc86f54d`. Added `"message": "agent 'X' not found"` to the `agent_not_found` branch in `commands/src/lib.rs` and `"message": "plugin 'X' not found"` to the `plugin_not_found` branch in `rusty-claude-cli/src/main.rs`; both now match the `skills show` shape. Source: Jobdori dogfood on `cc86f54d`, 2026-05-26.
735. **`claw /compact --output-format json` (and other interactive-only slash commands invoked outside a session) emitted `error_kind:"unknown"` instead of `error_kind:"interactive_only"``classify_error_kind` matched `"is a slash command"` and `"interactive_only:"` prefix but missed the `"slash command /X is interactive-only"` sentence pattern emitted by the interactive-only guard; automation branching on `error_kind` got `"unknown"` and couldn't distinguish "you called an interactive command outside a session" from a genuine unknown failure** — dogfooded 2026-05-26 on `d4494a8a`. Added `message.starts_with("slash command") && message.contains("interactive-only")` branch to `classify_error_kind` alongside the existing two matchers. Source: Jobdori dogfood on `d4494a8a`, 2026-05-26.
736. **`claw doctor --output-format json` `boot_preflight` check `details[]` had `value: null` for `Required binary`, `Last failed boot`, `MCP eligible`, and `Plugin eligible` entries — all four used format strings with no double-space separator, so the prose-splitter that builds `{key, value}` objects (introduced in #701) could not split key from value and emitted the entire string as `key` with `value: null`** — dogfooded 2026-05-26 on `b3242e8c`. Fix: insert the two-space separator between the label and its value in each format string: `"Required binary {} available={}"``key="Required binary claw"` / `value="available=true"`; `"Last failed boot {}"``key="Last failed boot"` / `value="<none>"`; MCP/Plugin eligible compound values use `" · "` intra-value separator since `splitn(2, " ")` splits only on the first double-space run. Source: Jobdori dogfood on `b3242e8c`, 2026-05-26.
737. **Test coverage gap: `doctor --output-format json` `boot_preflight` `details[]` had no assertion that entries are `{key,value}` objects with non-null `value` fields — the #736 double-space separator fix had no regression guard, so a revert or accidental prose-format change would silently re-introduce `value:null` entries** — filed 2026-05-26 on `ad982d20`. Added assertions to `doctor_and_resume_status_emit_json_when_requested` in `output_format_contract.rs`: iterate all `boot_preflight.details[]` entries and assert each has a string `key` and a non-null `value`. Source: Jobdori dogfood on `ad982d20`, 2026-05-26.
738. **`claw /commit --output-format json` (and all other interactive-only slash commands invoked outside a session) emitted `hint: null` — the remediation text was in the `error` prose string but no newline separated the short error from the hint, so `split_error_hint` returned the entire message as `error` and `hint: null`** — dogfooded 2026-05-26 on `c592313d`. The format string `"slash command {cmd} is interactive-only. Start `claw`..."` had no newline, so `split_error_hint` (which splits on `\n`) could not extract the hint. Fix: add `\n` between the short error `"slash command X is interactive-only."` and the remediation text, so callers reading `.hint` get the actionable guidance directly. Source: Jobdori dogfood on `c592313d`, 2026-05-26.
739. **`claw skills <unknown-subcommand> --output-format json` emitted two JSON objects on stdout: first the usage envelope (`action:"help", unexpected:"X"`), then a second error abort envelope (`kind:"unknown", error:"skills command failed"`) — the `print_skills` JSON path returned `Err` on `status:"error"` responses even when the response was a normal usage-display (`action:"help"`), causing the generic error serializer to emit the second envelope** — dogfooded 2026-05-26 on `4c3cb0f3`. Fix: skip the `return Err` path when `action == "help"`; usage envelopes are informational, not fatal errors. The root prompt-dispatch gap (`claw skills bogus``CliAction::Prompt``missing_credentials` in no-creds env) is a pre-existing auth-gate-on-local-surface issue (ROADMAP #431/#449) and not addressed here. Source: Jobdori dogfood on `4c3cb0f3`, 2026-05-26.
740. **Test coverage gap for ROADMAP #733: `diff_json_has_status_and_result_field_702` did not assert `changed_file_count` contract** — dogfooded 2026-05-26 on `d5f0d6ed`. The test asserts `kind`, `status`, `result`, `action`, `working_directory` but not the new `changed_file_count` field added by #733. Coverage gap: (a) no assertion that the field exists, (b) no assertion of numeric type in git repos, (c) no regression guard for dedupe behavior (staged+unstaged to the same file = 1 changed file). Fix: extend the test to assert `changed_file_count: null` in non-git repos and `changed_file_count: u64` in git repos. Source: gaebal-gajae dogfood on `d5f0d6ed`, 2026-05-26.
741. **`claw config list`, `claw config show`, `claw config bogus` --output-format json returned `hint: null` — the unsupported_config_section error envelope had no `hint` field populated, so callers reading `.hint` get null with no actionable guidance** — dogfooded 2026-05-26 on `5d072d21`. The `render_config_json` unsupported-section branch returned a JSON object with `error` (contains the section list) but no `hint` field. Notably `config list` and `config show` are natural verb patterns that users type expecting a list/show subcommand, but claw config uses `claw config` (no args) for list and `claw config <section>` for show — the error gave no indication of this. Fix: add `hint` field to unsupported_config_section error; verbs (`list`, `show`, `help`, `info`) get a hint explaining the correct idiom (`claw config` / `claw config <section>`); other unknown sections get a "not a config section" hint listing valid values. Source: Jobdori dogfood on `5d072d21`, 2026-05-26.
742. **ROADMAP #740 test coverage gap: the new `changed_file_count` branch for git repos was unreachable — the fixture is a plain `unique_temp_dir` (no `git init`), so the test always exercises the `no_git_repo` path and never proves the numeric contract or deduplication behavior** — confirmed by gaebal-gajae on `5d072d21`, fixed on `6e78c1fc`. Fix: add `diff_json_changed_file_count_deduplication_733` test that (a) `git init`s a temp repo, (b) commits a file, (c) asserts `result:"clean"` + `changed_file_count:0`, (d) stages an edit + makes an unstaged edit to the same file, (e) asserts `result:"changes"` + `changed_file_count:1` — proving the BTreeSet deduplication actually works. Source: gaebal-gajae dogfood on `5d072d21`, 2026-05-26.
743. **`claw plugins help --output-format json` returned `error_kind:"unknown_plugins_action"` with `hint:null` instead of the usage envelope (`action:"help", status:"ok", unexpected:null, usage:{...}`) that `agents help`, `mcp help`, and `skills help` all emit — schema drift within the same command family (ROADMAP #420)** — dogfooded 2026-05-26 on `2036f0bd`. Fix: (a) added `Some("help" | "-h" | "--help")` arm to `handle_plugins_slash_command` returning a text usage message (text path parity); (b) added early-return JSON help envelope in `print_plugins` JSON path matching shape of agents/mcp help: `{action:"help", kind:"plugin", status:"ok", unexpected:null, usage:{direct_cli, slash_command}}`. Source: Jobdori dogfood on `2036f0bd`, 2026-05-26.
744. **ROADMAP #741 has no regression test: `claw config list/show/bogus --output-format json hint` field could silently regress to null** — confirmed by gaebal-gajae on `2036f0bd`. Pattern same as #736#737 and #740#742: implementation fix without a pinning test. Fix: add `config_unsupported_section_json_hint_741` test iterating `[list, show, bogus, help]` and asserting `kind:config`, `status:error`, `error_kind:unsupported_config_section`, `hint` is non-empty string, `supported_sections[]` is non-empty. Source: gaebal-gajae dogfood on `2036f0bd`, 2026-05-26.
745. **`claw issue --output-format json` and all other direct-CLI slash commands (pr, commit, etc.) returned `hint: null` — the `bare_slash_command_guidance` message strings had no `\n` separator between short error and remediation text, so `split_error_hint` couldn't populate the hint field** — dogfooded 2026-05-26 on `92e053a1`. The #738 fix added `\n` to the `--resume SESSION /cmd` path but missed the direct-CLI path (e.g. `claw issue`, `claw pr`). The `bare_slash_command_guidance` function formats two message variants: resume-supported and non-resume; both lacked `\n`. Fix: add `\n` before the remediation text in both format strings. Source: Jobdori dogfood on `92e053a1`, 2026-05-26.
746. **`claw --output-format json` (bare, no TTY, no prompt) returned `hint: null` — the non-TTY interactive-only guard error string had no `\n` separator, so `split_error_hint` couldn't extract the remediation text into `.hint`** — dogfooded 2026-05-26 on `3c5459a3`. The single-string message `"interactive_only: claw requires an interactive terminal (stdin is not a TTY and no prompt was provided \u2014 pipe a prompt or run in a TTY)"` contained the hint inline but no newline, so callers reading `.hint` got null and had to parse the prose `error` string. Fix: split at `\n` — short error `"interactive_only: claw requires an interactive terminal."` + hint `"Stdin is not a TTY…pipe a prompt with \`echo 'task' | claw\` or run \`claw\` in an interactive terminal."`. Source: Jobdori dogfood on `3c5459a3`, 2026-05-26.
747. **ROADMAP #745 has no regression test: `claw issue/pr/commit --output-format json hint` could silently regress to null** — confirmed by gaebal-gajae on `3c5459a33`. Same pattern as #737, #742, #744. Fix: add `bare_slash_command_hint_745` test iterating `issue`, `pr`, `commit` and asserting `error_kind:"interactive_only"` + non-empty `hint` field. Source: gaebal-gajae dogfood on `3c5459a33`, fixed on `18e7744e`, 2026-05-26.
748. **`claw mcp bogussubcmd --output-format json` returned `error_kind: null` when an unknown subcommand was passed — `render_mcp_usage_json(Some("bogus"))` set `status:"error"` but left `error_kind` absent — while `agents bogussubcmd` emits `error_kind:"unknown_agents_subcommand"`** — dogfooded 2026-05-26 on `04eb661e`. Fix: add `error_kind: "unknown_mcp_action"` to `render_mcp_usage_json` when `unexpected.is_some()`; remains `null` for the `help` path (`unexpected: null`). Source: Jobdori dogfood on `04eb661e`, 2026-05-26.
749. **`claw compact --output-format json` returned `hint: null``compact_interactive_only_error()` returned a single-line string with no `\n` between short error and remediation text, so `split_error_hint` couldn't populate the hint field** — identified by gaebal-gajae on `04eb661e`. Same class as #738 / #745 / #746. Fix: add `\n` before the remediation text in `compact_interactive_only_error`. Regression guard: extended `compact_subcommand_json_help_fails_fast_when_stdin_closed` to also assert `hint` is non-empty and mentions `/compact` or `--resume`. Source: gaebal-gajae dogfood on `04eb661e`, 2026-05-26.
750. **`claw prompt --output-format json` (no text argument) returned `error_kind:"unknown"` and `hint: null`** — dogfooded 2026-05-26 on `2dfb7af6`. The error string `"prompt subcommand requires a prompt string"` had no prefix prefix for classifier and no `\n` for hint extraction. Fix: (a) prefix with `"missing_prompt: "` + newline before usage hint; (b) add `message.starts_with("missing_prompt:")``"missing_prompt"` classifier arm. Result: `error_kind:"missing_prompt"`, `hint:"Usage: claw prompt <text> or echo '<text>' | claw"`. Source: Jobdori dogfood on `2dfb7af6`, 2026-05-26.
751. **ROADMAP #750 has no regression test: `claw prompt --output-format json` no-arg `error_kind` and `hint` could silently regress** — confirmed by gaebal-gajae on `ac925ed4`. Fix: add `prompt_no_arg_json_error_kind_750` test asserting nonzero exit, `error_kind:"missing_prompt"`, non-empty `hint` mentioning `claw prompt` or `echo`. Source: gaebal-gajae dogfood on `ac925ed4`, 2026-05-26.
752. **`claw <subcommand> --output-format json <bogus-arg>` returned `hint: null` for all `cli_parse` errors when an unrecognized positional arg was supplied** — dogfooded 2026-05-26 on `ddc71b56`. Generic `unrecognized argument` format string had no `\n` so `split_error_hint` emitted null hint (only the `--json` special-case added a hint). Fix: add else-branch appending `\nRun `claw <verb> --help` for usage.` to the generic arm. Affected surfaces: `sandbox`, `doctor`, `version`, and any other subcommand routing through the same unrecognized-arg path. Source: Jobdori dogfood on `ddc71b56`, 2026-05-26.
753. **`claw --output-format json -p` (no prompt arg) returned `error_kind:"unknown"` and `hint: null`** — parity gap with #750/#751 which fixed the explicit `prompt` verb. Identified by gaebal-gajae on `ddc71b56`. Fix: same `missing_prompt:` prefix + newline usage hint as #750. Regression guard: `short_p_flag_no_arg_json_error_kind_753` asserting nonzero exit, `error_kind:"missing_prompt"`, non-empty hint mentioning `claw -p` or `claw prompt`. Source: gaebal-gajae dogfood on `ddc71b56`, 2026-05-26.
754. **`missing_credentials` JSON envelope always had `hint: null` even when a contextual hint was available** — dogfooded 2026-05-26 on `e9327135`. `ApiError::Display` for `MissingCredentials` appended the hint via ` — hint: {hint}` (inline, no `\n`), so `split_error_hint()` could not extract it and left the JSON `hint` field null. Fix: change delimiter from ` — hint: ` to `\n` in `api/src/error.rs` Display impl; update two tests in `api/src/error.rs` and `api/src/providers/mod.rs` to assert newline separator. Source: Jobdori dogfood on `e9327135`, 2026-05-26.
755. **`claw -p hello --model sonnet` swallowed `--model sonnet` into the prompt string** — gaebal-gajae pinpoint on `e9327135` (#117 revival). `-p` used `args[index+1..].join(" ")`, consuming all remaining tokens as prompt. Fix: capture exactly one token via `args.get(index+1)`, reject flag-like tokens (`starts_with('-')`) as `missing_prompt`, support `--` sentinel for literal flag-text, then `continue` the flag loop so `--model`/`--output-format`/etc. parse normally. Dispatch via `short_p_prompt` after full flag scan. Regression guard: `short_p_flag_swallows_no_flags_755` asserts `--output-format json` is parsed (not swallowed) and `--model` as prompt-arg is rejected. Source: gaebal-gajae dogfood on `e9327135`, 2026-05-26.
756. **`--reasoning-effort bogus`, `--model` (no value), and sibling missing/invalid flag-value errors all returned `error_kind:"unknown"` + `hint:null`** — gaebal-gajae pinpoint on `0e8a449e`. All `missing value for --X` and `invalid value for --reasoning-effort` error strings were single-line with no classifier arm. Fix: (a) prefix all with `missing_flag_value:` / `invalid_flag_value:` + `\n` usage hint; (b) add `message.starts_with("missing_flag_value:")``"missing_flag_value"` and `message.starts_with("invalid_flag_value:")``"invalid_flag_value"` classifier arms. Covers `--model`, `--output-format`, `--permission-mode`, `--base-commit`, `--reasoning-effort`. Regression guard: `flag_value_errors_have_error_kind_and_hint_756` — invalid `--reasoning-effort HIGH``invalid_flag_value` + hint with valid values; missing `--model``missing_flag_value` + non-null hint. Source: gaebal-gajae dogfood on `0e8a449e`, 2026-05-26.
757. **`--permission-mode bogus` and `--allowedTools` (no value) returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-26 on `4df14618`. `parse_permission_mode_arg()` error format had no prefix and no `\n`; `--allowedTools` missing-value string was plain. Fix: prefix `parse_permission_mode_arg` error with `invalid_flag_value:` + `\n` valid-values hint (both call sites); prefix `--allowedTools` missing-value with `missing_flag_value:` + `\n` usage hint. Both now classified by existing `missing_flag_value`/`invalid_flag_value` arms added in #756. Source: Jobdori dogfood on `4df14618`, 2026-05-26.
758. **Three remaining `missing value for --X` strings in `parse_init_args` were still untyped** — dogfooded 2026-05-26 on `02d77ae1`. `--cwd`, `--date`, `--session` missing-value errors in the init-args parser used the old plain-string form with no `missing_flag_value:` prefix and no `\n` hint, unlike the main `parse_args` flags fixed in #756/#757. Fix: applied `missing_flag_value:` prefix + `\n` usage hint to all three. `grep '"missing value for --'` now returns zero results outside of test assertions. Source: Jobdori dogfood sweep on `02d77ae1`, 2026-05-26.
759. **`--model badmodel --output-format json` returned `error_kind:"invalid_model_syntax"` but `hint: null`** — dogfooded 2026-05-26 on `b8b3af6f`. `validate_model_syntax()` had hint text embedded after a period in the error string (no `\n`), so `split_error_hint()` could not extract it. Affected paths: (a) generic invalid format `"invalid model syntax: '{}'. Expected ..."` — joined with `.` not `\n`; (b) spaces-in-model `"contains spaces. Use ..."` — same issue; (c) empty model string — no hint at all. Fix: added `\n` before hint text in all three format strings in `validate_model_syntax`. Source: Jobdori dogfood sweep on `b8b3af6f`, 2026-05-26.
760. **`agent_not_found` and `plugin_not_found` error envelopes lacked `hint` field** — dogfooded 2026-05-26 on `ef31328a`. `claw agents show nonexistent-agent --output-format json` returned `error_kind:"agent_not_found"` with `hint: null`; same for `claw plugins show`. Both structured JSON envelopes in `commands/src/lib.rs` and `main.rs` omitted `hint`. Fix: added `"hint": "Run \`claw agents list\` to see available agents."` to the `agent_not_found` envelope; `"hint": "Run \`claw plugins list\` to see available plugins."` to the `plugin_not_found` envelope. Source: Jobdori dogfood sweep on `ef31328a`, 2026-05-26.
761. **`mcp show <nonexistent>` and `skills show <nonexistent>` returned `hint: null`** — dogfooded 2026-05-27 on `7fa81b5d`. `server_not_found` envelope in `render_mcp_show_json` and `skill_not_found` envelope in `print_skills` JSON path both lacked `hint` fields, unlike `agent_not_found`/`plugin_not_found` fixed in #760. Fix: added `"hint": "Run \`claw mcp list\` to see configured servers."` to `server_not_found` and `"hint": "Run \`claw skills list\` to see available skills."` to `skill_not_found`. All four `*_not_found` envelopes now have hints. Source: Jobdori dogfood sweep on `7fa81b5d`, 2026-05-27.
762. **`classify_error_kind` unit test missing coverage for 15 of 23 classifier arms** — dogfooded 2026-05-27 on `d83de563`. `classify_error_kind_returns_correct_discriminants` only asserted 8 of the 23 arms, leaving `missing_flag_value`, `invalid_flag_value`, `missing_prompt`, `interactive_only`, `unknown_agents_subcommand`, `agent_not_found`, `plugin_not_found`, `skill_not_found`, `unsupported_config_section`, `no_managed_sessions`, `legacy_session_no_workspace_binding`, `missing_manifests`, `unknown_plugins_action`, `unsupported_skills_action`, and `confirmation_required` uncovered. Any discriminant string drift would silently fall to `"unknown"` without a failing test. Fix: added 18 new `assert_eq!` invocations covering all previously untested arms. Source: Jobdori test-brittleness sweep on `d83de563`, 2026-05-27.
763. **Config JSON parse errors fall to `error_kind:"unknown"`** — dogfooded 2026-05-27 on `88ce1810`. Malformed `.claw/settings.json` or `.claw.json` (unterminated string, type mismatch, unknown keys) produce serde_json errors like `"/path/.claw/settings.json: expected ',', found end of input"` but classify as `error_kind:"unknown"` + `hint:null`. Callers must regex the error message to route. Fix: added `config_parse_error` classifier arm that matches on presence of `.claw/settings.json` or `.claw.json` in the error message. All three error patterns now consistently produce `error_kind:"config_parse_error"`. Test coverage added. Source: Jobdori event/log opacity probe on `88ce1810`, 2026-05-27.
764. **`config_parse_error` returned `hint: null` despite #763 adding the classifier** — dogfooded 2026-05-27 on `c86dc73d`. #763 fixed `error_kind` classification but `hint` remained `null` because `ConfigError::Parse` Display impl emitted only the bare serde_json error string (no `\n` delimiter). `split_error_hint()` found nothing to split. Fix: updated `Display for ConfigError::Parse` in `runtime/src/config.rs` to append `\nFix: open the file shown above and correct the JSON syntax, then retry.`. Integration test `config_parse_error_has_typed_error_kind_and_hint_764` added to `output_format_contract.rs` asserting non-zero exit + `error_kind:config_parse_error` + non-empty hint. 31 contract tests pass. Source: Jobdori follow-up probe on `c86dc73d`, 2026-05-27.
765. **`claw login`/`claw logout` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `4ea255ca` (gaebal-gajae pinpoint against `88ce1810`, revised ID after #763/#764 landed). `removed_auth_surface_error()` emitted single-line string with no `\n` delimiter; `split_error_hint()` couldn't extract hint, and no `removed_subcommand` classifier arm existed. Fix: (1) `removed_auth_surface_error()` now emits two-line format (`has been removed.\nSet ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead.`); (2) `classify_error_kind()` arm added matching `has been removed.`; (3) unit test assertions and integration test `login_logout_removed_subcommands_have_error_kind_and_hint_765` added verifying both `error_kind:removed_subcommand` and non-null hint mentioning the env var migration. 32 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae + Jobdori probe on `4ea255ca`, 2026-05-27.
766. **`claw diff <extra>` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `d29a8e21`. `claw diff --bogus --output-format json` emitted bare error string `"unexpected extra arguments after \`claw diff\`: --bogus"` with no `\n` delimiter and no classifier arm. Fix: (1) added `\nUsage: claw diff` to the error format string; (2) added `unexpected_extra_args` classifier arm matching `starts_with("unexpected extra arguments")`; (3) unit test assertion + integration test `diff_extra_args_have_typed_error_kind_and_hint_766` added. 33 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori probe on `d29a8e21`, 2026-05-27.
767. **`claw session bogus --output-format json` ignores JSON flag and falls through to credential check** — dogfooded 2026-05-27 on `d29a8e21`. `claw --output-format json session bogus` dispatches to the full interactive REPL runtime instead of rejecting `bogus` as an unknown session subcommand. Output is `error_kind:"missing_credentials"` rather than `error_kind:"unknown_session_subcommand"`. Root cause: `session` arg parser has no unknown-subcommand guard before dispatch; `bogus` is silently accepted as a session ID / switch target and reaches the credential-check gate. Fix needed: validate known session subcommands (`list`, `exists`, `switch`, `fork`, `delete`) before dispatch, return structured `unknown_session_subcommand` error for unrecognized tokens. [SCOPE: claw-code] Source: Jobdori probe on `d29a8e21`, 2026-05-27.
768. **`claw --resume latest compact` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `89735dbd` (gaebal-gajae pinpoint against `d29a8e21`, revised ID after #766/#767 landed). Resume trailing-arg validator emitted single-line `"--resume trailing arguments must be slash commands"` with no typed prefix and no `\n` hint. Fix: (1) changed error to `"invalid_resume_argument: \`{token}\` is not a slash command.\nUsage: claw --resume <session-id|latest> /<slash-command>"` so `split_error_hint()` extracts the hint; (2) added `invalid_resume_argument` classifier arm; (3) unit test assertion + integration test `resume_non_slash_trailing_arg_has_typed_error_kind_and_hint_768` added. 34 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae + Jobdori probe on `89735dbd`, 2026-05-27.
769. **`claw session bogus` fell through to credential check instead of interactive-only guidance** — dogfooded 2026-05-27 on `b778d4e3` (tracked as #767). `claw session <anything>` with more than one token bypassed `parse_single_word_command_alias` (which only fires for `rest.len()==1`) and had no match arm in `parse_args`, so `rest.join(" ")` became a prompt literal dispatched to `CliAction::Prompt`, hitting `missing_credentials` at the gate. Fix: added `"session"` match arm that emits `interactive_only:` error with `\n`-delimited hint referencing `--resume SESSION.jsonl /session` and REPL usage. Integration test `session_with_unknown_subcommand_returns_interactive_only_not_credentials_767` asserts `error_kind:interactive_only` + non-null hint for `bogus`, `nuke`, `delete-all`. 35 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori probe on `b778d4e3`, 2026-05-27.
770. **`claw cost/clear/memory/ultraplan/model` with trailing args fell to credential check** — dogfooded 2026-05-27 on `9e1be056`. Same fallthrough gap as #767/#769: these slash-only verbs had no multi-arg match arms, so `claw cost breakdown`, `claw clear --force`, `claw memory reset`, `claw ultraplan bogus`, `claw model opus extra` all became `CliAction::Prompt` literals, hitting `missing_credentials` at the gate. Fix: added `"cost"`, `"clear"`, `"memory"`, `"ultraplan"`, `"model" if rest.len() > 1` match arms, each returning `interactive_only:` + `\n`-delimited hint. Integration test `slash_only_verbs_with_args_return_interactive_only_not_credentials_770` asserts all five cases. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori sweep on `9e1be056`, 2026-05-27.
771. **`init extraarg` silently succeeded; `usage`/`stats`/`fork` with args fell to credential check** — dogfooded 2026-05-27 on `3a1d8838`. Two distinct gaps: (1) `claw init extraarg` returned `status:ok` with trailing positional ignored — `"init"` arm always returned `Ok(CliAction::Init)` regardless of `rest[1..]`; (2) `claw usage extra`, `claw stats extra`, `claw fork newbranch` had no match arms and fell to `CliAction::Prompt` + credential gate. Fixes: (1) added extra-arg check in `"init"` arm — rejects with `unexpected_extra_args:` prefix + `\n` usage hint; (2) added `"usage"`, `"stats"`, `"fork"` interactive-only arms. All four now return correct `error_kind` + non-null hint. 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori sweep on `3a1d8838`, 2026-05-27.
772. **Slash command aliases bypassed `bare_slash_command_guidance` lookup** — dogfooded 2026-05-27 on `bf212b98`. `bare_slash_command_guidance()` only checked `spec.name == command_name`, not `spec.aliases`, so `claw yes`, `claw no`, `claw y`, `claw n`, `claw skill`, `claw cwd` all fell through (either to typo suggestions or `missing_credentials`). Should have returned `interactive_only:` guidance referencing the canonical form. Fix: (1) lookup changed to `spec.name == command_name || spec.aliases.contains(&command_name)`; (2) capture `canonical_name = slash_command.name`; (3) guidance strings updated to reference canonical form in remediation (e.g., `claw yes → /approve`, `claw n → /deny`, `claw skill → /skills`). 36 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint on `bf212b98`, 2026-05-27.
773. **Config deprecation warnings only emitted as unstructured stderr text in `--output-format json` mode** — dogfooded 2026-05-27 on `212f0b2a`. `emit_config_warning_once()` always wrote to stderr regardless of output format, causing JSON-mode callers to receive an unexpected `warning: ...` text line on stderr before the JSON object. Callers had to implement ad-hoc stripping. Fix: added `ConfigLoader::load_collecting_warnings()` method that returns `(RuntimeConfig, Vec<String>)` so callers can surface warnings structurally; `render_config_json()` now uses this and includes a `warnings: []` array in the config JSON envelope. Existing `load()` path unchanged (still emits to stderr for text-mode callers). 36 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori startup-friction probe on `212f0b2a`, 2026-05-27.
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.
777. **Resumed `/plugins install|enable|disable|uninstall|update` returned opaque error_kind instead of interactive_only** — dogfooded 2026-05-27 on `2684737d` (pinpoint by Gaebal-gajae). The mutation arm in `run_resume_command` returned a bare single-line error; after #776 it was classified/split by the caller but fell to `error_kind:"unknown"` + `hint:null` because there was no `interactive_only:` prefix. Orchestrators had no stable signal to distinguish "command rejected — switch to REPL" from a transient error. Fix: each mutation verb now returns `interactive_only: /plugins {action} requires a live session...\n...hint...` so the caller emits `error_kind:"interactive_only"` + non-null hint pointing at REPL or direct CLI. Integration test `resume_plugin_mutations_are_typed_interactive_only_777` covers all 5 mutation verbs. 39 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `2684737d`, 2026-05-27.
778. **`claw doctor --output-format json` check objects had no `hint` field — all warn/fail remediation was buried in `details_prose`** — dogfooded 2026-05-27 on `e0203036`. Automation had to parse prose strings to find remediation text instead of reading a stable `hint` field. `DiagnosticCheck.json_value()` never emitted a `hint` field. Fix: added `hint: Option<String>` field to `DiagnosticCheck`, added `with_hint()` builder, populated for all warn/fail cases (auth: set env var; config: fix JSON syntax; workspace: git init; boot_preflight: install missing binaries; sandbox: expected on non-Linux). Empty hint string collapses to `null` (ok checks). 39 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori doctor-envelope probe on `e0203036`, 2026-05-27.
779. **Resumed `/skills <skill>` invocation returned bare prose → `error_kind:"unknown"` + `hint:null` after #776** — dogfooded 2026-05-27 on `fded4f6b` (pinpoint by Gaebal-gajae). Sibling of #777: the `/skills` invoke-dispatch guard emitted a single-line prose error identical in structure to the pre-#777 plugins mutation guard. After #776's classify/split it fell to `unknown+null` because no `interactive_only:` prefix was present. Fix: replaced with `interactive_only: /skills {skill_name} invocation requires a live session.\n...hint...` format. Integration test `resume_skills_invocation_is_typed_interactive_only_779` added. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `fded4f6b`, 2026-05-27.
780. **`classify_error_kind` arm ordering bug: `"failed to restore session: legacy session is missing workspace binding: ..."` classified as `session_load_failed` instead of `legacy_session_no_workspace_binding`** — dogfooded 2026-05-27 on `364e7909`. The full error message from `resume_session` prepends `"failed to restore session: "` before `"legacy session is missing workspace binding: ..."`. The `contains("failed to restore session")` arm at line 278 matched first, returning `session_load_failed`; the more specific `legacy_session_no_workspace_binding` arm at line 282 was never reached. Same shadowing existed for `no_managed_sessions`. Fix: reordered the three arms — specific cases (`no_managed_sessions`, `legacy_session_no_workspace_binding`) before the generic `session_load_failed` catch-all. Unit test updated to assert corrected discriminants, plus new assertion covering the full prefixed message that exposed the bug. 40 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori classifier-ordering probe on `364e7909`, 2026-05-27.
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.
784. **`claw export` had two opaque arg-error paths returning `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `81fe0ccb` (pinpoint by Gaebal-gajae). `claw export --output` (missing flag value) emitted plain `"missing value for --output"` with no typed prefix; `claw export a.md b.md` (extra positional) emitted plain `"unexpected export argument: second.md"`. Both classified as `unknown+null`. Fix: (1) `--output` missing-value error now uses `missing_flag_value:` prefix + `\n` usage hint; (2) extra positional now uses `unexpected_extra_args:` prefix + `\n` usage hint; (3) classifier `unexpected_extra_args` arm extended to match both `starts_with("unexpected extra arguments")` (prose form, #766) and `starts_with("unexpected_extra_args:")` (typed prefix form, #784). Integration test `export_arg_errors_have_typed_kind_and_hint_784` covers both paths. 43 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `81fe0ccb`, 2026-05-27.
785. **`claw dump` (typo/near-miss for dump-manifests) returned `error_kind:"unknown"` — no classifier arm for `"unknown subcommand:"` prose prefix** — dogfooded 2026-05-27 on `e628b4bb`. Any unknown top-level subcommand that triggers the suggestion path emitted `"unknown subcommand: <x>.\nDid you mean <y>"` but `classify_error_kind` had no arm for that prefix; all fell to the `"unknown"` catch-all. The hint was non-null (the suggestion text was extracted by `split_error_hint`) but `error_kind` was undifferentiated. Fix: added `starts_with("unknown subcommand:")``"unknown_subcommand"` arm. Unit test assertion + integration test `unknown_subcommand_returns_typed_kind_785` using `claw dump` as the trigger. 44 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori subcommand-classifier probe on `e628b4bb`, 2026-05-27.
786. **`claw dump-manifests --manifests-dir` (missing value) and `--manifests-dir=` (empty) both returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `87f43347` (pinpoint by Gaebal-gajae). Both missing `--manifests-dir` branches in `parse_dump_manifests_args` emitted plain `"--manifests-dir requires a path"` with no typed prefix; `classify_error_kind` had no matching arm so they fell to `"unknown"`. Fix: both branches now use `missing_flag_value:` prefix + `\n` usage hint. Integration test `dump_manifests_missing_dir_has_typed_kind_and_hint_786` covers both cases. 45 CLI contract tests pass. [SCOPE: claw-code] Source: Gaebal-gajae pinpoint + Jobdori implementation on `87f43347`, 2026-05-27.
787. **`claw --resume /tmp` (directory path) returned `error_kind:"session_load_failed"` + `hint:null`; resume error emission sites didn't apply `fallback_hint_for_error_kind`** — dogfooded 2026-05-27 on `22b423b6`. Two gaps: (1) the OS error `"Is a directory (os error 21)"` had no classifier arm, falling to generic `session_load_failed`; (2) both resume error emission paths (session load at line 3338, command execution at line 3484) called `split_error_hint` but not `fallback_hint_for_error_kind`, so API-layer errors with no `\n` always got `hint:null`. Fix: added `session_path_is_directory` classifier arm for `"Is a directory"` / `"os error 21"` messages; added `fallback_hint_for_error_kind` fallback to both resume error sites; added `session_path_is_directory` and `session_load_failed` to `fallback_hint_for_error_kind`. Unit test + integration test `resume_directory_path_returns_typed_kind_and_hint_787`. 46 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori resume-path probe on `22b423b6`, 2026-05-27.
788. **`claw --output-format json skills show <not-found>` emitted two JSON objects — one from the skills handler, one duplicate from the top-level error path** — dogfooded 2026-05-27 on `113145a4`. `print_skills` in JSON mode called `println!` to emit the `skill_not_found` error envelope, then returned `Err(...)`. The `?` propagation triggered the top-level error handler which emitted a second `action:"abort"` JSON envelope on stderr. Callers reading both stdout and stderr got two JSON objects with the same `error_kind` but different `action` fields — the first was the authoritative response, the second was a duplicate. Fix: replaced `return Err(...)` with `std::process::exit(1)` after the skills error JSON is emitted, mirroring the existing `is_help_action` guard pattern. Integration test `skills_show_not_found_emits_single_json_object_788` asserts exactly 1 JSON object on stdout and no JSON on stderr. 47 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills double-emission probe on `113145a4`, 2026-05-27.
789. **`claw --output-format json agents show <not-found>` and `plugins show <not-found>` both returned exit 0 despite `status:"error"` in the JSON** — dogfooded 2026-05-27 on `abdbf61a`. Skills was fixed in #788 (exit 1 via process::exit). Agents and plugins had the identical gap: `print_agents` had no error check at all (just println + Ok(())); `print_plugins`'s not-found branch used `return Ok(())`. MCP was already fixed in an earlier cycle (#68). Fix: added `is_error` check in `print_agents` JSON path (exit 1 when status=="error"); changed plugins not-found branch from `return Ok(())` to `std::process::exit(1)`. Existing `inventory_commands_emit_structured_json_when_requested` test updated to use `run_claw` directly for the not-found case. Two new tests added: `agents_show_not_found_exits_nonzero_789`, `plugins_show_not_found_exits_nonzero_789`. 49 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori exit-code consistency probe on `abdbf61a`, 2026-05-27.
790. **`claw --output-format json system-prompt <unknown-option>` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `e4c3c1aa`. The unknown-option branch in `parse_print_system_prompt_args` emitted plain `"unknown system-prompt option: {other}"` for all unrecognised options except `--json` (which appended a `\n`-delimited suggestion). All non-`--json` cases fell to `unknown+null`. Fix: replaced bare format string with `unknown_option: ... \n<usage hint>` format for all unknown options; `--json` special case preserves its `--output-format json` suggestion in the hint prefix. Integration test `system_prompt_unknown_option_returns_typed_kind_790` covers both paths. 50 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori system-prompt option probe on `e4c3c1aa`, 2026-05-27.
791. **`claw config show <extra>` and `claw config set <extra1> <extra2>` returned `unexpected_extra_args` + `hint:null`** — dogfooded 2026-05-27 on `9968a27e`. The config arg parser emitted `"unexpected extra arguments after `claw config {}`: {}"` with no `\n` delimiter, so `split_error_hint` returned `None` and `fallback_hint_for_error_kind("unexpected_extra_args")` also returns `None`. Fix: appended `\nUsage: claw config [env|hooks|model|plugins|mcp|settings]` to the error format string. Integration test `config_extra_args_have_non_null_hint_791` covers both paths. 51 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori config-arg probe on `9968a27e`, 2026-05-27.
792. **`claw agents list --bogus-flag` and `claw skills list --bogus-flag` silently returned `status:"ok" count:0` instead of an error** — dogfooded 2026-05-27 on `93a159dc`. The `list <filter>` arm in both handlers treated flag-shaped tokens (`--something`) as name substring filters. Since no agents/skills have `--bogus` in their name, result was empty success list — a false positive that masks typos and unknown flags. Fix: added flag-prefix guard at the top of both `list <args>` arms in `commands/src/lib.rs`; detected filter tokens starting with `-` return `unknown_option` + usage hint. Two new integration tests `agents_list_flag_shaped_filter_returns_unknown_option_792`, `skills_list_flag_shaped_filter_returns_unknown_option_792`. 53 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills list probe on `93a159dc`, 2026-05-27.
793. **`claw plugins list --bogus-flag` silent empty success + `plugins uninstall <not-found>` had `hint:null`** — dogfooded 2026-05-27 on `abfa2e4c`. Two gaps: (1) `plugins list` filter branch in `print_plugins` treated `--bogus-flag` as an id substring filter, found no matches, returned `status:"ok"` empty list — same false-positive as #792 for agents/skills. (2) `plugins uninstall no-such` propagated `plugin_not_found` error via `?` with no `\n` delimiter; `plugin_not_found` was missing from `fallback_hint_for_error_kind` table. Fix: (1) added flag-prefix guard in `print_plugins` `is_list_action` branch (detects tokens starting with `-`, returns `unknown_option` + usage hint, exits 1); (2) added `"plugin_not_found"``"Run 'claw plugins list' to see installed plugins."` to fallback table. Two new tests `plugins_list_flag_shaped_filter_returns_unknown_option_793`, `plugins_uninstall_not_found_has_hint_793`. 55 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori plugins lifecycle probe on `abfa2e4c`, 2026-05-27.
794. **`claw plugins install /nonexistent/path` returned `error_kind:"unknown"` + `hint:null`** — dogfooded 2026-05-27 on `57a57ef7`. The error message `"plugin source '/path' was not found"` had no classifier arm, falling to `"unknown"`. Fix: added `plugin_source_not_found` classifier arm (`message.contains("plugin source") && message.contains("was not found")`); added `"plugin_source_not_found"``"Check that the path or URL is correct..."` to `fallback_hint_for_error_kind`. Unit test assertion added to `test_classify_error_kind`; integration test `plugins_install_not_found_path_returns_typed_kind_794` added. 56 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori plugins install probe on `57a57ef7`, 2026-05-27.
795. **`claw skills install /nonexistent` returned `skill_not_found + hint:null` and `claw skills uninstall x` returned `unsupported_skills_action + hint:null`** — dogfooded 2026-05-27 on `491f179a`. Both error kinds were missing from `fallback_hint_for_error_kind` table, so even though classify returned a typed kind, the hint field was always null. Fix: added `"skill_not_found"` → hint suggesting `claw skills list` / `claw skills install`; added `"unsupported_skills_action"` → hint listing supported actions. Integration test `skills_install_not_found_and_unsupported_action_have_hints_795` covers both paths. 57 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori skills lifecycle probe on `491f179a`, 2026-05-27.
796. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` returned confusing `agent_not_found`/`skill_not_found` for the concatenated "name extra" string** — dogfooded 2026-05-27 on `18b4cee5`. `join_optional_args` passes all tokens as a space-joined string; both `show` handlers called `split_once(' ')` to extract the name but did not check if the remainder (after the first split) contained additional tokens. Extra positional args (including `--flags`) became part of the "name", silently mangling the lookup. Fix: added second `split_once(' ')` on the extracted name; if the result has two parts, return `unexpected_extra_args` with a usage hint. Valid single-name lookups are unaffected. Two new integration tests `agents_show_extra_positional_arg_returns_unexpected_extra_796`, `skills_show_extra_positional_arg_returns_unexpected_extra_796`. 59 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori agents/skills show extra-arg probe on `18b4cee5`, 2026-05-27.
797. **Installed `claw version --output-format json` reports `git_sha:null` / `Git SHA unknown`, so dogfood cannot tie the binary under test to a source revision** — dogfooded 2026-05-27 from `#clawcode-building-in-public` using the installed `/home/bellman/.cargo/bin/claw` binary in a clean `ultraworkers/claw-code` checkout. `claw version --output-format json` returned `{"kind":"version","version":"0.1.0","git_sha":null,"target":null,"build_date":"2026-03-31"...}` while `claw status --output-format json` only reported workspace state (`git_branch`, clean/dirty counts) and did not provide any executable-vs-workspace provenance comparison. This is a clawability gap in event/log opacity and stale-binary confusion: an operator can run `doctor/status/version` successfully but still cannot prove which commit the installed CLI came from, whether it matches `origin/main`, or whether the observed behavior is from a stale packaged binary. **Required fix shape:** (a) embed build git SHA/target/build provenance in installed/release binaries whenever the source tree is available; (b) when provenance is missing, emit a typed `binary_provenance.status:"unknown"` rather than only `git_sha:null`; (c) have `status`/`doctor` include a redaction-safe comparison between executable provenance and workspace HEAD when running inside a git checkout; (d) add regression/packaging coverage proving release/local install paths preserve or explicitly classify provenance. **Why this matters:** dogfood reports and automation need to distinguish current-source failures from stale or unknown binary lineage before opening/rebasing/closing PRs. Source: gaebal-gajae live dogfood on 2026-05-27; active repo checkout had open PR #3124 DIRTY with no checks and PR #3125 CLEAN, but the installed binary itself could not identify its source revision.
798. **`claw plugins show <name> <extra-arg>` returned `unexpected_extra_args` + `hint:null`** — dogfooded 2026-05-27 on `9976585f`. The plugins arg parser at the top level emitted `"unexpected extra arguments after 'claw plugins show ...': ..."` with no `\n` delimiter (parity gap with #791 config fix). Fix: appended `\nUsage: claw plugins [list|show <id>|...]` to the error format string. Integration test `plugins_extra_args_have_non_null_hint_797`. Committed as `bff37000`. 60 CLI contract tests pass. [SCOPE: claw-code]
799. **`claw --output-format json ""` and `claw " "` returned `empty_prompt` + `hint:null`** — dogfooded 2026-05-27 on `bff37000`. The empty-prompt guard at the fallthrough path emitted `"empty prompt: provide a subcommand..."` with no `\n` delimiter. Fix: added `\n` + usage hint. Integration test `empty_prompt_has_non_null_hint_798`. Committed as `efb1542a`. 61 CLI contract tests pass. [SCOPE: claw-code]
800. **`classify_error_kind` unit test coverage gap: `invalid_history_count` and `unknown_option` arms had zero assertions** — found 2026-05-27 on `efb1542a`. Audit of 39 distinct classifier return values vs 37 unit test assertions revealed 2 untested arms. Fix: added 3 `assert_eq!` covering both arms (invalid_history_count prefix + contains paths, unknown_option prefix). Committed as `6ee67d6c`. All 39 return values now have unit test coverage. [SCOPE: claw-code]
801. **`claw --output-format json diff` in a non-git directory was missing `error_kind`, `hint`, and `message` fields** — dogfooded 2026-05-27 on `1201dc60`. The diff handler's no-git-repo JSON branch constructed a custom object with only `status:"error"` + `result:"no_git_repo"` + `detail`, violating the error envelope contract that every error has `error_kind` + `hint`. Fix: added `error_kind: "no_git_repo"`, `hint: "Run git init..."`, and `message` fields. Integration test `diff_non_git_dir_has_error_kind_and_hint_801`. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori non-git-dir probe on `1201dc60`, 2026-05-27.
802. **Four `status:"error"` JSON sites in resume-mode and broad-cwd handlers were missing `hint` field** — found 2026-05-27 on `53953a81` via source audit of all `"status": "error"` sites in main.rs. The resume `unsupported_command` (L3433), `unsupported_resumed_command` (L3455), `cli_parse` (L3474), and `broad_cwd` (L4838) handlers all emitted JSON error envelopes with `error_kind` but no `hint` field. Fix: added contextual `hint` string to all four sites. Source audit now shows 0 `status:"error"` JSON objects missing `hint` across entire main.rs. 62 CLI contract tests pass. [SCOPE: claw-code] Source: Jobdori source-level audit of all error JSON sites, 2026-05-27.
803. **`claw agents list --bogus`, `skills list --bogus`, and `plugins list --bogus` in text mode silently returned empty success** — dogfooded 2026-05-27 on `fcebf644`. The JSON-mode flag guards added in #792/#793 only covered the JSON branch; the text-mode path through `handle_agents_slash_command`, `handle_skills_slash_command`, and `print_plugins` still passed flag-shaped tokens as substring filters. Fix: added flag-prefix guards to all three text-mode list handlers (agents and skills in `commands/src/lib.rs`, plugins in `main.rs print_plugins`). Also removed the now-redundant JSON-only guard from print_plugins (the early guard catches both modes). Updated `plugins_list_flag_shaped_filter_returns_unknown_option_793` test to check stderr. 62 CLI contract tests pass. [SCOPE: claw-code]
804. **`claw agents show <name> <extra>` and `claw skills show <name> <extra>` in text mode returned wrong `agent_not_found`/silent empty instead of catching extra args** — dogfooded 2026-05-27 on `bad1b97f`. Parity gap with JSON-mode fix #796: the text-mode show handlers in `commands/src/lib.rs` still used single-split `split_once(' ')` without checking for spaces in the extracted name. Fix: added `contains(' ')` guard to both text-mode show arms; extra tokens now return `unexpected extra arguments` with usage hint. 62 CLI contract tests pass. [SCOPE: claw-code]
805. **`claw skills show <not-found>` in text mode silently returned "No skills found." instead of an error** — dogfooded 2026-05-27 on `2c3c0f60`. The text-mode show handler in `handle_skills_slash_command` returned `render_skills_report(&matched)` with an empty vec instead of checking for empty match and returning an error. JSON mode already returned `skill_not_found` since #706. Fix: added `matched.is_empty()` guard with `skill_not_found` error + `\n` hint suggesting `claw skills list`. 62 CLI contract tests pass. [SCOPE: claw-code]
806. **`claw plugins show <not-found>` in text mode returned "No plugins installed." instead of an error** — dogfooded 2026-05-27 on `ae6a207d`. The text-mode path in `print_plugins` printed `payload.message` (the full list render) without checking if the requested plugin existed. JSON mode correctly returned `plugin_not_found`. Fix: added show-action filtering + not-found guard to text-mode path; added `starts_with("plugin_not_found:")` arm to classifier for the new error prefix. 63 CLI contract tests pass. [SCOPE: claw-code]
807. **`claw models` / `claw model` with `--output-format json` hang with zero stdout instead of returning bounded model discovery/help JSON or a typed unsupported response** — dogfooded 2026-05-27 on `ae6a207` while checking docs/usage model-alias surface after PR #3162 opened. Both `cargo run -q -p rusty-claude-cli -- models --output-format json` and the actual rebuilt `./rust/target/debug/claw models --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained config deprecation warnings. The same silent timeout reproduced for `models help --output-format json`, `model --output-format json`, and `model help --output-format json`. **Required fix shape:** (a) make `model(s)` help/list/discovery commands return bounded stdout JSON without entering prompt/provider/auth paths; (b) if the command is unsupported, return a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message`; (c) ensure docs model-alias tables and CLI model discovery surfaces do not diverge; (d) add regression coverage for `models --output-format json`, `models help --output-format json`, `model --output-format json`, and `model help --output-format json` proving they do not hang or emit zero-byte stdout. **Why this matters:** model selection is a setup/control-plane surface. If the natural model discovery commands hang silently, claws cannot verify aliases like `qwen-max` / `qwen-plus`, distinguish unsupported command spelling from provider startup, or safely guide users during first-run model setup. Source: gaebal-gajae 13:30/14:00 dogfood probe; GitHub issue creation was blocked by API rate limit, so the finding was recorded directly in ROADMAP.
808. **Control-plane commands `claw config`, `claw settings`, `claw status`, and `claw doctor` with `--output-format json` hang with zero stdout instead of returning bounded JSON/help or a typed unsupported envelope** — dogfooded 2026-05-27 on `86f45a1` after ROADMAP #807 landed. Each of `./rust/target/debug/claw config --output-format json`, `config help --output-format json`, `settings --output-format json`, `settings help --output-format json`, `status --output-format json`, and `doctor --output-format json` timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. **Required fix shape:** keep non-interactive control-plane/info commands out of prompt/provider startup paths; return bounded JSON stdout for supported status/config/help surfaces, or a standard typed JSON error envelope with `error_kind`, non-null `hint`, and `message` for unsupported spellings; add timeout/nonzero-stdout regression coverage for the six repro commands. **Why this matters:** claws and users need first-run diagnostics/config/status surfaces that are safe to call from scripts. Silent hangs make setup triage indistinguishable from provider startup, auth, or model discovery failures. Source: gaebal-gajae 17:00 dogfood probe; rechecked 17:30 after `cargo build --manifest-path rust/Cargo.toml -p rusty-claude-cli` produced `claw --version` Git SHA `23a7de6`, and the same timeout reproduced for current HOME and a clean `HOME=/tmp/claw-clean-home-1730` (clean HOME produced rc 124, stdout 0, stderr 0 for `config`, `status`, and `doctor`). [SCOPE: claw-code]
809. **Top-level help/version/MCP/plugin JSON spellings hang with zero stdout in trailing `--output-format json` form instead of returning bounded JSON/help or typed unsupported envelopes** — dogfooded 2026-05-27 on rebuilt main `db81598` (`cargo build --manifest-path rust/Cargo.toml -p rusty-claude-cli`; `claw --version` Git SHA `db81598`). `help --output-format json`, `version --output-format json`, `mcp --output-format json`, `mcp help --output-format json`, `plugins --output-format json`, and `plugins help --output-format json` each timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. Leading global-style probes (`--help --output-format json`, `--version --output-format json`) fail immediately as `[error-kind: cli_parse] unknown option`, so the hang is again in the trailing subcommand-style routing/startup path. **Required fix shape:** treat help/version/MCP/plugin discovery surfaces as bounded non-interactive control-plane commands; either return JSON help/list/version payloads or standard typed JSON unsupported envelopes with `error_kind`, non-null `hint`, and `message`; add timeout/nonzero-stdout regression coverage for the six trailing repro commands and parser-envelope coverage for leading global-style spellings. **Why this matters:** claws need safe scriptable help/version/plugin/MCP discovery before provider/session startup; silent hangs hide whether a command is unsupported, misparsed, or initializing runtime state. Source: gaebal-gajae 19:00 dogfood probe. [SCOPE: claw-code]
810. **TTY JSON success for `config`/`plugins --output-format json` contaminates stdout with deprecated-settings warnings before the JSON object** — dogfooded 2026-05-27 on rebuilt main `db81598` after #809. Under pseudo-TTY (`script -q -c "./rust/target/debug/claw config --output-format json"` and `plugins --output-format json`), the commands return rc `0` and bounded JSON, but stdout begins with `warning: /home/bellman/.claw/settings.json: field "enabledPlugins" is deprecated ...` before the JSON object (`first_json_index=121`). Parsing succeeds only after manually stripping the warning/prefix; raw stdout is not valid JSON. **Required fix shape:** in JSON mode, keep diagnostics/warnings on stderr or include structured warning fields inside the JSON envelope, but never prepend human warnings to stdout; add regression coverage that raw stdout from JSON commands parses from byte 0 under TTY and non-TTY modes. **Why this matters:** even when the TTY path avoids the hang from #807/#808/#809, claws and scripts still cannot safely `json.loads(stdout)` if configuration warnings are mixed into stdout. Source: gaebal-gajae 20:00 pseudo-TTY dogfood probe. [SCOPE: claw-code]
811. **Previously typed JSON error/list surfaces hang in plain non-TTY trailing `--output-format json` form instead of emitting their JSON envelopes** — dogfooded 2026-05-27 on rebuilt main `b0e94c9` after #810. In plain non-TTY automation, `agents list --bogus --output-format json`, `skills show does-not-exist --output-format json`, `plugins show does-not-exist --output-format json`, `diff --output-format json`, `sessions show does-not-exist --output-format json`, and `resume bogus --output-format json` each timed out under an 8s outer timeout with stdout `0`; stderr only contained the local deprecated `enabledPlugins` settings warning. Several of these surfaces had prior roadmap fixes for typed JSON/text envelopes, so this is a regression-class scriptability gap: the command-specific envelope may exist, but plain non-TTY trailing JSON invocation routes into interactive startup before reaching it. **Required fix shape:** ensure trailing `--output-format json` is honored before any interactive/provider/session startup for error/list surfaces; add plain non-TTY timeout regression coverage that asserts raw stdout is a parseable typed JSON envelope for the six repro commands, including `error_kind`, non-null `hint`, and `message` where applicable. **Why this matters:** claws primarily invoke CLI checks from non-TTY automation; a fix that only works in manual/TTY mode still leaves JSON error handling unusable for agents. Source: gaebal-gajae 20:30 dogfood probe. [SCOPE: claw-code]

View File

@@ -17,10 +17,7 @@ covered by the Claw Code 2.0 board.
- Hook: `.github/hooks/pre-push` - Hook: `.github/hooks/pre-push`
- Install command: `git config core.hooksPath .github/hooks` - Install command: `git config core.hooksPath .github/hooks`
- Gate: `cargo build --manifest-path rust/Cargo.toml --workspace --locked` - Gate: `cargo build --manifest-path rust/Cargo.toml --workspace`
- Escape hatch: `SKIP_CLAW_PRE_PUSH_BUILD=1` prints an explicit skip message.
- Regression test: `tests/test_pre_push_hook_contract.py` locks the skip
hatch and `--locked` build command contract.
- Purpose: mirror the CI build job locally so stale field/variant references are - Purpose: mirror the CI build job locally so stale field/variant references are
caught before push. caught before push.
@@ -43,9 +40,8 @@ python3 scripts/generate_cc2_board.py
python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json
python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json
bash -n .github/hooks/pre-push bash -n .github/hooks/pre-push
python3 tests/test_pre_push_hook_contract.py -v
cargo fmt --manifest-path rust/Cargo.toml --all -- --check cargo fmt --manifest-path rust/Cargo.toml --all -- --check
cargo test --manifest-path rust/Cargo.toml -p claw-analog rag_response_ -- --nocapture cargo test --manifest-path rust/Cargo.toml -p claw-analog rag_response_ -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p runtime startup_preflight -- --nocapture cargo test --manifest-path rust/Cargo.toml -p runtime startup_preflight -- --nocapture
cargo build --manifest-path rust/Cargo.toml --workspace --locked cargo build --manifest-path rust/Cargo.toml --workspace
``` ```

1
rust/Cargo.lock generated
View File

@@ -2124,7 +2124,6 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"telemetry", "telemetry",
"tempfile",
"tokio", "tokio",
"walkdir", "walkdir",
] ]

View File

@@ -273,10 +273,7 @@ impl Display for ApiError {
} }
} }
if let Some(hint) = hint { if let Some(hint) = hint {
// #754: newline-delimited so split_error_hint() can extract the hint write!(f, " — hint: {hint}")?;
// into the JSON envelope's `hint` field. The em-dash form was a
// single-line string that left hint:null in --output-format json.
write!(f, "\n{hint}")?;
} }
Ok(()) Ok(())
} }
@@ -611,16 +608,11 @@ mod tests {
rendered.starts_with("missing Anthropic credentials;"), rendered.starts_with("missing Anthropic credentials;"),
"hint should be appended, not replace the base message: {rendered}" "hint should be appended, not replace the base message: {rendered}"
); );
// #754: hint is now newline-delimited so split_error_hint() can extract it let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
let hint_text = "I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
assert!( assert!(
rendered.ends_with(hint_text), rendered.ends_with(hint_marker),
"rendered error should end with the hint: {rendered}" "rendered error should end with the hint: {rendered}"
); );
assert!(
rendered.contains('\n'),
"rendered error must contain newline separator so split_error_hint works: {rendered}"
);
// Classification semantics are unaffected by the presence of a hint. // Classification semantics are unaffected by the presence of a hint.
assert_eq!(error.safe_failure_class(), "provider_auth"); assert_eq!(error.safe_failure_class(), "provider_auth");
assert!(!error.is_retryable()); assert!(!error.is_retryable());

View File

@@ -1649,15 +1649,10 @@ NO_EQUALS_LINE
rendered.starts_with("missing Anthropic credentials;"), rendered.starts_with("missing Anthropic credentials;"),
"canonical base message should still lead the rendered error: {rendered}" "canonical base message should still lead the rendered error: {rendered}"
); );
// #754: hint delimiter changed from " — hint: " to "\n" so split_error_hint works
assert!( assert!(
rendered.contains("I see OPENAI_API_KEY is set"), rendered.contains(" — hint: I see OPENAI_API_KEY is set"),
"rendered error should carry the env-driven hint: {rendered}" "rendered error should carry the env-driven hint: {rendered}"
); );
assert!(
rendered.contains('\n'),
"rendered error must use newline separator (#754): {rendered}"
);
} }
#[test] #[test]

View File

@@ -1109,33 +1109,25 @@ enum BlockKind {
}, },
} }
const KNOWN_RAG_BOOTSTRAP_PHASES: &[&str] =
&["1-sqlite-no-db", "1-sqlite-empty", "1-sqlite", "2-qdrant"];
fn unknown_bootstrap_phase_error(received_value: Value, message: &str) -> String {
json!({
"kind": "unknown_bootstrap_phase",
"field": "phase",
"received_value": received_value,
"allowed_values": KNOWN_RAG_BOOTSTRAP_PHASES,
"message": message,
})
.to_string()
}
pub(crate) fn format_rag_query_json_for_model(body: &str) -> Result<String, String> { pub(crate) fn format_rag_query_json_for_model(body: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?; let v: Value = serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?;
let phase = v.get("phase").and_then(|x| x.as_str()).ok_or_else(|| { let phase = v.get("phase").and_then(|x| x.as_str()).ok_or_else(|| {
unknown_bootstrap_phase_error( json!({
v.get("phase").cloned().unwrap_or(Value::Null), "kind": "unknown_bootstrap_phase",
"RAG response is missing a string phase; refusing to silently render phase as unknown", "field": "phase",
) "received_value": v.get("phase").cloned().unwrap_or(Value::Null),
"message": "RAG response is missing a string phase; refusing to silently render phase as unknown"
})
.to_string()
})?; })?;
if !KNOWN_RAG_BOOTSTRAP_PHASES.contains(&phase) { if phase.trim().is_empty() || phase == "unknown" {
return Err(unknown_bootstrap_phase_error( return Err(json!({
Value::String(phase.to_string()), "kind": "unknown_bootstrap_phase",
"RAG response phase is not a recognized bootstrap phase", "field": "phase",
)); "received_value": phase,
"message": "RAG response phase must be a concrete phase name"
})
.to_string());
} }
let hits = v let hits = v
.get("hits") .get("hits")
@@ -2594,16 +2586,6 @@ mod tests {
let err = format_rag_query_json_for_model(r#"{"hits":[],"phase":"unknown"}"#).unwrap_err(); let err = format_rag_query_json_for_model(r#"{"hits":[],"phase":"unknown"}"#).unwrap_err();
assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#)); assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#));
assert!(err.contains(r#""received_value":"unknown""#)); assert!(err.contains(r#""received_value":"unknown""#));
assert!(err.contains(r#""field":"phase""#));
}
#[test]
fn rag_response_unrecognized_phase_returns_typed_error() {
let err =
format_rag_query_json_for_model(r#"{"hits":[],"phase":"3-drifted"}"#).unwrap_err();
assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#));
assert!(err.contains(r#""received_value":"3-drifted""#));
assert!(err.contains(r#""allowed_values""#));
} }
#[test] #[test]

View File

@@ -2145,8 +2145,6 @@ struct AgentSummary {
reasoning_effort: Option<String>, reasoning_effort: Option<String>,
source: DefinitionSource, source: DefinitionSource,
shadowed_by: Option<DefinitionSource>, shadowed_by: Option<DefinitionSource>,
// #728: on-disk path so `agents show` can surface the file path
path: Option<PathBuf>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -2156,8 +2154,6 @@ struct SkillSummary {
source: DefinitionSource, source: DefinitionSource,
shadowed_by: Option<DefinitionSource>, shadowed_by: Option<DefinitionSource>,
origin: SkillOrigin, origin: SkillOrigin,
// #729: on-disk path parity with AgentSummary
path: Option<PathBuf>,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -2206,16 +2202,7 @@ pub fn handle_plugins_slash_command(
match action { match action {
None | Some("list") => { None | Some("list") => {
let report = manager.installed_plugin_registry_report()?; let report = manager.installed_plugin_registry_report()?;
let plugins: Vec<_> = if let Some(filter) = target { let plugins = report.summaries();
let needle = filter.to_lowercase();
report
.summaries()
.into_iter()
.filter(|p| p.metadata.id.to_lowercase().contains(&needle))
.collect()
} else {
report.summaries().into_iter().collect()
};
let failures = report.failures(); let failures = report.failures();
Ok(PluginsCommandResult { Ok(PluginsCommandResult {
message: render_plugins_report_with_failures(&plugins, failures), message: render_plugins_report_with_failures(&plugins, failures),
@@ -2314,36 +2301,12 @@ pub fn handle_plugins_slash_command(
reload_runtime: true, reload_runtime: true,
}) })
} }
Some("show" | "info" | "describe") => { Some(other) => Ok(PluginsCommandResult {
// Show a named plugin by filtering the installed registry. message: format!(
// Without a target, shows all (same as list). "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
let report = manager.installed_plugin_registry_report()?; ),
let plugins: Vec<_> = if let Some(name) = target {
let needle = name.to_lowercase();
report
.summaries()
.into_iter()
.filter(|p| p.metadata.id.to_lowercase() == needle)
.collect()
} else {
report.summaries().into_iter().collect()
};
let failures = report.failures();
Ok(PluginsCommandResult {
message: render_plugins_report_with_failures(&plugins, failures),
reload_runtime: false,
})
}
// #743/#420: "help" was caught by Some(other) → unknown_plugins_action error with hint:null.
// agents/mcp/skills all return a help envelope; plugins must match that parity.
Some("help" | "-h" | "--help") => Ok(PluginsCommandResult {
message: "Plugins\n Usage /plugins [list|show <id>|install <id>|enable <id>|disable <id>|uninstall <id>|update <id>|help]\n Subcommands list show install enable disable uninstall update help"
.to_string(),
reload_runtime: false, reload_runtime: false,
}), }),
Some(other) => Err(PluginError::CommandFailed(format!(
"unknown_plugins_action: '{other}' is not a supported /plugins action.\nUse: list, show, install, enable, disable, uninstall, or update."
))),
} }
} }
@@ -2363,65 +2326,10 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let agents = load_agents_from_roots(&roots)?; let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents)) Ok(render_agents_report(&agents))
} }
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
// #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792)
if filter.starts_with('-') {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown option for `agents list`: {filter}\nUsage: claw agents list [<filter>]\nFilters are name substrings, not flags."),
));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let filtered: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase().contains(&filter))
.collect();
Ok(render_agents_report(&filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents))
}
Some(args)
if args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name_raw = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
// #804: detect extra positional args (parity with JSON-mode fix #796)
if name_raw.contains(' ') {
let extra = name_raw.split_once(' ').map(|(_, e)| e).unwrap_or("");
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unexpected extra arguments after agent name\nUsage: claw agents show <name>\nUnexpected extra: '{extra}'"),
));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let matched: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase() == name_raw)
.collect();
if matched.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("agent not found: {name_raw}"),
));
}
Ok(render_agents_report(&matched))
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)), Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
Some(args) => Err(std::io::Error::new( Some(args) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput, std::io::ErrorKind::InvalidInput,
format!("unknown agents subcommand: {args}.\nSupported: list, show, help"), format!("unknown agents subcommand: {args}. Supported: list, help"),
)), )),
} }
} }
@@ -2442,86 +2350,10 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
let agents = load_agents_from_roots(&roots)?; let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json(cwd, &agents)) Ok(render_agents_report_json(cwd, &agents))
} }
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
// #792: unknown flags (--something) silently became filter strings, returning
// empty success list instead of an error. Detect and reject flag-shaped tokens.
if filter.starts_with('-') {
return Ok(serde_json::json!({
"kind": "agents",
"action": "list",
"status": "error",
"error_kind": "unknown_option",
"unexpected": filter,
"hint": "Usage: claw agents list [<filter>]\nFilters are name substrings, not flags.",
}));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let filtered: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase().contains(&filter))
.collect();
Ok(render_agents_report_json(cwd, &filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json_with_action(cwd, &agents, "show"))
}
Some(args)
if args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name_raw = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
// #796: extra positional args after the name (e.g. `agents show foo extra`)
// produced a confusing agent_not_found for "foo extra" instead of flagging
// the unexpected extra argument.
let (name, extra) = name_raw
.split_once(' ')
.map(|(n, e)| (n.to_string(), Some(e.to_string())))
.unwrap_or_else(|| (name_raw.clone(), None));
if let Some(extra_token) = extra {
return Ok(serde_json::json!({
"kind": "agents",
"action": "show",
"status": "error",
"error_kind": "unexpected_extra_args",
"unexpected": extra_token,
"hint": format!("Usage: claw agents show <name>\nUnexpected extra: '{extra_token}'"),
}));
}
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
let matched: Vec<_> = agents
.into_iter()
.filter(|a| a.name.to_lowercase() == name)
.collect();
if matched.is_empty() {
return Ok(serde_json::json!({
"kind": "agents",
"action": "show",
"status": "error",
"error_kind": "agent_not_found",
"requested": name,
// #734: parity with skills show which always emits a message field
"message": format!("agent '{}' not found", name),
// #760: hint so callers know how to enumerate available agents
"hint": "Run `claw agents list` to see available agents.",
}));
}
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)), Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
Some(args) => Err(std::io::Error::new( Some(args) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput, std::io::ErrorKind::InvalidInput,
format!("unknown agents subcommand: {args}.\nSupported: list, show, help"), format!("unknown agents subcommand: {args}. Supported: list, help"),
)), )),
} }
} }
@@ -2561,13 +2393,6 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
} }
Some(args) if args.starts_with("list ") => { Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase(); let filter = args["list ".len()..].trim().to_lowercase();
// #803: reject flag-shaped tokens in text mode too (JSON guard was added in #792)
if filter.starts_with('-') {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown option for `skills list`: {filter}\nUsage: claw skills list [<filter>]\nFilters are name substrings, not flags."),
));
}
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?; let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills let filtered: Vec<_> = skills
@@ -2586,33 +2411,18 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|| args.starts_with("info ") || args.starts_with("info ")
|| args.starts_with("describe ") => || args.starts_with("describe ") =>
{ {
let name_raw = args let name = args
.split_once(' ') .split_once(' ')
.map(|(_, name)| name) .map(|(_, name)| name)
.unwrap_or_default() .unwrap_or_default()
.trim() .trim()
.to_lowercase(); .to_lowercase();
// #804: detect extra positional args (parity with JSON-mode fix #796)
if name_raw.contains(' ') {
let extra = name_raw.split_once(' ').map(|(_, e)| e).unwrap_or("");
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unexpected extra arguments after skill name\nUsage: claw skills show <name>\nUnexpected extra: '{extra}'"),
));
}
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?; let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills let matched: Vec<_> = skills
.into_iter() .into_iter()
.filter(|s| s.name.to_lowercase() == name_raw) .filter(|s| s.name.to_lowercase() == name)
.collect(); .collect();
// #805: text-mode show must return an error when skill not found (parity with JSON)
if matched.is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("skill '{name_raw}' not found\nRun `claw skills list` to see available skills."),
));
}
Ok(render_skills_report(&matched)) Ok(render_skills_report(&matched))
} }
Some("install") => Ok(render_skills_usage(Some("install"))), Some("install") => Ok(render_skills_usage(Some("install"))),
@@ -2644,83 +2454,41 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
None | Some("list") => { None | Some("list") => {
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?; let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json_with_action(&skills, "list")) Ok(render_skills_report_json(&skills))
} }
Some(args) if args.starts_with("list ") => { Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase(); let filter = args["list ".len()..].trim().to_lowercase();
// #792: flag-shaped tokens silently became filter strings, returning
// empty success list instead of an error. Detect and reject them.
if filter.starts_with('-') {
return Ok(serde_json::json!({
"kind": "skills",
"action": "list",
"status": "error",
"error_kind": "unknown_option",
"unexpected": filter,
"hint": "Usage: claw skills list [<filter>]\nFilters are name substrings, not flags.",
}));
}
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?; let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills let filtered: Vec<_> = skills
.into_iter() .into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter)) .filter(|s| s.name.to_lowercase().contains(&filter))
.collect(); .collect();
Ok(render_skills_report_json_with_action(&filtered, "list")) Ok(render_skills_report_json(&filtered))
} }
Some("show" | "info" | "describe") => { Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?; let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json_with_action(&skills, "show")) Ok(render_skills_report_json(&skills))
} }
Some(args) Some(args)
if args.starts_with("show ") if args.starts_with("show ")
|| args.starts_with("info ") || args.starts_with("info ")
|| args.starts_with("describe ") => || args.starts_with("describe ") =>
{ {
let name_raw = args let name = args
.split_once(' ') .split_once(' ')
.map(|(_, name)| name) .map(|(_, name)| name)
.unwrap_or_default() .unwrap_or_default()
.trim() .trim()
.to_lowercase(); .to_lowercase();
// #796: extra positional args after the name (e.g. `skills show foo extra`)
// produced a confusing skill_not_found for "foo extra" instead of flagging
// the unexpected extra argument.
let (name, extra) = name_raw
.split_once(' ')
.map(|(n, e)| (n.to_string(), Some(e.to_string())))
.unwrap_or_else(|| (name_raw.clone(), None));
if let Some(extra_token) = extra {
return Ok(json!({
"kind": "skills",
"action": "show",
"status": "error",
"error_kind": "unexpected_extra_args",
"unexpected": extra_token,
"hint": format!("Usage: claw skills show <name>\nUnexpected extra: '{extra_token}'"),
}));
}
let roots = discover_skill_roots(cwd); let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?; let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills let matched: Vec<_> = skills
.into_iter() .into_iter()
.filter(|s| s.name.to_lowercase() == name) .filter(|s| s.name.to_lowercase() == name)
.collect(); .collect();
// #706: return typed error when named skill is not found instead of silent empty list Ok(render_skills_report_json(&matched))
if matched.is_empty() {
return Ok(json!({
"kind": "skills",
"action": "show",
"status": "error",
"error_kind": "skill_not_found",
"message": format!("skill '{}' not found", name),
"requested": name,
// #761: hint so callers know how to enumerate available skills
"hint": "Run `claw skills list` to see available skills.",
}));
}
Ok(render_skills_report_json_with_action(&matched, "show"))
} }
Some("install") => Ok(render_skills_usage_json(Some("install"))), Some("install") => Ok(render_skills_usage_json(Some("install"))),
Some(args) if args.starts_with("install ") => { Some(args) if args.starts_with("install ") => {
@@ -3038,11 +2806,7 @@ fn render_mcp_report_json_for(
runtime_config.mcp().get(server_name), runtime_config.mcp().get(server_name),
); );
if let Some(map) = value.as_object_mut() { if let Some(map) = value.as_object_mut() {
// Only override status to "ok" if the server was found; map.insert("status".to_string(), Value::String("ok".to_string()));
// render_mcp_server_report_json already sets status:"error" for not-found.
if map.get("found") == Some(&Value::Bool(true)) {
map.insert("status".to_string(), Value::String("ok".to_string()));
}
map.insert("config_load_error".to_string(), Value::Null); map.insert("config_load_error".to_string(), Value::Null);
} }
Ok(value) Ok(value)
@@ -3472,12 +3236,7 @@ fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<Ski
} else { } else {
cwd.join(candidate) cwd.join(candidate)
}; };
let source = fs::canonicalize(&source).map_err(|e| { let source = fs::canonicalize(&source)?;
std::io::Error::new(
e.kind(),
format!("skill source '{}' not found: {e}", source.display()),
)
})?;
if source.is_dir() { if source.is_dir() {
let prompt_path = source.join("SKILL.md"); let prompt_path = source.join("SKILL.md");
@@ -3653,7 +3412,6 @@ fn load_agents_from_roots(
reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"), reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
source: *source, source: *source,
shadowed_by: None, shadowed_by: None,
path: Some(entry.path()),
}); });
} }
root_agents.sort_by(|left, right| left.name.cmp(&right.name)); root_agents.sort_by(|left, right| left.name.cmp(&right.name));
@@ -3698,7 +3456,6 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
source: root.source, source: root.source,
shadowed_by: None, shadowed_by: None,
origin: root.origin, origin: root.origin,
path: Some(entry.path()),
}); });
} }
SkillOrigin::LegacyCommandsDir => { SkillOrigin::LegacyCommandsDir => {
@@ -3730,7 +3487,6 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSumma
source: root.source, source: root.source,
shadowed_by: None, shadowed_by: None,
origin: root.origin, origin: root.origin,
path: Some(markdown_path),
}); });
} }
} }
@@ -3863,14 +3619,6 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
} }
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value { fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
render_agents_report_json_with_action(cwd, agents, "list")
}
fn render_agents_report_json_with_action(
cwd: &Path,
agents: &[AgentSummary],
action: &str,
) -> Value {
let active = agents let active = agents
.iter() .iter()
.filter(|agent| agent.shadowed_by.is_none()) .filter(|agent| agent.shadowed_by.is_none())
@@ -3878,7 +3626,8 @@ fn render_agents_report_json_with_action(
json!({ json!({
"kind": "agents", "kind": "agents",
"status": "ok", "status": "ok",
"action": action, "action": "list",
"status": "ok",
"working_directory": cwd.display().to_string(), "working_directory": cwd.display().to_string(),
"count": agents.len(), "count": agents.len(),
"summary": { "summary": {
@@ -3953,7 +3702,7 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
lines.join("\n").trim_end().to_string() lines.join("\n").trim_end().to_string()
} }
fn render_skills_report_json_with_action(skills: &[SkillSummary], action: &str) -> Value { fn render_skills_report_json(skills: &[SkillSummary]) -> Value {
let active = skills let active = skills
.iter() .iter()
.filter(|skill| skill.shadowed_by.is_none()) .filter(|skill| skill.shadowed_by.is_none())
@@ -3961,7 +3710,8 @@ fn render_skills_report_json_with_action(skills: &[SkillSummary], action: &str)
json!({ json!({
"kind": "skills", "kind": "skills",
"status": "ok", "status": "ok",
"action": action, "action": "list",
"status": "ok",
"summary": { "summary": {
"total": skills.len(), "total": skills.len(),
"active": active, "active": active,
@@ -3995,8 +3745,8 @@ fn render_skill_install_report(skill: &InstalledSkill) -> String {
fn render_skill_install_report_json(skill: &InstalledSkill) -> Value { fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
json!({ json!({
"kind": "skills", "kind": "skills",
"status": "ok",
"action": "install", "action": "install",
"status": "ok",
"result": "installed", "result": "installed",
"invocation_name": &skill.invocation_name, "invocation_name": &skill.invocation_name,
"invoke_as": format!("${}", skill.invocation_name), "invoke_as": format!("${}", skill.invocation_name),
@@ -4139,7 +3889,6 @@ fn render_mcp_server_report_json(
Some(server) => json!({ Some(server) => json!({
"kind": "mcp", "kind": "mcp",
"action": "show", "action": "show",
"status": "ok",
"working_directory": cwd.display().to_string(), "working_directory": cwd.display().to_string(),
"found": true, "found": true,
"server": mcp_server_json(server_name, server), "server": mcp_server_json(server_name, server),
@@ -4147,14 +3896,10 @@ fn render_mcp_server_report_json(
None => json!({ None => json!({
"kind": "mcp", "kind": "mcp",
"action": "show", "action": "show",
"status": "error",
"error_kind": "server_not_found",
"working_directory": cwd.display().to_string(), "working_directory": cwd.display().to_string(),
"found": false, "found": false,
"server_name": server_name, "server_name": server_name,
"message": format!("server `{server_name}` is not configured"), "message": format!("server `{server_name}` is not configured"),
// #761: hint so callers know how to enumerate configured MCP servers
"hint": "Run `claw mcp list` to see configured servers.",
}), }),
} }
} }
@@ -4262,26 +4007,11 @@ fn render_mcp_usage(unexpected: Option<&str>) -> String {
} }
fn render_mcp_usage_json(unexpected: Option<&str>) -> Value { fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
// #748: add error_kind when unexpected is set, matching agents/plugins unknown-subcommand shape.
let error_kind: Value = if unexpected.is_some() {
json!("unknown_mcp_action")
} else {
Value::Null
};
// #774: add hint field so unknown_mcp_action errors have non-null hint parity
// with agents/plugins unknown-subcommand envelopes.
let hint: Value = if unexpected.is_some() {
json!("Use: list, show <server>, or help")
} else {
Value::Null
};
json!({ json!({
"kind": "mcp", "kind": "mcp",
"action": "help", "action": "help",
"ok": unexpected.is_none(), "ok": unexpected.is_none(),
"status": if unexpected.is_some() { "error" } else { "ok" }, "status": if unexpected.is_some() { "error" } else { "ok" },
"error_kind": error_kind,
"hint": hint,
"usage": { "usage": {
"slash_command": "/mcp [list|show <server>|help]", "slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]", "direct_cli": "claw mcp [list|show <server>|help]",
@@ -4382,17 +4112,9 @@ fn definition_source_id(source: DefinitionSource) -> &'static str {
} }
fn definition_source_json(source: DefinitionSource) -> Value { fn definition_source_json(source: DefinitionSource) -> Value {
definition_source_json_with_detail(source, None)
}
fn definition_source_json_with_detail(
source: DefinitionSource,
detail_label: Option<&'static str>,
) -> Value {
json!({ json!({
"id": definition_source_id(source), "id": definition_source_id(source),
"label": source.label(), "label": source.label(),
"detail_label": detail_label,
}) })
} }
@@ -4405,8 +4127,6 @@ fn agent_summary_json(agent: &AgentSummary) -> Value {
"source": definition_source_json(agent.source), "source": definition_source_json(agent.source),
"active": agent.shadowed_by.is_none(), "active": agent.shadowed_by.is_none(),
"shadowed_by": agent.shadowed_by.map(definition_source_json), "shadowed_by": agent.shadowed_by.map(definition_source_json),
// #728: expose on-disk path so callers can inspect the agent file directly
"path": agent.path.as_ref().map(|p| p.display().to_string()),
}) })
} }
@@ -4428,12 +4148,10 @@ fn skill_summary_json(skill: &SkillSummary) -> Value {
json!({ json!({
"name": &skill.name, "name": &skill.name,
"description": &skill.description, "description": &skill.description,
"source": definition_source_json_with_detail(skill.source, skill.origin.detail_label()), "source": definition_source_json(skill.source),
"origin": skill_origin_json(skill.origin), "origin": skill_origin_json(skill.origin),
"active": skill.shadowed_by.is_none(), "active": skill.shadowed_by.is_none(),
"shadowed_by": skill.shadowed_by.map(definition_source_json), "shadowed_by": skill.shadowed_by.map(definition_source_json),
// #729: path parity with agent_summary_json
"path": skill.path.as_ref().map(|p| p.display().to_string()),
}) })
} }
@@ -5616,23 +5334,13 @@ mod tests {
assert_eq!(help["status"], "ok"); assert_eq!(help["status"], "ok");
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]"); assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
// `show <name>` is now valid. Known agent returns ok with matching entry. // Unknown agents subcommands now return Err so CLI layer can exit 1.
let show_planner = handle_agents_slash_command_json(Some("show planner"), &workspace) let unexpected_err = handle_agents_slash_command_json(Some("show planner"), &workspace);
.expect("show planner should return Ok");
assert_eq!(show_planner["status"], "ok");
let show_agents = show_planner["agents"].as_array().expect("agents array");
assert_eq!(show_agents.len(), 1, "show by exact name returns one entry");
assert_eq!(show_agents[0]["name"], "planner");
// Missing agent returns Ok(json error) with error_kind:agent_not_found.
let show_missing =
handle_agents_slash_command_json(Some("show nonexistent-xyz"), &workspace)
.expect("show missing agent should return Ok");
assert_eq!(show_missing["status"], "error");
assert_eq!(show_missing["error_kind"], "agent_not_found");
assert_eq!(show_missing["requested"], "nonexistent-xyz");
// Truly unknown subcommands still Err.
let unexpected_err = handle_agents_slash_command_json(Some("frobnicate"), &workspace);
assert!(unexpected_err.is_err()); assert!(unexpected_err.is_err());
assert!(unexpected_err
.unwrap_err()
.to_string()
.contains("show planner"));
let _ = fs::remove_dir_all(workspace); let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home); let _ = fs::remove_dir_all(user_home);
@@ -5733,9 +5441,8 @@ mod tests {
origin: SkillOrigin::SkillsDir, origin: SkillOrigin::SkillsDir,
}, },
]; ];
let report = super::render_skills_report_json_with_action( let report = super::render_skills_report_json(
&load_skills_from_roots(&roots).expect("skills should load"), &load_skills_from_roots(&roots).expect("skills should load"),
"list",
); );
assert_eq!(report["kind"], "skills"); assert_eq!(report["kind"], "skills");
assert_eq!(report["action"], "list"); assert_eq!(report["action"], "list");
@@ -5744,18 +5451,7 @@ mod tests {
assert_eq!(report["summary"]["shadowed"], 1); assert_eq!(report["summary"]["shadowed"], 1);
assert_eq!(report["skills"][0]["name"], "plan"); assert_eq!(report["skills"][0]["name"], "plan");
assert_eq!(report["skills"][0]["source"]["id"], "project_claw"); assert_eq!(report["skills"][0]["source"]["id"], "project_claw");
assert_eq!(report["skills"][0]["source"]["label"], "Project roots");
assert_eq!(
report["skills"][0]["source"]["detail_label"],
serde_json::Value::Null
);
assert_eq!(report["skills"][1]["name"], "deploy"); assert_eq!(report["skills"][1]["name"], "deploy");
assert_eq!(report["skills"][1]["source"]["id"], "project_claw");
assert_eq!(report["skills"][1]["source"]["label"], "Project roots");
assert_eq!(
report["skills"][1]["source"]["detail_label"],
"legacy /commands"
);
assert_eq!(report["skills"][1]["origin"]["id"], "legacy_commands_dir"); assert_eq!(report["skills"][1]["origin"]["id"], "legacy_commands_dir");
assert_eq!(report["skills"][3]["shadowed_by"]["id"], "project_claw"); assert_eq!(report["skills"][3]["shadowed_by"]["id"], "project_claw");
@@ -5784,23 +5480,14 @@ mod tests {
assert!(agents_help assert!(agents_help
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents")); .contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
// `show <name>` is now valid. For an agent that doesn't exist it returns Err(NotFound). // Unknown agents subcommands now return Err (typed error) instead of Ok+help text
let agents_show_missing = super::handle_agents_slash_command(Some("show planner"), &cwd); // so that the CLI layer can exit 1. The error message names the unexpected input.
assert!( let agents_unexpected_err = super::handle_agents_slash_command(Some("show planner"), &cwd);
agents_show_missing.is_err(), assert!(agents_unexpected_err.is_err());
"show of a missing agent should Err" assert!(agents_unexpected_err
); .unwrap_err()
assert_eq!( .to_string()
agents_show_missing.unwrap_err().kind(), .contains("show planner"));
std::io::ErrorKind::NotFound
);
// Truly unknown subcommands still Err with InvalidInput.
let agents_unknown_err = super::handle_agents_slash_command(Some("frobnicate"), &cwd);
assert!(agents_unknown_err.is_err());
assert_eq!(
agents_unknown_err.unwrap_err().kind(),
std::io::ErrorKind::InvalidInput
);
let skills_help = let skills_help =
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");

View File

@@ -217,10 +217,7 @@ impl Display for ConfigError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Io(error) => write!(f, "{error}"), Self::Io(error) => write!(f, "{error}"),
Self::Parse(error) => write!( Self::Parse(error) => write!(f, "{error}"),
f,
"{error}\nFix: open the file shown above and correct the JSON syntax, then retry."
),
} }
} }
} }
@@ -346,70 +343,6 @@ impl ConfigLoader {
feature_config, feature_config,
}) })
} }
/// Like [`load`] but also returns the list of validation warnings collected during
/// loading, without emitting them to stderr. Callers that want to surface warnings
/// through a structured channel (e.g. the JSON config envelope) should use this.
/// #773: enables JSON-mode callers to include `warnings` in their output envelope
/// instead of receiving unstructured text on stderr.
pub fn load_collecting_warnings(&self) -> Result<(RuntimeConfig, Vec<String>), ConfigError> {
let mut merged = BTreeMap::new();
let mut loaded_entries = Vec::new();
let mut mcp_servers = BTreeMap::new();
let mut all_warnings: Vec<String> = Vec::new();
for entry in self.discover() {
crate::config_validate::check_unsupported_format(&entry.path)?;
let Some(parsed) = read_optional_json_object(&entry.path)? else {
continue;
};
let validation = crate::config_validate::validate_config_file(
&parsed.object,
&parsed.source,
&entry.path,
);
if !validation.is_ok() {
let first_error = &validation.errors[0];
return Err(ConfigError::Parse(first_error.to_string()));
}
all_warnings.extend(validation.warnings.iter().map(|w| w.to_string()));
validate_optional_hooks_config(&parsed.object, &entry.path)?;
merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path)?;
deep_merge_objects(&mut merged, &parsed.object);
loaded_entries.push(entry);
}
// Still emit to stderr for non-JSON callers that go through the normal load() path;
// here we just *also* return them so callers can surface them structurally.
for warning in &all_warnings {
emit_config_warning_once(warning);
}
let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
plugins: parse_optional_plugin_config(&merged_value)?,
mcp: McpConfigCollection {
servers: mcp_servers,
},
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
model: parse_optional_model(&merged_value),
aliases: parse_optional_aliases(&merged_value)?,
permission_mode: parse_optional_permission_mode(&merged_value)?,
permission_rules: parse_optional_permission_rules(&merged_value)?,
sandbox: parse_optional_sandbox_config(&merged_value)?,
provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?,
trusted_roots: parse_optional_trusted_roots(&merged_value)?,
};
let config = RuntimeConfig {
merged,
loaded_entries,
feature_config,
};
Ok((config, all_warnings))
}
} }
impl RuntimeConfig { impl RuntimeConfig {

View File

@@ -342,7 +342,6 @@ where
let mut tool_results = Vec::new(); let mut tool_results = Vec::new();
let mut prompt_cache_events = Vec::new(); let mut prompt_cache_events = Vec::new();
let mut iterations = 0; let mut iterations = 0;
let mut auto_compaction = None;
loop { loop {
iterations += 1; iterations += 1;
@@ -398,12 +397,6 @@ where
.map_err(|error| RuntimeError::new(error.to_string()))?; .map_err(|error| RuntimeError::new(error.to_string()))?;
assistant_messages.push(assistant_message); assistant_messages.push(assistant_message);
// Run auto-compaction check before next API call, including on the terminal
// (no-tool) iteration, to prevent unbounded session growth (#3106).
if let Some(compaction) = self.maybe_auto_compact() {
auto_compaction = Some(compaction);
}
if pending_tool_uses.is_empty() { if pending_tool_uses.is_empty() {
break; break;
} }
@@ -510,6 +503,8 @@ where
} }
} }
let auto_compaction = self.maybe_auto_compact();
let summary = TurnSummary { let summary = TurnSummary {
assistant_messages, assistant_messages,
tool_results, tool_results,

View File

@@ -413,7 +413,6 @@ impl Session {
.get("created_at_ms") .get("created_at_ms")
.map(|value| required_u64_from_value(value, "created_at_ms")) .map(|value| required_u64_from_value(value, "created_at_ms"))
.transpose()? .transpose()?
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
.unwrap_or(now); .unwrap_or(now);
let updated_at_ms = object let updated_at_ms = object
.get("updated_at_ms") .get("updated_at_ms")
@@ -501,10 +500,7 @@ impl Session {
"session_meta" => { "session_meta" => {
version = required_u32(object, "version")?; version = required_u32(object, "version")?;
session_id = Some(required_string(object, "session_id")?); session_id = Some(required_string(object, "session_id")?);
created_at_ms = object created_at_ms = Some(required_u64(object, "created_at_ms")?);
.get("created_at_ms")
.map(|value| required_u64_from_value(value, "created_at_ms"))
.transpose()?;
updated_at_ms = Some(required_u64(object, "updated_at_ms")?); updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
fork = object.get("fork").map(SessionFork::from_json).transpose()?; fork = object.get("fork").map(SessionFork::from_json).transpose()?;
workspace_root = object workspace_root = object
@@ -547,15 +543,11 @@ impl Session {
} }
let now = current_time_millis(); let now = current_time_millis();
let session_id = session_id.unwrap_or_else(generate_session_id);
let created_at_ms = created_at_ms
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
.unwrap_or(now);
Ok(Self { Ok(Self {
version, version,
session_id, session_id: session_id.unwrap_or_else(generate_session_id),
created_at_ms, created_at_ms: created_at_ms.unwrap_or(now),
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms), updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)),
messages, messages,
compaction, compaction,
fork, fork,
@@ -1299,15 +1291,6 @@ fn current_time_millis() -> u64 {
} }
} }
pub(crate) fn parse_created_at_ms_from_session_id(session_id: &str) -> Option<u64> {
let timestamp_and_suffix = session_id.strip_prefix("session-")?;
let (timestamp, suffix) = timestamp_and_suffix.split_once('-')?;
if suffix.is_empty() {
return None;
}
timestamp.parse::<u64>().ok()
}
fn generate_session_id() -> String { fn generate_session_id() -> String {
let millis = current_time_millis(); let millis = current_time_millis();
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed); let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
@@ -1397,9 +1380,8 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
cleanup_rotated_logs, current_time_millis, parse_created_at_ms_from_session_id, cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session, ConversationMessage, MessageRole, Session, SessionFork,
SessionFork,
}; };
use crate::json::JsonValue; use crate::json::JsonValue;
use crate::usage::TokenUsage; use crate::usage::TokenUsage;
@@ -1520,44 +1502,6 @@ mod tests {
assert!(!restored.session_id.is_empty()); assert!(!restored.session_id.is_empty());
} }
#[test]
fn created_at_parser_requires_full_session_id_shape() {
assert_eq!(
parse_created_at_ms_from_session_id("session-1743724800123-0"),
Some(1_743_724_800_123)
);
assert_eq!(
parse_created_at_ms_from_session_id("session-1743724800123"),
None
);
assert_eq!(
parse_created_at_ms_from_session_id("session-1743724800123-"),
None
);
assert_eq!(
parse_created_at_ms_from_session_id("other-1743724800123-0"),
None
);
}
#[test]
fn loads_legacy_jsonl_created_at_from_session_id_when_meta_omits_it() {
let path = temp_session_path("legacy-jsonl-created-at");
fs::write(
&path,
r#"{"type":"session_meta","version":3,"session_id":"session-1743724800123-0","updated_at_ms":1743724800456}
"#,
)
.expect("legacy jsonl should write");
let restored = Session::load_from_path(&path).expect("legacy jsonl should load");
fs::remove_file(&path).expect("temp file should be removable");
assert_eq!(restored.session_id, "session-1743724800123-0");
assert_eq!(restored.created_at_ms, 1_743_724_800_123);
assert_eq!(restored.updated_at_ms, 1_743_724_800_456);
}
#[test] #[test]
fn appends_messages_to_persisted_jsonl_session() { fn appends_messages_to_persisted_jsonl_session() {
let path = temp_session_path("append"); let path = temp_session_path("append");

View File

@@ -5,7 +5,7 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH; use std::time::UNIX_EPOCH;
use crate::session::{parse_created_at_ms_from_session_id, Session, SessionError}; use crate::session::{Session, SessionError};
/// Per-worktree session store that namespaces on-disk session files by /// Per-worktree session store that namespaces on-disk session files by
/// workspace fingerprint so that parallel `opencode serve` instances never /// workspace fingerprint so that parallel `opencode serve` instances never
@@ -345,9 +345,6 @@ impl SessionStore {
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis()) .map(|duration| duration.as_millis())
.unwrap_or_default(); .unwrap_or_default();
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
let fallback_created_at_ms =
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
let summary = match Session::load_from_path(&path) { let summary = match Session::load_from_path(&path) {
Ok(session) => { Ok(session) => {
if self.validate_loaded_session(&path, &session).is_err() { if self.validate_loaded_session(&path, &session).is_err() {
@@ -356,7 +353,6 @@ impl SessionStore {
ManagedSessionSummary { ManagedSessionSummary {
id: session.session_id, id: session.session_id,
path, path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms, updated_at_ms: session.updated_at_ms,
modified_epoch_millis, modified_epoch_millis,
message_count: session.messages.len(), message_count: session.messages.len(),
@@ -371,9 +367,12 @@ impl SessionStore {
} }
} }
Err(_) => ManagedSessionSummary { Err(_) => ManagedSessionSummary {
id: fallback_id, id: path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
path, path,
created_at_ms: fallback_created_at_ms,
updated_at_ms: 0, updated_at_ms: 0,
modified_epoch_millis, modified_epoch_millis,
message_count: 0, message_count: 0,
@@ -410,14 +409,10 @@ impl SessionStore {
.and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis()) .map(|duration| duration.as_millis())
.unwrap_or_default(); .unwrap_or_default();
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
let fallback_created_at_ms =
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
let summary = match Session::load_from_path(&path) { let summary = match Session::load_from_path(&path) {
Ok(session) => ManagedSessionSummary { Ok(session) => ManagedSessionSummary {
id: session.session_id, id: session.session_id,
path, path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms, updated_at_ms: session.updated_at_ms,
modified_epoch_millis, modified_epoch_millis,
message_count: session.messages.len(), message_count: session.messages.len(),
@@ -431,9 +426,12 @@ impl SessionStore {
.and_then(|fork| fork.branch_name.clone()), .and_then(|fork| fork.branch_name.clone()),
}, },
Err(_) => ManagedSessionSummary { Err(_) => ManagedSessionSummary {
id: fallback_id, id: path
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_string(),
path, path,
created_at_ms: fallback_created_at_ms,
updated_at_ms: 0, updated_at_ms: 0,
modified_epoch_millis, modified_epoch_millis,
message_count: 0, message_count: 0,
@@ -485,7 +483,6 @@ pub struct SessionHandle {
pub struct ManagedSessionSummary { pub struct ManagedSessionSummary {
pub id: String, pub id: String,
pub path: PathBuf, pub path: PathBuf,
pub created_at_ms: u64,
pub updated_at_ms: u64, pub updated_at_ms: u64,
pub modified_epoch_millis: u128, pub modified_epoch_millis: u128,
pub message_count: usize, pub message_count: usize,
@@ -813,7 +810,6 @@ mod tests {
ManagedSessionSummary { ManagedSessionSummary {
id: "older-file-newer-session".to_string(), id: "older-file-newer-session".to_string(),
path: PathBuf::from("/tmp/older"), path: PathBuf::from("/tmp/older"),
created_at_ms: 100,
updated_at_ms: 200, updated_at_ms: 200,
modified_epoch_millis: 100, modified_epoch_millis: 100,
message_count: 2, message_count: 2,
@@ -823,7 +819,6 @@ mod tests {
ManagedSessionSummary { ManagedSessionSummary {
id: "newer-file-older-session".to_string(), id: "newer-file-older-session".to_string(),
path: PathBuf::from("/tmp/newer"), path: PathBuf::from("/tmp/newer"),
created_at_ms: 50,
updated_at_ms: 100, updated_at_ms: 100,
modified_epoch_millis: 200, modified_epoch_millis: 200,
message_count: 1, message_count: 1,

View File

@@ -1193,7 +1193,7 @@ fn git_tracks_path(cwd: &Path, path: &str) -> bool {
fn git_metadata_path(cwd: &Path) -> Option<PathBuf> { fn git_metadata_path(cwd: &Path) -> Option<PathBuf> {
let output = Command::new("git") let output = Command::new("git")
.args(["rev-parse", "--git-dir"]) .args(["rev-parse", "--git-path", "."])
.current_dir(cwd) .current_dir(cwd)
.output() .output()
.ok()?; .ok()?;
@@ -1214,27 +1214,17 @@ fn git_metadata_path(cwd: &Path) -> Option<PathBuf> {
fn path_is_writable(path: &Path) -> bool { fn path_is_writable(path: &Path) -> bool {
let probe_dir = if path.is_dir() { let probe_dir = if path.is_dir() {
path path.to_path_buf()
} else { } else {
path.parent().unwrap_or(path) path.parent().unwrap_or(path).to_path_buf()
}; };
std::fs::metadata(probe_dir) let probe = probe_dir.join(format!(".claw-write-probe-{}", now_secs()));
.ok() std::fs::OpenOptions::new()
.filter(std::fs::Metadata::is_dir) .write(true)
.is_some_and(|metadata| metadata_allows_directory_writes(&metadata)) .create_new(true)
} .open(&probe)
.and_then(|_| std::fs::remove_file(&probe))
#[cfg(unix)] .is_ok()
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode();
mode & 0o222 != 0 && mode & 0o111 != 0
}
#[cfg(not(unix))]
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
!metadata.permissions().readonly()
} }
fn detect_trust_prompt(lowered: &str) -> bool { fn detect_trust_prompt(lowered: &str) -> bool {
@@ -1637,56 +1627,6 @@ mod tests {
})); }));
} }
#[cfg(unix)]
#[test]
fn startup_preflight_warns_when_git_metadata_is_not_writable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let worktree = tmp.path().join("worktree");
let git_dir = tmp.path().join("external-gitdir");
fs::create_dir_all(&worktree).expect("worktree dir");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", git_dir.display()),
)
.expect(".git file");
let original_permissions = fs::metadata(&git_dir)
.expect("gitdir metadata")
.permissions();
let mut read_only_permissions = original_permissions.clone();
read_only_permissions.set_mode(0o555);
fs::set_permissions(&git_dir, read_only_permissions).expect("make gitdir read-only");
let warnings = startup_preflight_warnings(&worktree, "Audit repository.");
let registry = WorkerRegistry::new();
let worker = registry.create(&worktree.display().to_string(), &[], true);
let observed = registry
.observe_startup_preflight(&worker.worker_id, "Audit repository.")
.expect("preflight should run");
fs::set_permissions(&git_dir, original_permissions).expect("restore gitdir permissions");
assert!(warnings.iter().any(|warning| {
warning.kind == WorkerStartupPreflightWarningKind::GitMetadataNotWritable
&& warning.path.as_deref() == Some(git_dir.to_string_lossy().as_ref())
}));
assert!(observed.events.iter().any(|event| {
matches!(
&event.payload,
Some(WorkerEventPayload::StartupPreflightWarning {
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
path: Some(path),
..
}) if path == git_dir.to_string_lossy().as_ref()
)
}));
}
#[test] #[test]
fn startup_preflight_records_structured_warning_event() { fn startup_preflight_records_structured_warning_event() {
let tmp = tempfile::tempdir().expect("tempdir"); let tmp = tempfile::tempdir().expect("tempdir");

View File

@@ -381,16 +381,11 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir() -> std::path::PathBuf { fn temp_dir() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now() let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.expect("time should be after epoch") .expect("time should be after epoch")
.as_nanos(); .as_nanos();
// Combine counter + nanoseconds so parallel tests in the same process std::env::temp_dir().join(format!("rusty-claude-init-{nanos}"))
// never collide even if two calls land in the same nanosecond (#707).
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}-{id}"))
} }
#[test] #[test]

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,9 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Command, Output, Stdio}; use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX}; use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::Value; use serde_json::Value;
@@ -245,94 +245,6 @@ stderr:
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed"); fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
} }
#[test]
fn compact_subcommand_json_help_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-json-help");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let output = run_claw_closed_stdin_with_timeout(
&workspace,
&config_home,
&home,
&["compact", "--output-format", "json", "--help"],
Duration::from_secs(2),
);
assert!(
!output.status.success(),
"compact json help should fail non-zero"
);
assert!(
output.stdout.is_empty(),
"compact json help should not start a prompt/spinner on stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
let parsed: Value = serde_json::from_str(stderr.trim()).expect("stderr should be JSON error");
assert_eq!(parsed["status"], "error");
assert_eq!(parsed["error_kind"], "interactive_only");
assert_eq!(parsed["action"], "abort");
assert!(
parsed["message"]
.as_str()
.unwrap_or_default()
.contains("claw compact"),
"message should name compact: {parsed}"
);
// #749: hint must be non-empty (was null before fix — same class as #738/#745/#746)
let hint = parsed["hint"].as_str().unwrap_or("");
assert!(
!hint.is_empty(),
"compact interactive-only JSON must have non-empty hint (#749); got: {parsed}"
);
assert!(
hint.contains("/compact") || hint.contains("--resume"),
"hint should mention /compact or --resume: {hint}"
);
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_subcommand_text_fails_fast_when_stdin_closed() {
let workspace = unique_temp_dir("compact-nontty-text");
let config_home = workspace.join("config-home");
let home = workspace.join("home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&home).expect("home should exist");
let output = run_claw_closed_stdin_with_timeout(
&workspace,
&config_home,
&home,
&["compact"],
Duration::from_secs(2),
);
assert!(
!output.status.success(),
"compact text should fail non-zero"
);
assert!(
output.stdout.is_empty(),
"compact text should not start a prompt/spinner on stdout: {}",
String::from_utf8_lossy(&output.stdout)
);
let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8");
assert!(
stderr.contains("[error-kind: interactive_only]"),
"{stderr}"
);
assert!(stderr.contains("claw compact"), "{stderr}");
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
fn run_claw( fn run_claw(
cwd: &std::path::Path, cwd: &std::path::Path,
config_home: &std::path::Path, config_home: &std::path::Path,
@@ -354,48 +266,6 @@ fn run_claw(
command.output().expect("claw should launch") command.output().expect("claw should launch")
} }
fn run_claw_closed_stdin_with_timeout(
cwd: &std::path::Path,
config_home: &std::path::Path,
home: &std::path::Path,
args: &[&str],
timeout: Duration,
) -> Output {
let mut child = Command::new(env!("CARGO_BIN_EXE_claw"))
.current_dir(cwd)
.env_clear()
.env("CLAW_CONFIG_HOME", config_home)
.env("HOME", home)
.env("NO_COLOR", "1")
.env("PATH", "/usr/bin:/bin")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.args(args)
.spawn()
.expect("claw should launch");
let start = Instant::now();
loop {
if child.try_wait().expect("try_wait should succeed").is_some() {
return child.wait_with_output().expect("output should collect");
}
if start.elapsed() > timeout {
let _ = child.kill();
let output = child
.wait_with_output()
.expect("killed output should collect");
panic!(
"claw did not exit within {:?}\nstdout:\n{}\nstderr:\n{}",
timeout,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
std::thread::sleep(Duration::from_millis(10));
}
}
fn unique_temp_dir(label: &str) -> PathBuf { fn unique_temp_dir(label: &str) -> PathBuf {
let millis = SystemTime::now() let millis = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)

File diff suppressed because it is too large Load Diff

View File

@@ -523,14 +523,7 @@ fn resumed_stub_command_emits_not_implemented_json() {
assert!(!output.status.success()); assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).expect("utf8"); let stderr = String::from_utf8(output.stderr).expect("utf8");
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json"); let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
assert_eq!( assert_eq!(parsed["type"], "error");
parsed["status"], "error",
"stub command should emit status:error"
);
assert_eq!(
parsed["kind"], "unsupported_command",
"stub command should emit kind:unsupported_command"
);
assert!( assert!(
parsed["error"] parsed["error"]
.as_str() .as_str()

View File

@@ -26,13 +26,11 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
repo_root = args.repo_root.resolve() repo_root = args.repo_root.resolve()
script_root = Path(__file__).resolve().parent
tool_root = script_root.parent
board_json = repo_root / args.board_json board_json = repo_root / args.board_json
board_md = repo_root / args.board_md board_md = repo_root / args.board_md
generator = script_root / "generate_cc2_board.py" generator = repo_root / "scripts" / "generate_cc2_board.py"
validator = script_root / "validate_cc2_board.py" validator = repo_root / "scripts" / "validate_cc2_board.py"
renderer = tool_root / ".omx" / "cc2" / "render_board_md.py" renderer = repo_root / ".omx" / "cc2" / "render_board_md.py"
if args.command == "generate": if args.command == "generate":
rc = run([sys.executable, str(generator), "--repo-root", str(repo_root), "--out-dir", str(board_json.parent)], repo_root) rc = run([sys.executable, str(generator), "--repo-root", str(repo_root), "--out-dir", str(board_json.parent)], repo_root)

View File

@@ -504,11 +504,7 @@ def main() -> int:
repo_root = args.repo_root.resolve() repo_root = args.repo_root.resolve()
out_dir = args.out_dir or (repo_root / ".omx" / "cc2") out_dir = args.out_dir or (repo_root / ".omx" / "cc2")
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
try: board = build_board(repo_root)
board = build_board(repo_root)
except FileNotFoundError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
board_json = out_dir / "board.json" board_json = out_dir / "board.json"
board_md = out_dir / "board.md" board_md = out_dir / "board.md"
board_json.write_text(json.dumps(board, indent=2, sort_keys=True) + "\n", encoding="utf-8") board_json.write_text(json.dumps(board, indent=2, sort_keys=True) + "\n", encoding="utf-8")

View File

@@ -1,78 +0,0 @@
#!/usr/bin/env bash
# roadmap-check-ids.sh — fail when helper-era ROADMAP item ids are duplicated.
# Usage: scripts/roadmap-check-ids.sh [--min-id N] [path/to/ROADMAP.md]
#
# By default this validates ids >= 723, the point where ROADMAP appends started
# using scripts/roadmap-next-id.sh. Earlier ROADMAP content contains historical
# numbered lists and already-landed duplicate low ids, so the default guard is
# intentionally scoped to new helper-era append collisions. Use --min-id 1 for a
# strict whole-file audit after legacy numbering is cleaned up.
set -euo pipefail
MIN_ID=723
ROADMAP="ROADMAP.md"
ROADMAP_PATH_SEEN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--min-id)
if [[ $# -lt 2 || ! "$2" =~ ^[0-9]+$ ]]; then
echo "error: --min-id requires a non-negative integer" >&2
exit 2
fi
MIN_ID="$2"
shift 2
;;
--help|-h)
sed -n '2,9p' "$0" | sed 's/^# //; s/^#//'
exit 0
;;
--*)
echo "error: unknown option: $1" >&2
exit 2
;;
*)
if [[ "$ROADMAP_PATH_SEEN" -ne 0 ]]; then
echo "error: unexpected extra ROADMAP path: $1" >&2
exit 2
fi
ROADMAP="$1"
ROADMAP_PATH_SEEN=1
shift
;;
esac
done
if [[ ! -f "$ROADMAP" ]]; then
echo "error: ROADMAP not found at $ROADMAP" >&2
exit 1
fi
awk -v min_id="$MIN_ID" -v path="$ROADMAP" '
/^[0-9]+\./ {
id = $0
sub(/\..*/, "", id)
id += 0
if (id >= min_id) {
count[id]++
lines[id] = lines[id] (lines[id] ? ", " : "") FNR
}
}
END {
for (id in count) {
if (count[id] > 1) {
duplicate_count++
duplicate_ids[duplicate_count] = id
}
}
if (duplicate_count) {
print "error: duplicate ROADMAP numeric id(s) in " path " (min id " min_id "):" > "/dev/stderr"
for (i = 1; i <= duplicate_count; i++) {
id = duplicate_ids[i]
print " - " id " at line(s) " lines[id] > "/dev/stderr"
}
exit 1
}
print "roadmap id check passed: no duplicate ids >= " min_id " in " path
}
' "$ROADMAP"

View File

@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# roadmap-next-id.sh — print the next available ROADMAP item id.
# Usage: scripts/roadmap-next-id.sh [path/to/ROADMAP.md]
#
# Designed to be used before appending a new entry so that concurrent
# dogfood claws do not accidentally reuse the same id:
#
# NEXT=$(scripts/roadmap-next-id.sh)
# cat >> ROADMAP.md << EOF
# ${NEXT}. **...description...**
# EOF
#
# The script first validates helper-era ids with roadmap-check-ids.sh, then
# reads the highest numeric id prefix from ROADMAP.md and prints highest+1. It
# does not lock the file; callers working in parallel should git-pull
# immediately before appending, run scripts/roadmap-check-ids.sh before push,
# and resolve any append collision at git-push time.
set -euo pipefail
ROADMAP="ROADMAP.md"
ROADMAP_PATH_SEEN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
sed -n '2,15p' "$0" | sed 's/^# //; s/^#//'
exit 0
;;
--*)
echo "error: unknown option: $1" >&2
exit 2
;;
*)
if [[ "$ROADMAP_PATH_SEEN" -ne 0 ]]; then
echo "error: unexpected extra ROADMAP path: $1" >&2
exit 2
fi
ROADMAP="$1"
ROADMAP_PATH_SEEN=1
shift
;;
esac
done
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
CHECKER="$SCRIPT_DIR/roadmap-check-ids.sh"
if [[ ! -f "$ROADMAP" ]]; then
echo "error: ROADMAP not found at $ROADMAP" >&2
exit 1
fi
if [[ ! -f "$CHECKER" || ! -r "$CHECKER" ]]; then
echo "error: required ROADMAP id checker not found or not readable at $CHECKER" >&2
echo "error: refusing to print a next id without duplicate-id validation" >&2
exit 1
fi
if ! checker_output="$(bash "$CHECKER" "$ROADMAP" 2>&1)"; then
printf '%s\n' "$checker_output" >&2
exit 1
fi
# Find the highest leading integer from lines that start with a number + '.'.
highest=$(awk '
/^[0-9]+\./ {
id = $0
sub(/\..*/, "", id)
id += 0
if (id > highest) {
highest = id
}
}
END { print highest + 0 }
' "$ROADMAP")
if [[ "$highest" -eq 0 ]]; then
echo 1
else
echo $(( highest + 1 ))
fi

View File

@@ -44,14 +44,7 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
repo_root = args.repo_root.resolve() repo_root = args.repo_root.resolve()
board_path = args.board or (repo_root / ".omx" / "cc2" / "board.json") board_path = args.board or (repo_root / ".omx" / "cc2" / "board.json")
try: board = json.loads(board_path.read_text(encoding="utf-8"))
board = json.loads(board_path.read_text(encoding="utf-8"))
except FileNotFoundError:
print(f"error: board not found at {board_path}")
return 1
except json.JSONDecodeError as exc:
print(f"error: invalid board JSON at {board_path}: {exc}")
return 1
errors: list[str] = [] errors: list[str] = []
ids = set() ids = set()
for index, item in enumerate(board.get("items", []), 1): for index, item in enumerate(board.get("items", []), 1):

View File

@@ -1,45 +0,0 @@
from __future__ import annotations
import os
import subprocess
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
PRE_PUSH_HOOK = REPO_ROOT / '.github' / 'hooks' / 'pre-push'
class PrePushHookContractTests(unittest.TestCase):
def test_skip_escape_hatch_exits_successfully_with_stderr_notice(self) -> None:
env = os.environ.copy()
env['SKIP_CLAW_PRE_PUSH_BUILD'] = '1'
result = subprocess.run(
['bash', str(PRE_PUSH_HOOK)],
cwd=REPO_ROOT,
env=env,
check=True,
capture_output=True,
text=True,
)
self.assertEqual('', result.stdout)
self.assertIn('SKIP_CLAW_PRE_PUSH_BUILD=1', result.stderr)
self.assertIn('skipping cargo workspace build', result.stderr)
def test_default_build_gate_uses_workspace_locked_cargo_build(self) -> None:
hook = PRE_PUSH_HOOK.read_text()
self.assertIn(
'cargo build --manifest-path rust/Cargo.toml --workspace --locked',
hook,
)
self.assertIn(
'build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)',
hook,
)
if __name__ == '__main__':
unittest.main()

View File

@@ -1,67 +0,0 @@
from __future__ import annotations
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
return subprocess.run(
['bash', str(script), str(roadmap)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=False,
)
class RoadmapHelperTests(unittest.TestCase):
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
roadmap = Path(temp_dir) / 'ROADMAP.md'
roadmap.write_text('721. old\n723. helper era\n724. guard\n')
result = run_next_id(roadmap)
self.assertEqual(0, result.returncode)
self.assertEqual('725\n', result.stdout)
self.assertEqual('', result.stderr)
def test_roadmap_next_id_fails_fast_on_helper_era_duplicate(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
roadmap = Path(temp_dir) / 'ROADMAP.md'
roadmap.write_text('722. legacy\n999. first\n999. duplicate\n')
result = run_next_id(roadmap)
self.assertNotEqual(0, result.returncode)
self.assertEqual('', result.stdout)
self.assertIn('duplicate ROADMAP numeric id(s)', result.stderr)
self.assertIn('999', result.stderr)
self.assertNotIn('1000', result.stdout)
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
script_dir = Path(temp_dir) / 'scripts'
script_dir.mkdir()
copied_next_id = script_dir / 'roadmap-next-id.sh'
shutil.copy2(NEXT_ID, copied_next_id)
roadmap = Path(temp_dir) / 'ROADMAP.md'
roadmap.write_text('724. guard\n')
result = run_next_id(roadmap, copied_next_id)
self.assertNotEqual(0, result.returncode)
self.assertEqual('', result.stdout)
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
self.assertIn('refusing to print a next id', result.stderr)
if __name__ == '__main__':
unittest.main()