Compare commits

..

45 Commits

Author SHA1 Message Date
YeonGyu-Kim
553d25ee50 docs(roadmap): consolidate items #407-#420 from 12 conflicting PRs
Batch-appended ROADMAP entries from PRs #2950, #2951, #2953, #2954,
#2955, #2956, #2957, #2959, #2960, #2962, #2963, #2964.

All PRs were CI-green but conflicting on ROADMAP.md due to serial
appends to the same file.
2026-05-06 15:00:34 +09:00
YeonGyu-Kim
5be173edf6 Merge pull request #2986 from andhai/pr/glob-search-prune-heavy-dirs
runtime: prune heavy directories during glob searches
2026-05-06 14:53:37 +09:00
YeonGyu-Kim
28998422e2 Merge pull request #2984 from andhai/pr/openai-token-limit-hardening
openai: harden token-limit handling and default output-token caps
2026-05-06 14:53:24 +09:00
YeonGyu-Kim
b4733b67a6 Merge pull request #2949 from Yeachan-Heo/fix/export-help-json
Fix export help JSON output
2026-05-06 14:52:09 +09:00
YeonGyu-Kim
ab44985916 fix(scripts): inject GIT_SHA in dogfood-build.sh so provenance check passes (#2996)
Scripts-only PR — CI intentionally does not run for scripts/ (path filter covers rust/** and docs only). Manually verified: dogfood-build.sh builds, injects GIT_SHA, verifies provenance, and documents CLAW_CONFIG_HOME isolation. Zero stderr with isolated config.
2026-05-05 07:02:13 +09:00
YeonGyu-Kim
d074d1c046 fix(mcp): exit 1 when JSON envelope contains ok:false (#2995)
* fix(mcp): exit 1 when JSON envelope contains ok:false

mcp info, mcp describe, and mcp list-filter all return
{"action":"error","ok":false,...} but previously exited 0,
requiring automation callers to inspect the envelope field.

After this fix: print_mcp detects ok:false in the rendered JSON
value and calls process::exit(1) after printing, so the exit code
reflects the semantic error in the envelope.

Unaffected: mcp list, mcp show, mcp help all have no ok field and
continue to exit 0 (they are not error paths).

Closes ROADMAP #68 (partial — agents bogus/mcp show nonexistent
found:false remain exit:0 as they use different envelope shapes).

* feat(scripts): add dogfood-build.sh — build from checkout and verify provenance

Builds claw from the current HEAD, then checks that the binary's
git_sha matches git rev-parse --short HEAD. Exits non-zero if the
binary is stale or provenance is opaque (git_sha: null).

Usage:
  CLAW=$(bash scripts/dogfood-build.sh)   # fail-fast if stale
  $CLAW version --output-format json       # provenance confirmed

Addresses ROADMAP #69: dogfooders using a stale installed binary
cannot attribute behavior to specific commits. This script makes
dogfood round zero unambiguous.

Also documents the safe workaround for contributors who have a
stale system-installed binary.
2026-05-05 06:09:11 +09:00
YeonGyu-Kim
caeac828b5 fix(permissions): return guidance for multi-word forms instead of falling through to LLM (#2994)
claw permissions list / claw permissions allow <tool> / claw permissions deny <tool>
all fell through to the prompt/LLM path because parse_subcommand had no
arm for "permissions". The single-word bare form was already intercepted
by bare_slash_command_guidance, but any form with rest.len() > 1 bypassed
the single-word guard and landed in the _other => CliAction::Prompt branch.

Fix: add a "permissions" arm in parse_subcommand that returns a structured
guidance Err so all multi-word forms get the same exit:1 + JSON error as
the bare single-word form, without any LLM call or session creation.

Verified: all invocation forms (bare, list, read-only, workspace-write,
allow/deny <tool>) exit 1 with kind:unknown guidance JSON. Zero sessions.
2026-05-05 05:35:50 +09:00
YeonGyu-Kim
85435ad4b5 fix(plugins): route plugin and marketplace aliases through local handler (#2993)
claw plugin list / claw marketplace / claw marketplace list all fell
through to the prompt/LLM path because parse_subcommand only matched
"plugins" (the primary name) while the canonical spec aliases
"plugin" and "marketplace" were unhandled.

This manifested as auth errors and session creation on direct
invocation — dogfood confirmed Gaebal's binary created one session
via plugin prompt fallback.

Fix: extend the plugins arm in parse_subcommand to also match
"plugin" | "marketplace" so all three forms route to the same
CliAction::Plugins without network calls or session creation.

Verified: all six forms (bare + list subcommand for each name) return
kind:plugin JSON, exit 0, and create zero sessions.

Closes ROADMAP #55 partial (plugins/marketplace bypass complete).
2026-05-05 05:16:00 +09:00
YeonGyu-Kim
5eb4b8a944 fix(mcp): return typed error JSON for unsupported actions (info/describe/list-filter) (#2989)
`claw mcp info nonexistent --output-format json` and
`claw mcp list nonexistent --output-format json` fell through to
the generic help renderer, returning an opaque envelope with only
`unexpected` set — no machine-readable error_kind.

Fix:
- Add typed guards in render_mcp_report_for/_json_for for:
  - `list <filter>`: list accepts no filter argument
  - `info <name>` / `describe <name>`: suggest `mcp show`
- New render_mcp_unsupported_action_text/json helpers emit
  `ok:false`, `error_kind:"unsupported_action"`, `hint`, `requested_action`
- `mcp show`, `mcp list`, `mcp help` existing paths unchanged

Test: mcp_unsupported_actions_return_typed_error_not_generic_help
asserts kind=="mcp", ok==false, error_kind=="unsupported_action"
for info/list-filter/describe paths.

Pinpoint: ROADMAP #504
2026-05-05 05:13:07 +09:00
YeonGyu-Kim
65aa559733 fix: support /plugins slash command in resume mode (#2973)
* fix: support /plugins slash command in resume mode

Move SlashCommand::Plugins out of the 'unsupported resumed slash
command' catch-all and add a handler arm in run_resume_command that
calls handle_plugins_slash_command for list/help actions.

Mutation actions (install/uninstall/enable/disable) are rejected with
a clear error since there is no runtime to reload in resume mode.

Add /plugins coverage to resumed_inventory_commands test in
output_format_contract.rs: kind, action, reload_runtime, target.

Before: claw --resume session.jsonl /plugins --output-format json
-> {error: 'unsupported resumed slash command', type: 'error'}, exit 1

After: claw --resume session.jsonl /plugins --output-format json
-> {kind: 'plugin', action: 'list', ...}, exit 0

* style: cargo fmt line wrap in run_resume_command plugins handler

* fix: block /plugins update in resume mode, fix comment

Address REQUEST_CHANGES from OMX review:
1. Add 'update' to the blocked mutation actions in resume mode
   (previously only install/uninstall/enable/disable were blocked)
2. Fix comment: 'Only list is supported' instead of 'Only list/help'
   since /plugins help doesn't actually parse as a valid action

* style: cargo fmt after conflict resolution
2026-05-05 04:55:39 +09:00
YeonGyu-Kim
ac8a24b30b fix(config): emit section and section_value in JSON output for config subcommands (#2990)
`claw config model --output-format json` and all other section subcommands
(`env`, `hooks`, `plugins`) returned identical output with no section field
— the section arg was parsed but discarded (_section parameter).

Fix: render_config_json now:
- Passes section through to handler
- Looks up the section value via runtime_config.get(), converting the
  internal JsonValue to serde_json::Value via render()+parse
- Emits `section` (string) and `section_value` (JSON value or null)
  in the response envelope
- Returns ok:false + error for unsupported section tokens

Test: config_section_json_emits_section_and_value asserts:
- No section field when no section arg
- section + section_value fields present for all known sections
- ok:false + error for unknown section

Pinpoint: ROADMAP #126
2026-05-05 04:50:33 +09:00
YeonGyu-Kim
94b80a05d3 fix(skills): route show/info/list-filter to local, not model invoke (#2988)
`claw skills show <name>`, `claw skills info <name>`, and
`claw skills list <filter>` were all falling through to
SkillSlashDispatch::Invoke, which spawned a real model session,
consumed tokens, and created session files.

Root cause: classify_skills_slash_command had no guards for
these discovery prefixes; every non-reserved arg became Invoke.

Fix:
- Add "show", "info" as Local-only bare tokens
- Add starts_with guards for "show ", "info ", "list " args
- handle_skills_slash_command: filter skill list by name/substring
  for show/info/list-filter paths (no model call, no session)
- handle_skills_slash_command_json: same structured filtering

Test: skills_show_and_list_filter_do_not_invoke_model asserts
  classify_skills_slash_command returns Local for all discovery
  patterns and still returns Invoke for bare skill names.

Pinpoint: ROADMAP #502
2026-05-05 04:50:30 +09:00
YeonGyu-Kim
9b97c4d832 fix(tests): isolate CLAW_CONFIG_HOME in resumed_status JSON test (#2992)
resumed_status_command_emits_structured_json_when_requested was reading
the real ~/.claw/settings.json, causing loaded_config_files to be 1
instead of the expected 0 on machines with user config present.

Root cause: unlike other tests (e.g. resumed_config_command_loads_settings_files),
this test did not pass an isolated CLAW_CONFIG_HOME env var to run_claw,
so claw fell back to the real HOME and loaded the developer's settings file.

Fix: create a temp config-home dir and pass it as CLAW_CONFIG_HOME via
run_claw_with_env. This gives the assertion a clean 0-file baseline.

Unblocks PRs #2973, #2988, #2990 which all failed this same test on main.

Ref: ROADMAP #65
2026-05-05 04:49:46 +09:00
YeonGyu-Kim
1206f4131d fix(resume): emit structured JSON for /agents --output-format json (#2987)
Resumed /agents --output-format json was returning a human-readable
text render wrapped in a JSON envelope field instead of the actual
structured agent list. The run_resume_command handler was calling
handle_agents_slash_command (text) for the json field instead of
handle_agents_slash_command_json.

Fix: use handle_agents_slash_command_json for the json outcome field,
matching the pattern already used by /skills and /plugins.

Test: extended resumed_inventory_commands_emit_structured_json_when_requested
to cover /agents, asserting kind=="agents", action=="list",
agents is an array, and count is a number (not a text render).
2026-05-05 04:20:52 +09:00
YeonGyu-Kim
c99330372c fix(version): add build_date and executable_path to version JSON output
`claw version --output-format json` was missing build_date and
executable_path, making it impossible to identify which binary is
running or correlate it with a specific build/commit.

Fix: version_json_value() now includes:
- build_date: compile-time BUILD_DATE env (already in text output)
- executable_path: std::env::current_exe() at runtime

Test: version_emits_json_when_requested extended to assert both fields
are strings in the JSON envelope.

Pinpoint: ROADMAP #507
2026-05-05 04:20:12 +09:00
Andreas Haida
9a512633a5 Cap OpenAI default output tokens using model metadata 2026-05-03 22:16:12 +02:00
Andreas Haida
6ac13ffdad Handle OpenAI token-limit errors as context-window failures 2026-05-03 22:16:12 +02:00
Andreas Haida
482681cdfe Prune heavy directories during glob searches 2026-05-03 22:13:58 +02:00
YeonGyu-Kim
8e45f1850c test(output_format_contract): add plugins json coverage to inventory_commands test (#2972)
Add four assertions to inventory_commands_emit_structured_json_when_requested:
- kind == "plugin"
- action == "list"
- reload_runtime is boolean
- target is null when no plugin is targeted

Closes the only major --output-format json surface with zero contract
coverage. All other surfaces (agents, mcp, skills, status, sandbox,
doctor, help, version, acp, bootstrap-plan, system-prompt, init, diff,
config) already had test assertions.
2026-05-01 06:03:31 +09:00
YeonGyu-Kim
57096b0a1a docs(roadmap): add no-session kind drift item
Adds ROADMAP #422 documenting the concrete export/resume no-session ErrorKind drift.
2026-05-01 02:46:12 +09:00
Yeachan-Heo
51b9e6b37f Fix export help JSON output 2026-04-30 09:04:11 +00:00
Bellman
e939777f92 Merge pull request #2921 from ultraworkers/docs/roadmap-381-cache-help-json-hangs
docs(roadmap): add #381 — cache help json hangs
2026-04-30 12:09:06 +09:00
Yeachan-Heo
1093e26792 Document cache help JSON hang
Constraint: ROADMAP-only dogfood follow-up for 03:00 nudge on rebuilt claw git_sha d95b230c

Rejected: implementation change to cache help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON responsiveness sanity check
Scope-risk: narrow
Directive: Continue preflight help hang coverage with distinct cache surface pinpoint
Tested: repeated timeout --kill-after=1s 8s ./rust/target/debug/claw cache --help --output-format json; timeout --kill-after=1s 8s ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 03:06:16 +00:00
Bellman
44cca2054d Merge pull request #2918 from ultraworkers/docs/roadmap-380-tokens-help-json-hangs
docs(roadmap): add #380 — tokens help json hangs
2026-04-30 11:38:50 +09:00
Yeachan-Heo
6dc7b26d82 Document tokens help JSON hang
Constraint: ROADMAP-only dogfood follow-up for 02:30 nudge on rebuilt claw git_sha d95b230c

Rejected: implementation change to tokens help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON responsiveness sanity check
Scope-risk: narrow
Directive: Continue cost/token preflight help coverage with a distinct tokens surface pinpoint
Tested: repeated timeout 8 ./rust/target/debug/claw tokens --help --output-format json; timeout 8 ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 02:35:19 +00:00
Bellman
a0bd406c8f Merge pull request #2915 from ultraworkers/docs/roadmap-358-cost-help-json-hangs
docs(roadmap): add #358 — cost help json hangs
2026-04-30 11:32:32 +09:00
Yeachan-Heo
b62646edfe Document cost help JSON hang
Constraint: ROADMAP-only dogfood follow-up for 02:00 nudge on rebuilt claw git_sha d95b230c

Rejected: implementation change to cost help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON responsiveness sanity check
Scope-risk: narrow
Directive: Continue help surface coverage after #356/#357 but preserve exact evidence only
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw cost --help --output-format json; timeout 8 ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 02:03:20 +00:00
Bellman
d95b230cae Merge pull request #2912 from ultraworkers/docs/roadmap-357-doctor-help-json-plain-text
docs(roadmap): add #357 — doctor help json emits plain text
2026-04-30 11:01:19 +09:00
Yeachan-Heo
f48f156754 Document doctor help JSON plain-text fallback
Constraint: ROADMAP-only dogfood follow-up for 01:30 nudge on rebuilt claw git_sha 52a909ce

Rejected: implementation change to doctor help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus status help plain-text sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2911 with verified help JSON-format fallback gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw doctor --help --output-format json; timeout 8 ./rust/target/debug/claw status --help --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 01:31:48 +00:00
Bellman
52a909cebe Merge pull request #2908 from ultraworkers/docs/roadmap-356-status-help-json-plain-text
docs(roadmap): add #356 — status help json emits plain text
2026-04-30 10:30:47 +09:00
Yeachan-Heo
c4c618e476 Document status help JSON plain-text fallback
Constraint: ROADMAP-only dogfood follow-up for 01:00 nudge on rebuilt claw git_sha 74338dc6

Rejected: implementation change to status help; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus version JSON sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2907 with verified help JSON-format fallback gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw status --help --output-format json; timeout 8 ./rust/target/debug/claw version --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 01:02:15 +00:00
Bellman
74338dc635 Merge pull request #2904 from ultraworkers/docs/roadmap-355-session-json-help-list-hangs
docs(roadmap): add #355 — session json help/list hangs
2026-04-30 10:01:09 +09:00
Yeachan-Heo
c092cf7fef Document session JSON help/list hang
Constraint: ROADMAP-only dogfood follow-up for 00:30 nudge on rebuilt claw git_sha 8e24f304

Rejected: implementation change to session command dispatch; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded session list samples plus session help bounded probe also hung/no bytes
Scope-risk: narrow
Directive: Switch from memory command introspection to session command introspection clawability
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw session list --output-format json; timeout 8 ./rust/target/debug/claw session help --output-format json/killed after no bytes; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 00:32:26 +00:00
Bellman
8e24f3049e Merge pull request #2901 from ultraworkers/docs/roadmap-354-memory-list-hangs
docs(roadmap): add #354 — memory json help/list hangs
2026-04-30 09:30:57 +09:00
Yeachan-Heo
71d8e7b925 Document memory JSON help/list hang
Constraint: ROADMAP-only dogfood follow-up for 00:00 nudge on rebuilt claw git_sha 19947545

Rejected: implementation change to memory command dispatch; request was one concrete follow-up if no backlog item
Confidence: high after bounded memory list samples plus memory help bounded sanity also hung
Scope-risk: narrow
Directive: Switch from plugin lifecycle repetition to memory command introspection clawability
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; timeout 8 ./rust/target/debug/claw memory list --output-format json samples; timeout 8 ./rust/target/debug/claw memory help --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-30 00:02:58 +00:00
Bellman
19947545e2 Merge pull request #2899 from ultraworkers/docs/roadmap-353-plugins-uninstall-stderr-only
docs(roadmap): add #353 — plugins uninstall json error is stderr-only
2026-04-30 09:01:02 +09:00
Yeachan-Heo
f7b2d8d6fe Document plugins uninstall JSON stderr-only not-found
Constraint: ROADMAP-only dogfood follow-up for 23:30 nudge on rebuilt claw git_sha 6f92e54d

Rejected: implementation change to plugin lifecycle uninstall; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2897 with verified stderr-only JSON-mode gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins uninstall does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 23:31:56 +00:00
Bellman
6f92e54dc0 Merge pull request #2895 from ultraworkers/docs/roadmap-352-plugins-update-stderr-only
docs(roadmap): add #352 — plugins update json error is stderr-only
2026-04-30 08:30:58 +09:00
Yeachan-Heo
31d9198a02 Document plugins update JSON stderr-only not-found
Constraint: ROADMAP-only dogfood follow-up for 23:00 nudge on rebuilt claw git_sha 5eb1d7d8

Rejected: implementation change to plugin lifecycle update; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2894 with verified stderr-only JSON-mode gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins update does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 23:02:03 +00:00
Bellman
5eb1d7d824 Merge pull request #2892 from ultraworkers/docs/roadmap-351-plugins-disable-stderr-only
docs(roadmap): add #351 — plugins disable json error is stderr-only
2026-04-30 08:00:56 +09:00
Yeachan-Heo
3b03375e69 Document plugins disable JSON stderr-only not-found
Constraint: ROADMAP-only dogfood follow-up for 22:30 nudge on rebuilt claw git_sha 0f9e8915

Rejected: implementation change to plugin lifecycle mutation; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Replaces invalid hang PR #2891 with verified stderr-only JSON-mode gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins disable does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 22:32:14 +00:00
Bellman
0f9e8915be Merge pull request #2887 from ultraworkers/docs/roadmap-350-plugins-enable-missing-hangs
docs(roadmap): add #350 — plugins enable missing target hangs
2026-04-30 07:30:51 +09:00
Yeachan-Heo
ab95b75fcd Document plugins enable missing-target hang
Constraint: ROADMAP-only dogfood follow-up for 22:00 nudge on rebuilt claw git_sha ee44ff98

Rejected: implementation change to plugin lifecycle mutation; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples plus prompt list sanity check
Scope-risk: narrow
Directive: Keep supported lifecycle missing-target hang distinct from #348 list schema and #349 unsupported show action
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins enable does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 22:02:04 +00:00
Bellman
ee44ff984d Merge pull request #2886 from ultraworkers/docs/roadmap-349-plugins-show-unsupported-success
docs(roadmap): add #349 — plugins unsupported action returns success-shaped json
2026-04-30 07:00:56 +09:00
Yeachan-Heo
2ab26df4bd Document plugins unsupported action success-shaped JSON
Constraint: ROADMAP-only dogfood follow-up for 21:30 nudge on rebuilt claw git_sha a2a38df9

Rejected: implementation change to plugin action dispatch; request was one concrete follow-up if no backlog item
Confidence: high after repeated bounded samples
Scope-risk: narrow
Directive: Replaces invalid hang PR #2885 with verified unsupported-action classification gap
Tested: cargo run --manifest-path rust/Cargo.toml --bin claw -- version --output-format json; repeated timeout 8 ./rust/target/debug/claw plugins show does-not-exist --output-format json; timeout 8 ./rust/target/debug/claw plugins list --output-format json; git diff --check; scripts/fmt.sh --check
Not-tested: runtime behavior change, because this commit only documents the gap
2026-04-29 21:32:19 +00:00
9 changed files with 977 additions and 64 deletions

View File

@@ -6288,5 +6288,53 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
346. **Top-level `agents show <name> --output-format json` accepts a natural agent-detail request but falls back to generic help JSON instead of returning the selected agent or a typed unsupported-detail error** — dogfooded 2026-04-29 for the 20:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `c6c01bea`. Running `./rust/target/debug/claw agents list --output-format json` returned a valid stdout JSON inventory with `kind:"agents"`, `action:"list"`, and an `agents[]` entry named `analyst`. Immediately running `./rust/target/debug/claw agents show analyst --output-format json` returned success on stdout but did not return the `analyst` detail object; instead it returned generic help-shaped JSON: `{"action":"help","kind":"agents","unexpected":"show analyst","usage":{"direct_cli":"claw agents [list|help]","slash_command":"/agents [list|help]",...}}`. Both stderr streams were empty. The command therefore accepts a natural detail-inspection spelling, recognizes it only as `unexpected`, and hides the absence of an agent-detail surface behind a successful help fallback rather than a typed `unsupported_agents_action` / `agent_detail_unavailable` error. This is distinct from #328 and #329: those cover source/provenance mismatch and slash `/agents` inventory flattening, while this pinpoint is the missing top-level agent detail/inspection contract after inventory discovery succeeds. **Required fix shape:** (a) either implement `agents show <name> --output-format json` returning the selected agent's structured fields and provenance, or return a non-success typed JSON error with `code:"unsupported_agents_action"`, `requested_action:"show"`, and `supported_actions:["list","help"]`; (b) include `agent_name` and whether the name exists in the current inventory when rejecting detail inspection; (c) avoid `action:"help"` success envelopes for unsupported subcommands because they make failed detail inspection look like intentional help output; (d) add regression coverage proving `agents show analyst --output-format json` does not silently collapse to generic help when `analyst` exists in `agents list`. **Why this matters:** claws discover agents first, then need to inspect a chosen agent before delegation. If the natural detail command returns successful generic help instead of a selected-agent payload or typed unsupported-action error, automation cannot distinguish typo, unsupported detail view, missing agent, or successful help request without comparing unrelated inventory output. Source: gaebal-gajae dogfood follow-up for the 20:00 nudge on rebuilt `./rust/target/debug/claw` `c6c01bea`; earlier false hang hypotheses for `mcp help` and `agents list` were closed after bounded repros succeeded.
347. **Top-level `mcp show <missing-server> --output-format json` reports a missing server as `status:"ok"` instead of a typed not-found/error status** — dogfooded 2026-04-29 for the 20:30 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `ee41b266`. After rebuilding and verifying the binary provenance, running `./rust/target/debug/claw mcp show does-not-exist --output-format json` returned stdout JSON with `{"action":"show","config_load_error":null,"found":false,"kind":"mcp","message":"server `does-not-exist` is not configured","server_name":"does-not-exist","status":"ok"}` and no stderr. `found:false` is useful, but pairing it with `status:"ok"` makes the command-level outcome ambiguous: a missing requested server is not an OK inspection result for automation that needs to distinguish successful detail retrieval from a not-found lookup. This is distinct from #327's MCP source-list mismatch and the invalid #2874/#2879/#2880 hang/nondeterminism hypotheses that were closed after bounded repros. **Required fix shape:** (a) return a typed not-found status such as `status:"not_found"` or `kind:"error"` plus `code:"mcp_server_not_found"` while preserving `server_name` and optional `available_servers[]`; (b) document whether `found:false` objects are considered success or error and keep that convention consistent across text and JSON modes; (c) ensure process exit semantics match the JSON status contract or expose a separate `exit_ok`/`lookup_status` field; (d) add regression coverage proving missing-server lookup is distinguishable from successful server detail retrieval without parsing the human `message`. **Why this matters:** MCP inspection is a control-plane diagnostic. If a missing server returns `status:"ok"`, claws can silently treat a failed lookup as healthy MCP state unless they special-case `found:false`, which defeats the purpose of a clear machine-readable status field. Source: gaebal-gajae dogfood follow-up for the 20:30 nudge on rebuilt `./rust/target/debug/claw` `ee41b266`.
348. **Top-level `plugins list --output-format json` returns plugin inventory only as a prose `message` string instead of structured `plugins[]` entries** — dogfooded 2026-04-29 for the 21:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `cca6f682`. Running `./rust/target/debug/claw plugins list --output-format json` repeatedly returned valid stdout JSON with `{"action":"list","kind":"plugin","message":"Plugins\n example-bundled v0.1.0 disabled\n sample-hooks v0.1.0 disabled","reload_runtime":false,"target":null}` and no stderr. The actual plugin names, versions, and enabled/disabled states are present only inside the human-formatted `message` table; there is no `plugins[]` array, no per-plugin `name`, `version`, `enabled`, `source`, `load_error`, or lifecycle/action metadata. This is distinct from #325's broad help JSON opacity and the config/MCP/agent items: the affected surface is plugin lifecycle inventory, where automation needs a structured list before enabling, disabling, updating, or uninstalling plugins. **Required fix shape:** (a) add `plugins[]` with stable per-plugin fields such as `name`, `version`, `enabled`, `source`, `configured`, `load_status`, and optional `error`; (b) keep `message` only as a human summary, not the sole inventory payload; (c) expose counts and truncation metadata if the list can be large; (d) add regression coverage proving `plugins list --output-format json` can be parsed without scraping the prose message and that disabled/enabled state survives as booleans/enums. **Why this matters:** plugin lifecycle management is a control-plane path. If the JSON inventory is just a text table, claws must scrape spacing-sensitive prose before deciding whether a plugin is installed, disabled, broken, or safe to mutate. Source: gaebal-gajae dogfood follow-up for the 21:00 nudge on rebuilt `./rust/target/debug/claw` `cca6f682`.
349. **Top-level `plugins show <name> --output-format json` returns success-shaped JSON for an unsupported plugin action instead of a typed unsupported-action error** — dogfooded 2026-04-29 for the 21:30 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `a2a38df9`. After rebuilding and verifying the binary provenance, repeated bounded runs of `./rust/target/debug/claw plugins show does-not-exist --output-format json` returned stdout JSON with `{"action":"show","kind":"plugin","message":"Unknown /plugins action 'show'. Use list, install, enable, disable, uninstall, or update.","reload_runtime":false,"target":"does-not-exist"}` and no stderr. The command therefore reports the requested unsupported action as the top-level `action:"show"` and exits successfully while hiding the failure class inside a human `message`; it does not provide `status:"unsupported_action"`, `code:"plugin_action_unsupported"`, or structured `supported_actions[]`. This is distinct from #348's prose-only plugin inventory schema: #348 covers `plugins list` payload shape, while this pinpoint covers unsupported plugin action classification and recovery metadata. **Required fix shape:** (a) return a typed stdout JSON error or explicit non-ok status for unsupported plugin actions, with `requested_action`, `supported_actions`, and `target` fields; (b) do not label the primary `action` as the unsupported requested verb unless a separate `status`/`code` makes the failure unambiguous; (c) keep the human message optional and avoid making it the only way to detect the unsupported action; (d) add regression coverage proving `plugins show foo --output-format json` is machine-classifiable as unsupported without scraping prose. **Why this matters:** plugin lifecycle automation follows action/status fields. If an unsupported mutation/inspection verb returns success-shaped JSON and only says "Unknown" in prose, claws can treat a failed preflight as a valid plugin show result and continue toward unsafe lifecycle actions. Source: gaebal-gajae dogfood follow-up for the 21:30 nudge on rebuilt `./rust/target/debug/claw` `a2a38df9`; invalid hang PR #2885 was closed after repeated bounded repros returned stdout JSON.
350. **Top-level `plugins enable <missing-plugin> --output-format json` hangs with zero stdout/stderr instead of returning a typed plugin-not-found or unsupported-target response** — dogfooded 2026-04-29 for the 22:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `ee44ff98`. After rebuilding and verifying the binary provenance, repeated bounded runs of `timeout 8 ./rust/target/debug/claw plugins enable does-not-exist --output-format json` exited `124` with `stdout=0` and `stderr=0`; a third sample was still stuck until killed. In the same rebuilt binary, `plugins list --output-format json` returned promptly with the known plugin inventory payload, proving the plugin top-level surface is reachable and narrowing the hang to missing-plugin lifecycle mutation. This is distinct from #348's prose-only list inventory and #349's unsupported `plugins show` success-shaped JSON: #350 covers a supported lifecycle verb (`enable`) against an absent target, where the CLI should be able to fail fast before any plugin runtime work. **Required fix shape:** (a) validate the target plugin against the discovered/configured inventory before invoking enable-side effects; (b) return bounded stdout JSON such as `kind:"plugin"`, `action:"enable"`, `status:"not_found"` or `kind:"error"`, `code:"plugin_not_found"`, `plugin`, and optional `available_plugins[]`; (c) add internal timeout/diagnostic metadata for plugin lifecycle operations so registry or hook stalls do not produce silent zero-byte hangs; (d) add regression coverage proving `plugins enable does-not-exist --output-format json` returns a typed JSON outcome within a deterministic budget and does not mutate plugin state. **Why this matters:** enable/disable/update/uninstall are destructive control-plane actions. A missing or stale plugin name must fail safely and machine-readably; otherwise claws cannot preflight plugin lifecycle operations, distinguish typo from loader deadlock, or recover without killing a hung process. Source: gaebal-gajae dogfood follow-up for the 22:00 nudge on rebuilt `./rust/target/debug/claw` `ee44ff98`.
351. **Top-level `plugins disable <missing-plugin> --output-format json` sends the JSON error envelope to stderr only, leaving stdout empty** — dogfooded 2026-04-29 for the 22:30 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `0f9e8915`. After rebuilding and verifying the binary provenance, repeated bounded runs of `timeout 8 ./rust/target/debug/claw plugins disable does-not-exist --output-format json` exited `1` with `stdout=0` and `stderr=113`; stderr contained JSON (`{"error":"plugin `does-not-exist` is not installed or discoverable","hint":null,"kind":"unknown","type":"error"}`), but stdout was empty. In the same rebuilt binary, `plugins list --output-format json` returned stdout JSON promptly with the known plugin inventory payload, proving the plugin command surface is reachable. This is distinct from #350's missing-target `plugins enable` zero-byte timeout: the disable path fails fast, but its JSON-mode error envelope is routed to stderr and uses generic `kind:"unknown"`/`type:"error"` instead of a plugin-specific stdout outcome. **Required fix shape:** (a) define and consistently document whether JSON mode emits machine-readable envelopes on stdout, stderr, or both for nonzero exits; (b) return a plugin-specific typed error with `kind:"plugin"` or `domain:"plugin"`, `action:"disable"`, `status:"not_found"` or `code:"plugin_not_found"`, `plugin`, and optional `available_plugins[]`; (c) keep stdout/stderr placement consistent across plugin lifecycle verbs so callers do not need per-action stream heuristics; (d) add regression coverage proving `plugins disable does-not-exist --output-format json` produces a typed plugin-not-found JSON contract on the documented stream. **Why this matters:** disable is a recovery/control-plane operation. A stale plugin name should be a structured, domain-specific not-found result on a predictable stream; otherwise claws that read stdout JSON for normal responses and stderr for human diagnostics must special-case this lifecycle failure. Source: gaebal-gajae dogfood follow-up for the 22:30 nudge on rebuilt `./rust/target/debug/claw` `0f9e8915`; invalid hang PR #2891 was closed after repeated bounded repros returned exit 1 with JSON on stderr.
352. **Top-level `plugins update <missing-plugin> --output-format json` sends a generic JSON error envelope to stderr only, leaving stdout empty** — dogfooded 2026-04-29 for the 23:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `5eb1d7d8`. After rebuilding and verifying the binary provenance, repeated bounded runs of `timeout 8 ./rust/target/debug/claw plugins update does-not-exist --output-format json` exited `1` with `stdout=0` and `stderr=97`; stderr contained JSON (`{"error":"plugin `does-not-exist` is not installed","hint":null,"kind":"unknown","type":"error"}`), but stdout was empty. In the same rebuilt binary, `plugins list --output-format json` returned stdout JSON promptly with the known plugin inventory payload. This is distinct from #350's missing-target `plugins enable` zero-byte timeout and parallel to #351's `plugins disable` stderr-only JSON envelope: update fails fast, but the JSON-mode error lives on stderr only and uses generic `kind:"unknown"`/`type:"error"` instead of a plugin-specific not-found contract. **Required fix shape:** (a) define and consistently document stdout/stderr placement for JSON-mode lifecycle errors; (b) return a plugin-specific typed error with `kind:"plugin"` or `domain:"plugin"`, `action:"update"`, `status:"not_found"` or `code:"plugin_not_found"`, `plugin`, and optional `available_plugins[]`; (c) share missing-target error-envelope behavior across disable/update/uninstall and reconcile it with enable's timeout path; (d) add regression coverage proving `plugins update does-not-exist --output-format json` produces a typed plugin-not-found JSON contract on the documented stream. **Why this matters:** update is a maintenance/control-plane operation often run in automation. A stale plugin name should produce a predictable, domain-specific not-found result, not require callers to special-case stderr-only generic error envelopes after explicitly requesting JSON. Source: gaebal-gajae dogfood follow-up for the 23:00 nudge on rebuilt `./rust/target/debug/claw` `5eb1d7d8`; invalid hang PR #2894 was closed after repeated bounded repros returned exit 1 with JSON on stderr.
353. **Top-level `plugins uninstall <missing-plugin> --output-format json` sends a generic JSON error envelope to stderr only, leaving stdout empty** — dogfooded 2026-04-29 for the 23:30 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `6f92e54d`. After rebuilding and verifying the binary provenance, repeated bounded runs of `timeout 8 ./rust/target/debug/claw plugins uninstall does-not-exist --output-format json` exited `1` with `stdout=0` and `stderr=97`; stderr contained JSON (`{"error":"plugin `does-not-exist` is not installed","hint":null,"kind":"unknown","type":"error"}`), but stdout was empty. In the same rebuilt binary, `plugins list --output-format json` returned stdout JSON promptly with the known plugin inventory payload. This is distinct from #350's missing-target `plugins enable` zero-byte timeout and parallel to #351/#352 for disable/update: uninstall fails fast, but the JSON-mode error lives on stderr only and uses generic `kind:"unknown"`/`type:"error"` instead of a plugin-specific not-found contract. **Required fix shape:** (a) define and consistently document stdout/stderr placement for JSON-mode lifecycle errors; (b) return a plugin-specific typed error with `kind:"plugin"` or `domain:"plugin"`, `action:"uninstall"`, `status:"not_found"` or `code:"plugin_not_found"`, `plugin`, and optional `available_plugins[]`; (c) share missing-target error-envelope behavior across disable/update/uninstall and reconcile it with enable's timeout path; (d) add regression coverage proving `plugins uninstall does-not-exist --output-format json` produces a typed plugin-not-found JSON contract on the documented stream. **Why this matters:** uninstall is the most destructive plugin lifecycle action. A stale plugin name should produce a predictable, domain-specific not-found result before cleanup hooks or loader work, not require callers to special-case stderr-only generic error envelopes after explicitly requesting JSON. Source: gaebal-gajae dogfood follow-up for the 23:30 nudge on rebuilt `./rust/target/debug/claw` `6f92e54d`; invalid hang PR #2897 was closed after repeated bounded repros returned exit 1 with JSON on stderr.
354. **Top-level `memory list` and `memory help` with `--output-format json` hang with zero stdout/stderr instead of returning bounded memory inventory/help or a typed unavailable response** — dogfooded 2026-04-30 for the 00:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `19947545`. After rebuilding and verifying the binary provenance, bounded runs of `timeout 8 ./rust/target/debug/claw memory list --output-format json` produced `stdout=0` and `stderr=0`; the first sample exited `124` and the second sample was still stuck until killed. A follow-up sanity check of `timeout 8 ./rust/target/debug/claw memory help --output-format json` also exited `124` with `stdout=0` and `stderr=0`, so the issue is broader than list inventory: even the memory help path can hang silently in JSON mode. This is distinct from prior plugin lifecycle stream/status items: the affected surface is memory command introspection, where claws need safe local help/inventory before reading or mutating memory. **Required fix shape:** (a) make `memory help` and `memory list --output-format json` return bounded local JSON without requiring external/authenticated backing store availability; (b) return stdout JSON with `kind:"memory"`, `action:"help"|"list"`, `status`, usage or `entries[]`, source/provenance, counts, and truncation metadata; (c) if credentials/config/backing store are missing or slow, return a typed JSON unavailable/config/timeout error instead of hanging; (d) add regression coverage proving both `memory help --output-format json` and `memory list --output-format json` return machine-readable outcomes within a deterministic budget. **Why this matters:** memory is a core clawability surface. If even help/list can hang silently with no bytes, agents cannot tell whether memory is empty, unavailable, remote-auth blocked, or deadlocked, and any higher-level recall/debug flow stalls at the first introspection step. Source: gaebal-gajae dogfood follow-up for the 00:00 nudge on rebuilt `./rust/target/debug/claw` `19947545`.
355. **Top-level `session list` and `session help` with `--output-format json` hang with zero stdout/stderr instead of returning bounded session inventory/help or a typed unavailable response** — dogfooded 2026-04-30 for the 00:30 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `8e24f304`. After rebuilding and verifying the binary provenance, repeated bounded runs of `timeout 8 ./rust/target/debug/claw session list --output-format json` exited `124` with `stdout=0` and `stderr=0`. A follow-up bounded `session help --output-format json` probe also produced no stdout/stderr before it had to be killed, so the issue is broader than inventory: even the session help path can silently hang in JSON mode. This is distinct from #354's memory help/list hang: the affected surface is session command introspection, where claws need a safe local way to enumerate resumable sessions or at least read usage before deciding whether to resume, inspect, or clean them up. **Required fix shape:** (a) make `session help` and `session list --output-format json` return bounded local JSON without waiting indefinitely on remote API/auth/session-store availability; (b) return stdout JSON with `kind:"session"`, `action:"help"|"list"`, `status`, usage or `sessions[]`, source/provenance, counts, and truncation metadata, or typed `status:"unavailable"`/`code` when backing state cannot be reached; (c) add explicit timeout diagnostics if a remote/authenticated session source is consulted; (d) add regression coverage proving both `session help --output-format json` and `session list --output-format json` return machine-readable outcomes within a deterministic budget. **Why this matters:** session inventory/help is a core recovery/control-plane path. If even help/list can hang silently with no bytes, claws cannot distinguish no sessions, missing credentials, remote API stall, corrupted local store, or dispatch deadlock, and resume/cleanup automation blocks before it can choose a safe next action. Source: gaebal-gajae dogfood follow-up for the 00:30 nudge on rebuilt `./rust/target/debug/claw` `8e24f304`.
356. **Top-level `status --help --output-format json` exits successfully but emits plain text help instead of JSON** — dogfooded 2026-04-30 for the 01:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `74338dc6`. After rebuilding and verifying the binary provenance, repeated bounded runs of `./rust/target/debug/claw status --help --output-format json` exited `0` with `stdout=326` and `stderr=0`, but stdout was plain text (`Status`, `Usage`, `Purpose`, `Output`, `Formats`, `Related`) rather than a JSON object. In the same rebuilt binary, `version --output-format json` returned proper stdout JSON with version/build metadata, proving the JSON output path itself is reachable. This is distinct from #354/#355 memory/session JSON help/list hangs: the status help path returns promptly, but ignores the requested JSON format. **Required fix shape:** (a) make `status --help --output-format json` emit valid stdout JSON with `kind:"help"` or `kind:"status"`, `action:"help"`, usage, options, examples, supported output formats, and related slash/direct commands; (b) preserve text help for default/text mode only; (c) add a `format:"json"` or equivalent field so callers can assert the contract without parsing prose; (d) add regression coverage proving status help with JSON format parses as JSON and does not silently fall back to plain text. **Why this matters:** help is the discovery surface automation uses before invoking status. If `--output-format json` is accepted but help remains plain text, claws must scrape formatting-sensitive prose or special-case help output, defeating the point of machine-readable CLI contracts. Source: gaebal-gajae dogfood follow-up for the 01:00 nudge on rebuilt `./rust/target/debug/claw` `74338dc6`; invalid hang PR #2907 was closed after repeated bounded repros returned promptly.
357. **Top-level `doctor --help --output-format json` exits successfully but emits plain text help instead of JSON** — dogfooded 2026-04-30 for the 01:30 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `52a909ce`. After rebuilding and verifying the binary provenance, repeated bounded runs of `./rust/target/debug/claw doctor --help --output-format json` exited `0` with `stdout=343` and `stderr=0`, but stdout was plain text (`Doctor`, `Usage`, `Purpose`, `Output`, `Formats`, `Related`) rather than a JSON object. In the same rebuilt binary, `status --help --output-format json` also returned promptly as plain text (#356), confirming a broader help-format fallback class while keeping this pinpoint on the doctor surface. This is distinct from #354/#355 memory/session JSON help/list hangs: doctor help returns promptly, but ignores the requested JSON format. **Required fix shape:** (a) make `doctor --help --output-format json` emit valid stdout JSON with `kind:"help"` or `kind:"doctor"`, `action:"help"`, usage, checks, options, examples, supported output formats, and related slash/direct commands; (b) preserve text help for default/text mode only; (c) add a `format:"json"` or equivalent field so callers can assert the contract without parsing prose; (d) add regression coverage proving doctor help with JSON format parses as JSON and does not silently fall back to plain text. **Why this matters:** doctor is the diagnostic entrypoint users reach for when things are broken. If JSON help falls back to prose, claws cannot discover diagnostic semantics or present structured recovery instructions without scraping formatting-sensitive text. Source: gaebal-gajae dogfood follow-up for the 01:30 nudge on rebuilt `./rust/target/debug/claw` `52a909ce`; invalid hang PR #2911 was closed after repeated bounded repros returned promptly.
358. **Top-level `cost --help --output-format json` hangs with zero stdout/stderr instead of returning bounded command help JSON** — dogfooded 2026-04-30 for the 02:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `d95b230c`. After rebuilding and verifying the binary provenance, repeated bounded runs of `timeout 8 ./rust/target/debug/claw cost --help --output-format json` exited `124` with `stdout=0` and `stderr=0`. In the same rebuilt binary, `version --output-format json` returned promptly with version/build metadata, proving the binary itself and the JSON output path are reachable; the hang is specific to the cost help path, though other help surfaces have separate known JSON contract issues (#356/#357). **Required fix shape:** (a) make `cost --help --output-format json` return static/bounded stdout JSON with `kind:"help"` or `kind:"cost"`, `action:"help"`, usage, options, examples, supported output formats, and related slash/direct commands; (b) ensure help rendering does not initialize slow cost/session/accounting providers; (c) if any dynamic provider is accidentally consulted, return a typed JSON timeout/unavailable error instead of hanging; (d) add regression coverage proving cost help in JSON mode returns within a deterministic budget. **Why this matters:** cost/tokens surfaces are commonly consumed by automation for budgeting. If even cost help can hang silently, claws cannot discover cost command semantics or present safe budget diagnostics before running potentially slow accounting paths. Source: gaebal-gajae dogfood follow-up for the 02:00 nudge on rebuilt `./rust/target/debug/claw` `d95b230c`.
380. **Top-level `tokens --help --output-format json` hangs with zero stdout/stderr instead of returning bounded command help JSON** — dogfooded 2026-04-30 for the 02:30 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `d95b230c`. After verifying #358 covered `cost --help`, a fresh adjacent probe on the token-budget surface showed the same silent failure class: repeated bounded runs of `timeout 8 ./rust/target/debug/claw tokens --help --output-format json` exited `124` with `stdout=0` and `stderr=0`. In the same rebuilt binary, `version --output-format json` returned promptly with version/build metadata, proving the binary itself and JSON output path are reachable. This is distinct from #358's cost help hang: the affected surface is the sibling `tokens` command help, which agents use before estimating prompt/session token budgets. **Required fix shape:** (a) make `tokens --help --output-format json` return static/bounded stdout JSON with `kind:"help"` or `kind:"tokens"`, `action:"help"`, usage, options, examples, supported output formats, and related slash/direct commands; (b) ensure help rendering does not initialize slow token accounting, session, or provider state; (c) if any dynamic provider is consulted, return a typed JSON timeout/unavailable error instead of hanging; (d) add regression coverage proving tokens help in JSON mode returns within a deterministic budget. **Why this matters:** token budgeting is a preflight clawability surface. If help hangs silently, automation cannot safely discover how to inspect or constrain token usage before running expensive prompts, and budget-aware wrappers stall at the discovery step. Source: gaebal-gajae dogfood follow-up for the 02:30 nudge on rebuilt `./rust/target/debug/claw` `d95b230c`.
381. **Top-level `cache --help --output-format json` hangs with zero stdout/stderr instead of returning bounded command help JSON** — dogfooded 2026-04-30 for the 03:00 nudge on current `origin/main` / rebuilt `./rust/target/debug/claw` with embedded `git_sha` `d95b230c`. After #358 and #380 landed for the cost/tokens preflight help hangs, a fresh adjacent probe on the cache-control surface showed the same silent failure class: repeated bounded runs of `timeout --kill-after=1s 8s ./rust/target/debug/claw cache --help --output-format json` exited `124` with `stdout=0` and `stderr=0`. In the same rebuilt binary, `version --output-format json` returned promptly with version/build metadata, proving the binary itself and JSON output path are reachable. This is distinct from the separate `/cache` slash-command envelope mismatch class: the affected surface here is top-level `cache` command help, where agents need bounded local discovery before deciding whether to inspect, clear, or summarize cache state. **Required fix shape:** (a) make `cache --help --output-format json` return static/bounded stdout JSON with `kind:"help"` or `kind:"cache"`, `action:"help"`, usage, options, examples, supported output formats, and related slash/direct commands; (b) ensure help rendering does not initialize slow cache/session/provider state; (c) if any dynamic provider is consulted, return a typed JSON timeout/unavailable error instead of hanging; (d) add regression coverage proving cache help in JSON mode returns within a deterministic budget. **Why this matters:** cache inspection and cleanup are recovery/control-plane operations. If cache help hangs silently, claws cannot safely discover cache semantics before attempting cleanup, and automation stalls before it can choose a non-destructive cache action. Source: gaebal-gajae dogfood follow-up for the 03:00 nudge on rebuilt `./rust/target/debug/claw` `d95b230c`.
422. **`export --output-format json` and `--resume latest` report the same "no managed sessions" scenario using two different `kind` codes — `no_managed_sessions` vs `session_load_failed` — making "no session found" undetectable by a single kind-code check** — dogfooded 2026-04-30 KST (UTC+9) by Jobdori on `e939777f`. Running `claw export --output-format json` with no session present returns (on stderr, exit 1): `{"error":"no managed sessions found in .claw/sessions/<fingerprint>/","hint":"Start \`claw\` to create a session, then rerun with \`--resume latest\`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible.","kind":"no_managed_sessions","type":"error"}`. Running `claw --resume latest /status --output-format json` with no session present returns (on stderr, exit 1): `{"error":"failed to restore session: no managed sessions found in .claw/sessions/<fingerprint>/","hint":"Start \`claw\` to create a session, then rerun with \`--resume latest\`.\nNote: claw partitions sessions per workspace fingerprint; sessions from other CWDs are invisible.","kind":"session_load_failed","type":"error"}`. Both describe the same root condition — there are no sessions to operate on — but they expose it via different `kind` discriminants. Automation that checks `kind == "no_managed_sessions"` to detect a cold workspace will miss the `--resume` path's `session_load_failed`, and vice versa. A wrapper that guards "run with --resume only if a session exists" must special-case both codes. The hint text is identical between them, suggesting the messages are logically equivalent. Additionally neither code matches the proposed canonical names `session_not_found` / `session_load_failed` as stable `ErrorKind` discriminants described in ROADMAP #77's fix shape, which explicitly proposes typed error-kind codes for session lifecycle failures. **Required fix shape:** (a) unify "no sessions found for this workspace fingerprint" under a single canonical `kind` code — either `no_managed_sessions` or `session_not_found` — used consistently by every command path that encounters an empty session registry; (b) if `session_load_failed` is a more general category (covering e.g. corrupt session files, IO errors, schema version mismatches), it should nest a concrete `reason:"no_managed_sessions"` or `reason:"session_not_found"` sub-field so callers can distinguish "empty registry" from "found but unreadable"; (c) align with the canonical error-kind contract proposed in #77; (d) add regression coverage proving `export` and `--resume latest` in an empty workspace both return an error with the same top-level `kind` code. **Why this matters:** session guard-rails in orchestration need a single stable `kind` to detect cold workspaces without enumerating all possible no-session synonyms. Two divergent codes for the same condition make defensive automation brittle and contradict the promise of machine-readable error envelopes. Source: Jobdori live dogfood, `e939777f`, 2026-04-30 KST (UTC+9).
407. **`config --output-format json` returns `files[].loaded:false` with no `load_error`, `not_found`, or `skip_reason` field — automation cannot distinguish "file does not exist", "file exists but parse failed", and "file exists but was skipped by policy" from the same `loaded:false` value; also `loaded_files` and `merged_keys` are bare integers with no per-file attribution** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `./claw --output-format json config` on a workspace with 5 discovered config files returns `{"kind":"config","cwd":"...","files":[{"loaded":false,"path":"/Users/yeongyu/.claw.json","source":"user"},{"loaded":true,"path":"/Users/yeongyu/.claw/settings.json","source":"user"},{"loaded":true,"path":"/Users/yeongyu/clawd/claw-code/.claw.json","source":"project"},{"loaded":false,"path":"/Users/yeongyu/clawd/claw-code/.claw/settings.json","source":"project"},{"loaded":false,"path":"/Users/yeongyu/clawd/claw-code/.claw/settings.local.json","source":"local"}],"loaded_files":2,"merged_keys":2}`. Three of five files have `loaded:false` with no accompanying `not_found:true`, `parse_error`, `io_error`, or `skip_reason`; automation must stat each path separately to guess why. Also `loaded_files:2` and `merged_keys:2` are bare counts — ambiguous whether `merged_keys:2` means 2 total top-level JSON keys across all files or 2 unique merged settings. **Required fix shape:** (a) add `not_found: bool` and optional `load_error: string` to each `files[]` entry so callers can distinguish missing, parse-broken, and policy-skipped files without filesystem probing; (b) document or rename `merged_keys` as `merged_setting_count` or `total_merged_keys` to remove the int-semantics ambiguity; (c) optionally add `merged_keys_by_file: [{path, keys}]` for attribution; (d) add regression coverage proving `files[]` entries with `loaded:false` carry at minimum `not_found` distinguishing non-existent paths from load failures. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
408. **`status --output-format json` `workspace.changed_files` is ambiguous — on a workspace with 5 untracked files, `changed_files:5`, `staged_files:0`, `unstaged_files:0`, `untracked_files:5`; it is unclear whether `changed_files` is the sum of all four git-status categories or only a subset; automation cannot tell if `changed_files:5` means "5 tracked modified" or "5 total non-clean files including untracked"** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `./claw --output-format json status` returns `{"workspace":{"changed_files":5,"staged_files":0,"unstaged_files":0,"untracked_files":5,...}}``changed_files==untracked_files==5` with staged and unstaged both zero. The field name `changed_files` implies "modified tracked files" but the value equals the untracked count, not `staged+unstaged`. Without a comment or documented definition, automation must probe whether `changed_files = staged + unstaged` (excludes untracked) or `changed_files = staged + unstaged + untracked + conflicted` (total dirty). Also `git_state:"dirty · 5 files · 5 untracked"` repeats the same data as a prose string alongside the structured integer fields — redundant human-readable string alongside machine-readable integers. **Required fix shape:** (a) document and stabilize `changed_files` as either `tracked_dirty_count` (staged+unstaged only) or `total_non-clean_count` (staged+unstaged+untracked+conflicted) and rename to remove the ambiguity; (b) ensure a machine consumer can compute `is_clean` as a single boolean field without interpreting `git_state` prose; (c) deprecate or remove `git_state` prose string now that all its constituent counts are available as integers; (d) add regression coverage proving `changed_files` semantics against a workspace with staged, unstaged, untracked, and conflicted files. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
409. **`init --output-format json` emits redundant parallel artifact schemas — `artifacts[].status` and flat `created[]`/`skipped[]`/`updated[]` arrays carry identical state, and `artifacts[].status:"skipped"` omits `skip_reason`** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw init --output-format json` on a fresh directory returns a JSON object with two parallel representations of the same artifact set: (1) `artifacts: [{name, status}]` — a structured per-artifact array; and (2) `created: [...]`, `skipped: [...]`, `updated: [...]` — flat string arrays partitioned by status. Both encode the same four artifact names and their outcomes with no additional information between them. On a subsequent run in an already-initialized directory, every artifact has `status:"skipped"`, but no `reason` field is present on any artifact entry — automation cannot distinguish `"already_exists"` (safe to ignore) from `"permission_denied"`, `"dry_run"`, or `"conflicting_contents"` (each requiring a different response). The `message` field also embeds `"skipped (already exists)"` prose that is absent from the structured payload. **Required fix shape:** (a) pick one canonical artifact representation — either `artifacts[{name, status, reason?, path?}]` or the flat status arrays — and deprecate the other; (b) add a `skip_reason` or `reason` field to `artifacts[]` entries with `status:"skipped"` and `status:"error"`, using an enum such as `already_exists`, `permission_denied`, `dry_run`, `conflict`, `unknown`; (c) add optional `path` (absolute) to each artifact entry so automation can act on the real on-disk location without re-joining with `project_path`; (d) add regression coverage proving `init --output-format json` on an existing directory includes machine-classifiable skip reasons for every skipped artifact and does not rely on the prose `message` field for structured state. **Why this matters:** init is the bootstrapping surface automation uses to ensure a project is claw-ready. If skip classification requires parsing human prose and the structured payload has two redundant formats, claws either over-provision re-inits or cannot distinguish safe skips from blocked writes without brittle message scraping. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
410. **`agents list`, `skills list`, and `mcp list` use three different count-field names and divergent envelope schemas despite being sibling list commands** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running all three list commands with `--output-format json` reveals incompatible envelope shapes: `agents list` emits `count:int` at the top level plus `summary:{active,shadowed,total}` and `working_directory`; `skills list` emits no top-level `count`, only `summary:{active,shadowed,total}`, and omits `working_directory`; `mcp list` uses a different count-field name `configured_servers:int`, has no `count`, no `summary`, and instead adds `status:"ok"` and `config_load_error:null` fields absent from the other two. The three sibling commands cannot be polymorphically consumed with the same count-extraction logic, requiring per-command special-casing at the cardinality check level. **Required fix shape:** (a) define one canonical top-level count field name (`count`, `total`, or `item_count`) and use it across `agents`, `skills`, and `mcp` list envelopes; (b) define one canonical `summary` object shape with at minimum `active`, `total`, and optionally `shadowed` and include it on all three; (c) expose `working_directory` consistently on all list commands or omit it from all; (d) add regression coverage proving the three list envelopes share the same count-field name and summary shape before each release. **Why this matters:** orchestration lanes that inventory agents, skills, and servers before delegation need one count-extraction pattern. Three different field names force per-command special-casing of the most basic cardinality check. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
411. **`plugins enable/disable --output-format json` always emits `reload_runtime:true` regardless of whether state actually changed, and omits `previous_status`, `changed`, `version`, and `source` fields — automation cannot tell if a reload is necessary or if the mutation was a no-op** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw plugins enable example-bundled --output-format json` on an already-enabled plugin returns `{"action":"enable","kind":"plugin","message":"…","reload_runtime":true,"target":"example-bundled"}``reload_runtime:true` every time, even on a no-op re-enable. The same applies to idempotent `disable`. Structured fields present: `action`, `kind`, `message`, `reload_runtime`, `target`. Structured fields absent: `previous_status`, `status`, `changed`, `version`, `source`. The actual plugin name, version, and new status are embedded only in the prose `message` field (`"Result enabled example-bundled@bundled\n Name example-bundled\n Version 0.1.0\n Status enabled"`), requiring callers to scrape column-aligned text to extract the post-mutation state. A no-op mutation emitting `reload_runtime:true` forces orchestration to trigger an expensive runtime reload even when no config change occurred. **Required fix shape:** (a) add `changed:bool` so callers can skip runtime reload when `changed:false`; (b) add `previous_status` and `status` fields (enums: `enabled`/`disabled`) so pre/post state is machine-readable without parsing `message`; (c) add `version` and `source` fields at the mutation response level, consistent with `plugins list` entry shape; (d) emit `reload_runtime:false` when `changed:false`; (e) add regression coverage proving idempotent enable/disable sets `changed:false` and `reload_runtime:false`. **Why this matters:** plugin lifecycle is a hot path for automation that conditionally enables plugins before running sessions. If every enable emits `reload_runtime:true` and no `changed` field exists, orchestration must reload unconditionally or maintain external state — both brittle patterns. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
412. **`bootstrap-plan --output-format json` returns `phases: string[]` of raw Rust enum variant names with no description, steps, duration, or dependency metadata — unusable by automation** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw bootstrap-plan --output-format json` returns `{"kind":"bootstrap-plan","phases":["CliEntry","FastPathVersion","StartupProfiler","SystemPromptFastPath","ChromeMcpFastPath","DaemonWorkerFastPath","BridgeFastPath","DaemonFastPath","BackgroundSessionFastPath","TemplateFastPath","EnvironmentRunnerFastPath","MainRuntime"]}`. The envelope has only two keys: `kind` and `phases`. The `phases` array contains 12 raw Rust enum variant name strings — opaque identifiers with no `description`, no `label`, no `steps[]`, no `estimated_ms`, no `dependencies[]`, no `optional:bool`, and no `status` (enabled/disabled/skipped). Automation that calls `bootstrap-plan` to understand startup costs or profile initialization paths receives 12 name strings that reveal nothing about what each phase does, how long it takes, whether it depends on credentials/network/MCP, or which ones can be skipped. **Required fix shape:** (a) replace `phases: string[]` with `phases: [{id, label, description, optional, estimated_ms?, dependencies?, status?}]`; (b) add a top-level `total_phases` count; (c) mark network/credential-dependent phases with a `requires_auth:bool` or `deps:["network","credentials","mcp"]` field so automation can plan for unavailability; (d) add regression coverage proving each phase entry has at least `id`, `label`, and `description` fields and that the count matches the phases array length. **Why this matters:** bootstrap-plan is the startup-cost introspection surface. If its JSON output is 12 opaque variant name strings, automation cannot profile startup, identify slow phases, skip optional phases, or present meaningful startup diagnostics — the entire command serves only as a list of internal identifiers. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
413. **`acp --output-format json` leaks internal ROADMAP tracking numbers and implementation notes as top-level JSON fields — `discoverability_tracking:"ROADMAP #64a"` and `tracking:"ROADMAP #76"` are internal backlog references that should not appear in the public machine-readable contract** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw acp --output-format json` returns a ten-key envelope: `aliases`, `discoverability_tracking`, `kind`, `launch_command`, `message`, `recommended_workflows`, `serve_alias_only`, `status`, `supported`, `tracking`. Two fields are verbatim internal backlog cross-references: `"discoverability_tracking":"ROADMAP #64a"` and `"tracking":"ROADMAP #76"`. These were presumably used during initial scaffolding to track which backlog items the stub relates to, but they are now part of the public JSON contract that automation consumes. The `message` field also contains implementation-note prose (`"ACP/Zed editor integration is not implemented in claw-code yet. \`claw acp serve\`..."`) that describes the build state rather than the command's machine-readable status. **Required fix shape:** (a) remove `discoverability_tracking` and `tracking` from the public JSON envelope or move them to an optional `_debug` or `_meta` sub-object gated on a debug flag; (b) replace `message` prose with a structured `reason` enum (`"not_implemented"`, `"discoverability_only"`, `"serve_only"`) plus optional `detail` string; (c) rename `supported:false` + `status:"discoverability_only"` to a single typed `availability` object with `status`, `reason`, and `target_command` fields; (d) add regression coverage proving the public `acp --output-format json` envelope contains no internal tracking/backlog fields and that `message` is not the sole machine-classifiable signal. **Why this matters:** public JSON APIs should not leak internal ticket references. Automation that snapshots or validates the ACP JSON schema will embed these internal identifiers into external contracts and need to change every time backlog numbering shifts. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
415. **`config <section> --output-format json` returns `merged_keys:int` (a count) with no actual merged key-value pairs — automation cannot read the resolved configuration values from JSON** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw config env --output-format json`, `claw config model --output-format json`, or `claw config hooks --output-format json` all return an identical five-key envelope: `{"cwd":"...","files":[...],"kind":"config","loaded_files":2,"merged_keys":1}`. The `merged_keys` field is an integer count of how many keys were merged across the loaded files, not an object or array of the actual key names and resolved values. The `files` array shows which config files were loaded/missing but contains no per-file key-value content. The merged section content — the actual resolved `env`, `model`, or `hooks` configuration — is entirely absent from the JSON output. It only appears in the prose output as a "Merged section: env / <value>" block. **Required fix shape:** (a) add a `merged` or `resolved` object/array field to the JSON envelope containing the actual key-value pairs that resulted from merging the loaded config files for the requested section; (b) rename `merged_keys` from an integer count to either remove it (derivable from `len(merged)`) or keep it as a companion count field; (c) for each entry in `merged`, include `key`, `value`, and optionally `source_file` so automation can attribute which file contributed the value; (d) add regression coverage proving `config env --output-format json` with a non-empty env section populates `merged` (or equivalent) with the actual resolved key-value pairs. **Why this matters:** the entire purpose of `config env/model/hooks --output-format json` is to allow automation to read the resolved runtime configuration without screen-scraping prose. Returning only a count defeats the purpose and forces callers to either re-parse the prose output or re-read and merge the source config files themselves. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
416. **`plugins list --output-format json` returns the mutation response shape with a prose `message` table instead of a structured `plugins:[]` array — `name`, `version`, `status`, `source` are embedded in `message` prose only** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw plugins list --output-format json` returns `{"action":"list","kind":"plugin","message":"Plugins\n example-bundled v0.1.0 disabled\n sample-hooks v0.1.0 disabled","reload_runtime":false,"target":null}`. This is the same four-key response envelope used by `plugins enable` and `plugins disable` mutation commands, not a list envelope. The `message` field contains the full rendered prose table (plugin name, version, and status as whitespace-aligned columns), but no `plugins` array with structured per-entry objects. `target` is `null` because no specific plugin was targeted. The `reload_runtime:false` field is meaningless for a read-only list operation. **This is distinct from ROADMAP #411** which covers the mutation commands' own missing `changed`/`previous_status`/`version`/`source` fields — #416 targets the list command's structural mismatch: it uses the mutation envelope entirely instead of emitting a dedicated list schema. **Required fix shape:** (a) emit a distinct `{kind:"plugin_list", plugins:[{name, version, status, source, path?, description?}], count}` envelope for the `list` action; (b) omit `action`, `reload_runtime`, and `target` from list responses (mutation-only fields); (c) the `message` field should be absent or optional and must not be the sole machine-readable inventory surface; (d) add regression coverage proving `plugins list --output-format json` populates a `plugins` array with at least `name`, `version`, and `status` fields for each installed plugin. **Why this matters:** automation that calls `plugins list --output-format json` to discover installed plugin inventory receives only a whitespace-aligned prose table in a string field, with `reload_runtime:false` and `target:null` as the only other machine-readable signals — identical noise to what a failed enable command returns. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
418. **`system-prompt --output-format json` exposes `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` as a literal element in the `sections` array — an internal split delimiter leaked into the public structured output** — dogfooded 2026-04-30 by Jobdori on `e939777f`. Running `claw system-prompt --output-format json` returns `{"kind":"system-prompt","message":"<full prose>","sections":["You are an interactive agent...", "# System\n...", "# Doing tasks\n...", "# Executing actions with care\n...", "__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__", "# Environment context\n...", "# Project context\n...", "# Claude instructions\n...", "# Runtime config\n..."]}`. The `sections` array has 9 elements; element index 4 is the raw string `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"`. This internal sentinel marks the boundary between the static and dynamic sections of the compiled system prompt, used during assembly to split the prompt at injection time. It appears in the public JSON output verbatim as a first-class section, indistinguishable from real sections by type alone. Automation that iterates `sections[]` must special-case this sentinel or it will process an internal implementation string as if it were a real system prompt section. **Required fix shape:** (a) strip `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` and any similar internal delimiters from the `sections` array before serializing to JSON; (b) if the static/dynamic boundary is semantically meaningful for callers, expose it as a structured metadata field such as `boundary_index:4` or as a `section_type:"static"|"dynamic"` field on each section entry, not as a raw sentinel string in the array; (c) rename the `sections` type from `string[]` to `[{id, type, content}]` to enable this without breaking the boundary signal; (d) add regression coverage proving the `system-prompt --output-format json` output's `sections` array contains no elements whose value equals `"__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__"` or matches `/__[A-Z_]+__/`. **Why this matters:** internal sentinel strings in public JSON are a contract liability — they couple the wire format to internal implementation details. Any refactor that renames or removes the sentinel breaks callers that don't special-case it, and automation that doesn't know to filter it will miscount, misparse, or misrender the system prompt. Source: Jobdori live dogfood, `e939777f`, 2026-04-30.
419. **`mcp <unknown-subcommand> --output-format json` returns `action:"help"` + `unexpected:<arg>` with exit 0 instead of an error envelope — unrecognized MCP subcommands silently succeed** — dogfooded 2026-05-01 by Jobdori on `e939777f`. Running `claw mcp add --output-format json` or `claw mcp remove --output-format json` (subcommands that do not exist) returns exit 0 with stdout JSON `{"action":"help","kind":"mcp","unexpected":"add","usage":{"direct_cli":"claw mcp [list|show <server>|help]","slash_command":"/mcp [list|show <server>|help]","sources":[...]}}`. Exit code is 0. The `action` field is `"help"` — not `"error"` — even though the caller issued a recognized token (`add`/`remove`) that maps to a real but unimplemented feature. The `unexpected` field correctly identifies the unrecognized arg, but automation that checks `exit == 0` or `action != "error"` will treat this as a successful invocation. This is distinct from ROADMAP #108 which covers *unrecognized CLI subcommands* falling through to the LLM prompt path — #419 targets MCP-specific *known-but-unimplemented* subcommands that return `action:"help"` with exit 0 instead of an explicit `action:"error"` envelope. **Required fix shape:** (a) return a non-zero exit code (exit 1 or exit 2) when an unrecognized or unimplemented MCP subcommand is provided; (b) emit `action:"error"` (or `kind:"error"`) with a `code:"unknown_subcommand"` and `unknown:"add"` field instead of `action:"help"`; (c) optionally include the help/usage payload as a sibling field `suggestion:{usage:{...}}` for context; (d) add regression coverage proving `mcp <unknown> --output-format json` returns a non-zero exit code and a non-help action token. **Why this matters:** `add` and `remove` are common MCP lifecycle operations that users will attempt; returning `action:"help"` with exit 0 makes these look like successful no-ops to any automation that doesn't deep-inspect the `unexpected` field. A pipeline that runs `claw mcp add my-server ... && claw mcp show my-server` will silently proceed to the show step even though add silently no-oped. Source: Jobdori live dogfood, `e939777f`, 2026-05-01.
420. **`plugins help --output-format json` returns the mutation response shape (`message`, `reload_runtime`, `target`) instead of the help envelope (`action:"help"`, `kind`, `unexpected`, `usage`) that `mcp help`, `agents help`, and `skills help` all use — schema drift within the same command family** — dogfooded 2026-05-01 by Jobdori on `e939777f`. Running `claw plugins help --output-format json` returns `{"action":"help","kind":"plugin","message":"Unknown /plugins action 'help'. Use list, install, enable, disable, uninstall, or update.","reload_runtime":false,"target":null}`. By contrast, `claw mcp help --output-format json`, `claw agents help --output-format json`, and `claw skills help --output-format json` all return a help envelope: `{"action":"help","kind":"<surface>","unexpected":null,"usage":{"direct_cli":"...","slash_command":"...","sources":[...]}}`. The `plugins` subgroup has not adopted the help envelope schema used by all sibling subgroups. Instead it uses the mutation response shape (`message`, `reload_runtime`, `target`) with an error string in `message` that calls `help` an "unknown action." Automation that checks `usage.direct_cli` to discover plugin commands gets a `TypeError` (key not found) on the plugins help path while succeeding on all sibling subgroups. **Required fix shape:** (a) make `plugins help` return the same help envelope as `mcp help`/`agents help`/`skills help`: `{action:"help", kind:"plugin", unexpected:null, usage:{direct_cli:"claw plugins [list|enable|disable|install|uninstall|update|help]", slash_command:"/plugins [...]", sources:[...]}`; (b) drop `reload_runtime` and `target` from help responses for all plugin subcommands; (c) add regression coverage proving `plugins help --output-format json` contains a `usage.direct_cli` field matching the same envelope shape as `mcp help`/`agents help`/`skills help`; (d) audit all subgroup `help` handlers for the same mutation-envelope contamination. **Why this matters:** help discovery is the bootstrap surface for automation. If `plugins help --output-format json` returns a mutation envelope with an error message instead of a usage envelope, automated schema discovery fails silently for the entire plugins subgroup while working for every other subgroup. Source: Jobdori live dogfood, `e939777f`, 2026-05-01.
360. **`/tokens`, `/stats`, and `/cost --output-format json` return identical token-count blobs differing only in `kind`; none exposes `context_window`, `context_remaining`, or cost in USD** — dogfooded 2026-04-30 by Jobdori on `c6c01bea`. Running all three commands under `--output-format json --resume latest` returns structurally identical responses with the same `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` values — only the `kind` field differs (`"tokens"`, `"stats"`, `"cost"` respectively). None includes `context_window` (the model's max token limit), `context_remaining` (available headroom), `cost_usd`, `currency`, or `model_id`. An orchestrator tracking context utilisation cannot determine how close the session is to the limit; an orchestrator tracking spend has no dollar amount. The three commands appear to be the same handler with a different `kind` label, providing no additional surface. **Required fix shape:** (a) `/tokens` — add `context_window`, `context_remaining`, `context_utilisation_pct`, and `model_id`; (b) `/cost` — add `cost_usd`, `currency`, and per-type cost breakdown; (c) `/stats` — combine token counts + cost + context window into a session summary; (d) if the commands are intentionally separated, differentiate their payloads so each name delivers its named information rather than returning the same blob; (e) add regression coverage confirming `/tokens` includes `context_window > 0` and `/cost` includes `cost_usd >= 0` for a session with at least one completed turn. Source: Jobdori live dogfood, mengmotaHost, `c6c01bea`, 2026-04-30.

View File

@@ -14,6 +14,11 @@ const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"too many tokens",
"prompt is too long",
"input is too long",
"input tokens exceed",
"configured limit",
"messages resulted in",
"completion tokens",
"prompt tokens",
"request is too large",
];
@@ -542,6 +547,26 @@ mod tests {
assert_eq!(error.request_id(), Some("req_ctx_123"));
}
#[test]
fn classifies_openai_configured_limit_errors_as_context_window_failures() {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(),
),
request_id: Some("req_ctx_openai_123".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_openai_123"));
}
#[test]
fn missing_credentials_without_hint_renders_the_canonical_message() {
// given

View File

@@ -252,17 +252,16 @@ pub fn detect_provider_kind(model: &str) -> ProviderKind {
#[must_use]
pub fn max_tokens_for_model(model: &str) -> u32 {
model_token_limit(model).map_or_else(
|| {
let canonical = resolve_model_alias(model);
if canonical.contains("opus") {
32_000
} else {
64_000
}
},
|limit| limit.max_output_tokens,
)
let canonical = resolve_model_alias(model);
let heuristic = if canonical.contains("opus") {
32_000
} else {
64_000
};
model_token_limit(model)
.map(|limit| heuristic.min(limit.max_output_tokens))
.unwrap_or(heuristic)
}
/// Returns the effective max output tokens for a model, preferring a plugin
@@ -276,7 +275,8 @@ pub fn max_tokens_for_model_with_override(model: &str, plugin_override: Option<u
#[must_use]
pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
let canonical = resolve_model_alias(model);
match canonical.as_str() {
let base_model = canonical.rsplit('/').next().unwrap_or(canonical.as_str());
match base_model {
"claude-opus-4-6" => Some(ModelTokenLimit {
max_output_tokens: 32_000,
context_window_tokens: 200_000,
@@ -289,6 +289,20 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 64_000,
context_window_tokens: 131_072,
}),
// GPT-4.1 family via the OpenAI API.
"gpt-4.1" | "gpt-4.1-mini" | "gpt-4.1-nano" => Some(ModelTokenLimit {
max_output_tokens: 32_768,
context_window_tokens: 1_047_576,
}),
// GPT-5.4 family via the OpenAI API.
"gpt-5.4" => Some(ModelTokenLimit {
max_output_tokens: 128_000,
context_window_tokens: 1_000_000,
}),
"gpt-5.4-mini" | "gpt-5.4-nano" => Some(ModelTokenLimit {
max_output_tokens: 128_000,
context_window_tokens: 400_000,
}),
// Kimi models via DashScope (Moonshot AI)
// Source: https://platform.moonshot.cn/docs/intro
"kimi-k2.5" | "kimi-k1.5" => Some(ModelTokenLimit {
@@ -614,6 +628,15 @@ mod tests {
fn keeps_existing_max_token_heuristic() {
assert_eq!(max_tokens_for_model("opus"), 32_000);
assert_eq!(max_tokens_for_model("grok-3"), 64_000);
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
}
#[test]
fn caps_default_max_tokens_to_openai_model_limits() {
assert_eq!(max_tokens_for_model("gpt-4.1-mini"), 32_768);
assert_eq!(max_tokens_for_model("openai/gpt-4.1-mini"), 32_768);
assert_eq!(max_tokens_for_model("gpt-5.4"), 64_000);
assert_eq!(max_tokens_for_model("openai/gpt-5.4"), 64_000);
}
#[test]
@@ -680,6 +703,18 @@ mod tests {
.context_window_tokens,
131_072
);
assert_eq!(
model_token_limit("openai/gpt-4.1-mini")
.expect("openai/gpt-4.1-mini should be registered")
.context_window_tokens,
1_047_576
);
assert_eq!(
model_token_limit("gpt-5.4")
.expect("gpt-5.4 should be registered")
.context_window_tokens,
1_000_000
);
}
#[test]
@@ -728,6 +763,42 @@ mod tests {
}
}
#[test]
fn preflight_blocks_oversized_requests_for_gpt_5_4() {
let request = MessageRequest {
model: "gpt-5.4".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(3_900_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: true,
..Default::default()
};
let error = preflight_message_request(&request)
.expect_err("oversized gpt-5.4 request should be rejected before the provider call");
match error {
ApiError::ContextWindowExceeded {
model,
requested_output_tokens,
context_window_tokens,
..
} => {
assert_eq!(model, "gpt-5.4");
assert_eq!(requested_output_tokens, 64_000);
assert_eq!(context_window_tokens, 1_000_000);
}
other => panic!("expected context-window preflight failure, got {other:?}"),
}
}
#[test]
fn preflight_skips_unknown_models() {
let request = MessageRequest {

View File

@@ -2371,6 +2371,40 @@ pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter))
.collect();
Ok(render_skills_report(&filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report(&skills))
}
Some(args)
if args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
.splitn(2, ' ')
.nth(1)
.unwrap_or_default()
.trim()
.to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
Ok(render_skills_report(&matched))
}
Some("install") => Ok(render_skills_usage(Some("install"))),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
@@ -2402,6 +2436,40 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json(&skills))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let filtered: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase().contains(&filter))
.collect();
Ok(render_skills_report_json(&filtered))
}
Some("show" | "info" | "describe") => {
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
Ok(render_skills_report_json(&skills))
}
Some(args)
if args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
let name = args
.splitn(2, ' ')
.nth(1)
.unwrap_or_default()
.trim()
.to_lowercase();
let roots = discover_skill_roots(cwd);
let skills = load_skills_from_roots(&roots)?;
let matched: Vec<_> = skills
.into_iter()
.filter(|s| s.name.to_lowercase() == name)
.collect();
Ok(render_skills_report_json(&matched))
}
Some("install") => Ok(render_skills_usage_json(Some("install"))),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
@@ -2419,10 +2487,20 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
#[must_use]
pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch {
match normalize_optional_args(args) {
None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local,
None | Some("list" | "help" | "-h" | "--help" | "show" | "info" | "describe") => {
SkillSlashDispatch::Local
}
Some(args) if args == "install" || args.starts_with("install ") => {
SkillSlashDispatch::Local
}
Some(args)
if args.starts_with("list ")
|| args.starts_with("show ")
|| args.starts_with("info ")
|| args.starts_with("describe ") =>
{
SkillSlashDispatch::Local
}
Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))),
}
}
@@ -2596,10 +2674,44 @@ fn render_mcp_report_for(
)),
}
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
// `mcp list <filter>` — list does not accept arguments; treat as unsupported action.
Ok(render_mcp_unsupported_action_text(
args,
"list accepts no filter argument; use `claw mcp list`",
))
}
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
Ok(render_mcp_unsupported_action_text(
args,
"use `claw mcp show <server>` to inspect a server",
))
}
Some(args) => Ok(render_mcp_usage(Some(args))),
}
}
fn render_mcp_unsupported_action_text(action: &str, hint: &str) -> String {
format!(
"MCP\n Error unsupported action '{action}'\n Hint {hint}\n Usage /mcp [list|show <server>|help]"
)
}
fn render_mcp_unsupported_action_json(action: &str, hint: &str) -> Value {
json!({
"kind": "mcp",
"action": "error",
"ok": false,
"error_kind": "unsupported_action",
"requested_action": action,
"hint": hint,
"usage": {
"slash_command": "/mcp [list|show <server>|help]",
"direct_cli": "claw mcp [list|show <server>|help]",
},
})
}
fn render_mcp_report_json_for(
loader: &ConfigLoader,
cwd: &Path,
@@ -2680,6 +2792,18 @@ fn render_mcp_report_json_for(
})),
}
}
Some(args) if args.split_whitespace().next() == Some("list") && args.contains(' ') => {
Ok(render_mcp_unsupported_action_json(
args,
"list accepts no filter argument; use `claw mcp list`",
))
}
Some(args) if matches!(args.split_whitespace().next(), Some("info" | "describe")) => {
Ok(render_mcp_unsupported_action_json(
args,
"use `claw mcp show <server>` to inspect a server",
))
}
Some(args) => Ok(render_mcp_usage_json(Some(args))),
}
}
@@ -4619,6 +4743,32 @@ mod tests {
assert!(agents_error.contains(" Usage /agents [list|help]"));
}
#[test]
fn skills_show_and_list_filter_do_not_invoke_model() {
// `show`, `info`, `list <filter>` must route to Local, not Invoke.
// Regression for: `claw skills show plan` unexpectedly spawned a model session.
for token in &["show", "info", "describe"] {
assert_eq!(
classify_skills_slash_command(Some(token)),
SkillSlashDispatch::Local,
"`skills {token}` alone must be Local"
);
}
for prefix in &["show ", "info ", "list ", "describe "] {
let arg = format!("{prefix}plan");
assert_eq!(
classify_skills_slash_command(Some(&arg)),
SkillSlashDispatch::Local,
"`skills {arg}` must be Local, not Invoke"
);
}
// Bare invocable tokens still dispatch to Invoke.
assert_eq!(
classify_skills_slash_command(Some("plan")),
SkillSlashDispatch::Invoke("$plan".to_string()),
);
}
#[test]
fn accepts_skills_invocation_arguments_for_prompt_dispatch() {
assert_eq!(
@@ -4641,6 +4791,38 @@ mod tests {
);
}
#[test]
fn mcp_unsupported_actions_return_typed_error_not_generic_help() {
// `mcp info <name>` and `mcp list <filter>` must return typed errors, not raw help.
// Regression for #504: these previously fell through to render_mcp_usage with
// unexpected=arg, giving no machine-readable error_kind.
use crate::handle_mcp_slash_command_json;
use std::path::PathBuf;
let cwd = PathBuf::from("/tmp");
let info_json = handle_mcp_slash_command_json(Some("info nonexistent"), &cwd)
.expect("info nonexistent should not error at IO level");
assert_eq!(info_json["kind"], "mcp");
assert_eq!(info_json["ok"], false);
assert_eq!(info_json["error_kind"], "unsupported_action");
assert!(info_json["hint"]
.as_str()
.unwrap_or_default()
.contains("show"));
let list_filter_json = handle_mcp_slash_command_json(Some("list nonexistent"), &cwd)
.expect("list nonexistent should not error at IO level");
assert_eq!(list_filter_json["kind"], "mcp");
assert_eq!(list_filter_json["ok"], false);
assert_eq!(list_filter_json["error_kind"], "unsupported_action");
let describe_json = handle_mcp_slash_command_json(Some("describe myserver"), &cwd)
.expect("describe myserver should not error at IO level");
assert_eq!(describe_json["kind"], "mcp");
assert_eq!(describe_json["ok"], false);
assert_eq!(describe_json["error_kind"], "unsupported_action");
}
#[test]
fn rejects_invalid_mcp_arguments() {
let show_error = parse_error_message("/mcp show alpha beta");

View File

@@ -1,4 +1,5 @@
use std::cmp::Reverse;
use std::collections::HashSet;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
@@ -7,7 +8,7 @@ use std::time::Instant;
use glob::Pattern;
use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use walkdir::{DirEntry, WalkDir};
/// Maximum file size that can be read (10 MB).
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
@@ -15,6 +16,15 @@ const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
/// Maximum file size that can be written (10 MB).
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
const GLOB_SEARCH_IGNORED_DIRS: &[&str] = &[
".git",
"node_modules",
".build",
"target",
"dist",
"coverage",
];
/// Check whether a file appears to contain binary content by examining
/// the first chunk for NUL bytes.
fn is_binary_file(path: &Path) -> io::Result<bool> {
@@ -313,14 +323,22 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
// `Assets/**/*.{cs,uxml,uss}` work correctly.
let expanded = expand_braces(&search_pattern);
let mut seen = std::collections::HashSet::new();
let mut seen = HashSet::new();
let mut matches = Vec::new();
for pat in &expanded {
let entries = glob::glob(pat)
let compiled = Pattern::new(pat)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
let walk_root = derive_glob_walk_root(pat);
let entries = WalkDir::new(&walk_root)
.into_iter()
.filter_entry(|entry| !should_skip_glob_dir(entry));
for entry in entries.flatten() {
if entry.is_file() && seen.insert(entry.clone()) {
matches.push(entry);
let candidate = entry.path();
if entry.file_type().is_file()
&& compiled.matches_path(candidate)
&& seen.insert(candidate.to_path_buf())
{
matches.push(candidate.to_path_buf());
}
}
}
@@ -457,6 +475,39 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
})
}
fn should_skip_glob_dir(entry: &DirEntry) -> bool {
entry.file_type().is_dir()
&& entry
.file_name()
.to_str()
.is_some_and(|name| GLOB_SEARCH_IGNORED_DIRS.contains(&name))
}
fn derive_glob_walk_root(pattern: &str) -> PathBuf {
let path = Path::new(pattern);
let mut prefix = PathBuf::new();
let mut saw_component = false;
for component in path.components() {
let text = component.as_os_str().to_string_lossy();
if component_contains_glob(&text) {
break;
}
prefix.push(component.as_os_str());
saw_component = true;
}
if saw_component {
prefix
} else {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
}
fn component_contains_glob(component: &str) -> bool {
component.contains('*') || component.contains('?') || component.contains('[')
}
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
if base_path.is_file() {
return Ok(vec![base_path.to_path_buf()]);
@@ -651,11 +702,13 @@ fn expand_braces(pattern: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
component_contains_glob, derive_glob_walk_root, edit_file, expand_braces, glob_search,
grep_search, is_symlink_escape, read_file, read_file_in_workspace, write_file,
GrepSearchInput, MAX_WRITE_SIZE,
};
fn temp_path(name: &str) -> std::path::PathBuf {
@@ -836,4 +889,50 @@ mod tests {
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn glob_search_skips_common_heavy_directories() {
let dir = temp_path("glob-ignored-dirs");
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::create_dir_all(dir.join("docs")).unwrap();
std::fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
std::fs::create_dir_all(dir.join(".build/checkouts/pkg")).unwrap();
std::fs::create_dir_all(dir.join("target/debug/deps")).unwrap();
std::fs::write(dir.join("src/AGENTS.md"), "src").unwrap();
std::fs::write(dir.join("docs/AGENTS.md"), "docs").unwrap();
std::fs::write(dir.join("node_modules/pkg/AGENTS.md"), "node_modules").unwrap();
std::fs::write(dir.join(".build/checkouts/pkg/AGENTS.md"), ".build").unwrap();
std::fs::write(dir.join("target/debug/deps/AGENTS.md"), "target").unwrap();
let result =
glob_search("**/AGENTS.md", Some(dir.to_str().unwrap())).expect("glob should succeed");
assert_eq!(result.num_files, 2, "ignored dirs should be pruned");
assert!(result
.filenames
.iter()
.any(|path| path.ends_with("src/AGENTS.md")));
assert!(result
.filenames
.iter()
.any(|path| path.ends_with("docs/AGENTS.md")));
assert!(!result
.filenames
.iter()
.any(|path| path.contains("node_modules")
|| path.contains(".build")
|| path.contains("/target/")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn derive_glob_walk_root_stops_at_first_glob_component() {
let root = derive_glob_walk_root("/tmp/demo/**/AGENTS.md");
assert_eq!(root, PathBuf::from("/tmp/demo"));
assert!(component_contains_glob("**"));
assert!(component_contains_glob("*.rs"));
assert!(!component_contains_glob("src"));
}
}

View File

@@ -148,11 +148,7 @@ impl ModelProvenance {
}
fn max_tokens_for_model(model: &str) -> u32 {
if model.contains("opus") {
32_000
} else {
64_000
}
api::max_tokens_for_model(model)
}
// Build-time constants injected by build.rs (fall back to static values when
// build.rs hasn't run, e.g. in doc-test or unusual toolchain environments).
@@ -464,7 +460,10 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
reasoning_effort,
allow_broad_cwd,
)?,
CliAction::HelpTopic(topic) => print_help_topic(topic),
CliAction::HelpTopic {
topic,
output_format,
} => print_help_topic(topic, output_format)?,
CliAction::Help { output_format } => print_help(output_format)?,
}
Ok(())
@@ -567,7 +566,10 @@ enum CliAction {
reasoning_effort: Option<String>,
allow_broad_cwd: bool,
},
HelpTopic(LocalHelpTopic),
HelpTopic {
topic: LocalHelpTopic,
output_format: CliOutputFormat,
},
// prompt-mode formatting is only supported for non-interactive runs
Help {
output_format: CliOutputFormat,
@@ -843,7 +845,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
if rest.first().map(String::as_str) == Some("--resume") {
return parse_resume_args(&rest[1..], output_format);
}
if let Some(action) = parse_local_help_action(&rest) {
if let Some(action) = parse_local_help_action(&rest, output_format) {
return action;
}
if let Some(action) = parse_single_word_command_alias(
@@ -877,13 +879,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// `missing Anthropic credentials` even though the command is purely
// local introspection. Mirror `agents`/`mcp`/`skills`: action is the
// first positional arg, target is the second.
"plugins" => {
// `plugin` (singular) and `marketplace` are aliases for `plugins`.
// All three must route to the same local handler so that no form
// falls through to the LLM/prompt path.
"plugins" | "plugin" | "marketplace" => {
let tail = &rest[1..];
let action = tail.first().cloned();
let target = tail.get(1).cloned();
if tail.len() > 2 {
return Err(format!(
"unexpected extra arguments after `claw plugins {}`: {}",
"unexpected extra arguments after `claw {} {}`: {}",
rest[0],
tail[..2].join(" "),
tail[2..].join(" ")
));
@@ -926,6 +932,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
Ok(CliAction::Diff { output_format })
}
// `claw permissions <mode>` falls through to the LLM when called
// with a subcommand argument because parse_single_word_command_alias
// only intercepts the bare single-word form. Catch all multi-word
// forms here and return a structured guidance error so no network
// call or session is created.
"permissions" => Err(format!(
"`claw permissions` is a slash command. Start `claw` and run `/permissions` inside the REPL.\n Usage /permissions [read-only|workspace-write|danger-full-access]"
)),
"skills" => {
let args = join_optional_args(&rest[1..]);
match classify_skills_slash_command(args.as_deref()) {
@@ -1021,7 +1035,10 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
}
}
fn parse_local_help_action(rest: &[String]) -> Option<Result<CliAction, String>> {
fn parse_local_help_action(
rest: &[String],
output_format: CliOutputFormat,
) -> Option<Result<CliAction, String>> {
if rest.len() != 2 || !is_help_flag(&rest[1]) {
return None;
}
@@ -1044,7 +1061,10 @@ fn parse_local_help_action(rest: &[String]) -> Option<Result<CliAction, String>>
"bootstrap-plan" => LocalHelpTopic::BootstrapPlan,
_ => return None,
};
Some(Ok(CliAction::HelpTopic(topic)))
Some(Ok(CliAction::HelpTopic {
topic,
output_format,
}))
}
fn is_help_flag(value: &str) -> bool {
@@ -2627,12 +2647,15 @@ fn print_version(output_format: CliOutputFormat) -> Result<(), Box<dyn std::erro
}
fn version_json_value() -> serde_json::Value {
let executable_path = env::current_exe().ok().map(|p| p.display().to_string());
json!({
"kind": "version",
"message": render_version_report(),
"version": VERSION,
"git_sha": GIT_SHA,
"target": BUILD_TARGET,
"build_date": DEFAULT_DATE,
"executable_path": executable_path,
})
}
@@ -3523,10 +3546,10 @@ fn run_resume_command(
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?),
json: Some(serde_json::json!({
"kind": "agents",
"text": handle_agents_slash_command(args.as_deref(), &cwd)?,
})),
json: Some(
serde_json::to_value(handle_agents_slash_command_json(args.as_deref(), &cwd)?)
.unwrap_or_else(|_| serde_json::json!(null)),
),
})
}
SlashCommand::Skills { args } => {
@@ -3542,6 +3565,37 @@ fn run_resume_command(
json: Some(handle_skills_slash_command_json(args.as_deref(), &cwd)?),
})
}
SlashCommand::Plugins { action, target } => {
// Only list is supported in resume mode (no runtime to reload)
match action.as_deref() {
Some("install") | Some("uninstall") | Some("enable") | Some("disable")
| Some("update") => {
return Err(
"resumed /plugins mutations are interactive-only; start `claw` and run `/plugins` in the REPL".into(),
);
}
_ => {}
}
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
let runtime_config = loader.load()?;
let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config);
let result =
handle_plugins_slash_command(action.as_deref(), target.as_deref(), &mut manager)?;
let action_str = action.as_deref().unwrap_or("list");
let json = serde_json::json!({
"kind": "plugin",
"action": action_str,
"target": target,
"message": &result.message,
"reload_runtime": result.reload_runtime,
});
Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(result.message),
json: Some(json),
})
}
SlashCommand::Doctor => {
let report = render_doctor_report()?;
Ok(ResumeCommandOutcome {
@@ -3628,7 +3682,6 @@ fn run_resume_command(
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
@@ -5062,10 +5115,17 @@ impl LiveCli {
let cwd = env::current_dir()?;
match output_format {
CliOutputFormat::Text => println!("{}", handle_mcp_slash_command(args, &cwd)?),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&handle_mcp_slash_command_json(args, &cwd)?)?
),
CliOutputFormat::Json => {
let value = handle_mcp_slash_command_json(args, &cwd)?;
// Propagate ok:false → non-zero exit so automation callers
// can rely on exit code instead of inspecting the envelope.
// (#68: mcp error envelopes previously always exited 0.)
let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false);
println!("{}", serde_json::to_string_pretty(&value)?);
if is_error {
std::process::exit(1);
}
}
}
Ok(())
}
@@ -6090,8 +6150,90 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
}
}
fn print_help_topic(topic: LocalHelpTopic) {
println!("{}", render_help_topic(topic));
fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
match topic {
LocalHelpTopic::Status => "status",
LocalHelpTopic::Sandbox => "sandbox",
LocalHelpTopic::Doctor => "doctor",
LocalHelpTopic::Acp => "acp",
LocalHelpTopic::Init => "init",
LocalHelpTopic::State => "state",
LocalHelpTopic::Export => "export",
LocalHelpTopic::Version => "version",
LocalHelpTopic::SystemPrompt => "system-prompt",
LocalHelpTopic::DumpManifests => "dump-manifests",
LocalHelpTopic::BootstrapPlan => "bootstrap-plan",
}
}
fn render_export_help_json() -> serde_json::Value {
json!({
"kind": "help",
"topic": "export",
"command": "export",
"usage": "claw export [--session <id|latest>] [--output <path>] [--output-format <format>]",
"purpose": "serialize a managed session to JSON for review, transfer, or archival",
"defaults": {
"session": LATEST_SESSION_REFERENCE,
"session_source": ".claw/sessions/",
"output": "derived from the selected session when omitted"
},
"formats": ["text", "json"],
"options": [
{
"name": "--session",
"value": "<id|latest>",
"default": LATEST_SESSION_REFERENCE,
"description": "managed session to export"
},
{
"name": "--output",
"aliases": ["-o"],
"value": "<path>",
"description": "write the exported transcript to this path"
},
{
"name": "--output-format",
"value": "<format>",
"values": ["text", "json"],
"default": "text",
"description": "format for the command result envelope"
},
{
"name": "--help",
"aliases": ["-h"],
"description": "show help for the export command"
}
],
"related": ["/session list", "claw --resume latest"]
})
}
fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value {
if topic == LocalHelpTopic::Export {
return render_export_help_json();
}
json!({
"kind": "help",
"topic": local_help_topic_command(topic),
"command": local_help_topic_command(topic),
"message": render_help_topic(topic),
})
}
fn print_help_topic(
topic: LocalHelpTopic,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Text => println!("{}", render_help_topic(topic)),
CliOutputFormat::Json => println!(
"{}",
serde_json::to_string_pretty(&render_help_topic_json(topic))?
),
}
Ok(())
}
fn print_acp_status(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
@@ -6207,7 +6349,7 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
}
fn render_config_json(
_section: Option<&str>,
section: Option<&str>,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
let loader = ConfigLoader::default_for(&cwd);
@@ -6240,13 +6382,52 @@ fn render_config_json(
})
.collect();
Ok(serde_json::json!({
let base = serde_json::json!({
"kind": "config",
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"merged_keys": runtime_config.merged().len(),
"files": files,
}))
});
if let Some(section) = section {
let section_rendered: Option<String> = match section {
"env" => runtime_config.get("env").map(|v| v.render()),
"hooks" => runtime_config.get("hooks").map(|v| v.render()),
"model" => runtime_config.get("model").map(|v| v.render()),
"plugins" => runtime_config
.get("plugins")
.or_else(|| runtime_config.get("enabledPlugins"))
.map(|v| v.render()),
other => {
return Ok(serde_json::json!({
"kind": "config",
"section": other,
"ok": false,
"error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."),
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"files": files,
}));
}
};
// Parse the rendered JSON string back into serde_json::Value so that
// section_value is a real JSON object/array in the envelope, not a quoted string.
let section_value: serde_json::Value = section_rendered
.as_deref()
.and_then(|s| serde_json::from_str(s).ok())
.unwrap_or(serde_json::Value::Null);
let mut obj = base;
let map = obj.as_object_mut().expect("base is object");
map.insert(
"section".to_string(),
serde_json::Value::String(section.to_string()),
);
map.insert("section_value".to_string(), section_value);
return Ok(obj);
}
Ok(base)
}
fn render_memory_report() -> Result<String, Box<dyn std::error::Error>> {
@@ -9249,17 +9430,17 @@ mod tests {
parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary,
parse_history_count, permission_policy, print_help_to, push_output_block,
render_config_report, render_diff_report, render_diff_report_for, render_help_topic,
render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage,
render_session_list, render_session_markdown, resolve_model_alias,
resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference,
response_to_events, resume_supported_slash_commands, run_resume_command, short_tool_id,
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt,
validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor,
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli,
LocalHelpTopic, PromptHistoryEntry, SessionLifecycleKind, SessionLifecycleSummary,
SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
STUB_COMMANDS,
render_help_topic_json, render_memory_report, render_prompt_history_report,
render_repl_help, render_resume_usage, render_session_list, render_session_markdown,
resolve_model_alias, resolve_model_alias_with_config, resolve_repl_model,
resolve_session_reference, response_to_events, resume_supported_slash_commands,
run_resume_command, short_tool_id, slash_command_completion_candidates_with_sessions,
split_error_hint, status_context, status_json_value, summarize_tool_payload_for_markdown,
try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, CliAction,
CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, InternalPromptProgressEvent,
InternalPromptProgressState, LiveCli, LocalHelpTopic, PromptHistoryEntry,
SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot,
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
@@ -9424,6 +9605,39 @@ mod tests {
);
}
#[test]
fn openai_configured_limit_errors_are_rendered_as_context_window_guidance() {
let error = ApiError::Api {
status: "400".parse().expect("status"),
error_type: Some("invalid_request_error".to_string()),
message: Some(
"Input tokens exceed the configured limit of 922000 tokens. Your messages resulted in 1860900 tokens. Please reduce the length of the messages."
.to_string(),
),
request_id: Some("req_ctx_openai_456".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
assert!(rendered.contains("Context window blocked"), "{rendered}");
assert!(rendered.contains("context_window_blocked"), "{rendered}");
assert!(
rendered.contains("Trace req_ctx_openai_456"),
"{rendered}"
);
assert!(
rendered.contains("Detail Input tokens exceed the configured limit of 922000 tokens."),
"{rendered}"
);
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Fresh session /clear --confirm"),
"{rendered}"
);
}
#[test]
fn retry_wrapped_context_window_errors_keep_recovery_guidance() {
let error = ApiError::RetriesExhausted {
@@ -10379,21 +10593,33 @@ mod tests {
assert_eq!(
parse_args(&["status".to_string(), "--help".to_string()])
.expect("status help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Status)
CliAction::HelpTopic {
topic: LocalHelpTopic::Status,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["sandbox".to_string(), "-h".to_string()])
.expect("sandbox help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Sandbox)
CliAction::HelpTopic {
topic: LocalHelpTopic::Sandbox,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["doctor".to_string(), "--help".to_string()])
.expect("doctor help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Doctor)
CliAction::HelpTopic {
topic: LocalHelpTopic::Doctor,
output_format: CliOutputFormat::Text,
}
);
assert_eq!(
parse_args(&["acp".to_string(), "--help".to_string()]).expect("acp help should parse"),
CliAction::HelpTopic(LocalHelpTopic::Acp)
CliAction::HelpTopic {
topic: LocalHelpTopic::Acp,
output_format: CliOutputFormat::Text,
}
);
}
@@ -10423,10 +10649,30 @@ mod tests {
});
assert_eq!(
parsed,
CliAction::HelpTopic(*expected_topic),
CliAction::HelpTopic {
topic: *expected_topic,
output_format: CliOutputFormat::Text,
},
"`{subcommand} {flag}` should resolve to HelpTopic({expected_topic:?})"
);
}
let json_parsed = parse_args(&[
subcommand.to_string(),
"--help".to_string(),
"--output-format".to_string(),
"json".to_string(),
])
.unwrap_or_else(|error| {
panic!("`{subcommand} --help --output-format json` should parse: {error}")
});
assert_eq!(
json_parsed,
CliAction::HelpTopic {
topic: *expected_topic,
output_format: CliOutputFormat::Json,
},
"`{subcommand} --help --output-format json` should preserve json output format"
);
// And the rendered help must actually mention the subcommand name
// (or its canonical title) so users know they got the right help.
let rendered = render_help_topic(*expected_topic);
@@ -10441,6 +10687,24 @@ mod tests {
}
}
#[test]
fn export_help_json_is_bounded_and_parseable_384() {
let value = render_help_topic_json(LocalHelpTopic::Export);
assert_eq!(value["kind"], "help");
assert_eq!(value["topic"], "export");
assert_eq!(value["command"], "export");
assert_eq!(
value["usage"],
"claw export [--session <id|latest>] [--output <path>] [--output-format <format>]"
);
assert_eq!(value["defaults"]["session"], LATEST_SESSION_REFERENCE);
assert!(value["options"].as_array().expect("options array").len() >= 4);
assert!(
value.get("message").is_none(),
"export help json should be a bounded envelope, not plaintext help wrapped in json"
);
}
#[test]
fn status_degrades_gracefully_on_malformed_mcp_config_143() {
// #143: previously `claw status` hard-failed on any config parse error,

View File

@@ -22,6 +22,42 @@ fn help_emits_json_when_requested() {
.contains("Usage:"));
}
#[test]
fn export_help_emits_bounded_json_when_requested_384() {
let root = unique_temp_dir("export-help-json");
fs::create_dir_all(&root).expect("temp dir should exist");
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
assert_eq!(parsed["kind"], "help");
assert_eq!(parsed["topic"], "export");
assert_eq!(parsed["command"], "export");
assert_eq!(
parsed["usage"],
"claw export [--session <id|latest>] [--output <path>] [--output-format <format>]"
);
assert_eq!(parsed["defaults"]["session"], "latest");
assert!(parsed["options"].as_array().expect("options").len() >= 4);
assert!(parsed.get("message").is_none());
}
#[test]
fn export_help_preserves_plaintext_in_text_mode_384() {
let root = unique_temp_dir("export-help-text");
fs::create_dir_all(&root).expect("temp dir should exist");
let output = run_claw(&root, &["export", "--help"], &[]);
assert!(
output.status.success(),
"stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("stdout utf8");
assert!(stdout.starts_with("Export\n"));
assert!(stdout.contains("Usage claw export"));
serde_json::from_str::<Value>(&stdout).expect_err("text help should remain plaintext");
}
#[test]
fn version_emits_json_when_requested() {
let root = unique_temp_dir("version-json");
@@ -30,6 +66,15 @@ fn version_emits_json_when_requested() {
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
assert_eq!(parsed["kind"], "version");
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
// Provenance fields must be present for binary identification (#507).
assert!(
parsed["build_date"].is_string(),
"build_date must be a string in version JSON"
);
assert!(
parsed["executable_path"].is_string(),
"executable_path must be a string in version JSON so callers can identify which binary is running"
);
}
#[test]
@@ -105,6 +150,18 @@ fn inventory_commands_emit_structured_json_when_requested() {
let skills = assert_json_command(&root, &["--output-format", "json", "skills"]);
assert_eq!(skills["kind"], "skills");
assert_eq!(skills["action"], "list");
let plugins = assert_json_command(&root, &["--output-format", "json", "plugins"]);
assert_eq!(plugins["kind"], "plugin");
assert_eq!(plugins["action"], "list");
assert!(
plugins["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
);
assert!(
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
);
}
#[test]
@@ -348,6 +405,62 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
assert_eq!(skills["action"], "list");
assert!(skills["summary"]["total"].is_number());
assert!(skills["skills"].is_array());
let agents = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/agents",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(agents["kind"], "agents");
assert_eq!(agents["action"], "list");
assert!(
agents["agents"].is_array(),
"agents field must be a JSON array"
);
assert!(
agents["count"].is_number(),
"count must be a number, not a text render"
);
let plugins = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"--resume",
session_path.to_str().expect("utf8 session path"),
"/plugins",
],
&[
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("utf8 config home"),
),
("HOME", home.to_str().expect("utf8 home")),
],
);
assert_eq!(plugins["kind"], "plugin");
assert_eq!(plugins["action"], "list");
assert!(
plugins["reload_runtime"].is_boolean(),
"plugins reload_runtime should be a boolean"
);
assert!(
plugins["target"].is_null(),
"plugins target should be null when no plugin is targeted"
);
}
#[test]
@@ -384,6 +497,44 @@ fn resumed_version_and_init_emit_structured_json_when_requested() {
assert!(root.join("CLAUDE.md").exists());
}
#[test]
fn config_section_json_emits_section_and_value() {
let root = unique_temp_dir("config-section-json");
fs::create_dir_all(&root).expect("temp dir should exist");
// Without a section: should return base envelope (no section field).
let base = assert_json_command(&root, &["--output-format", "json", "config"]);
assert_eq!(base["kind"], "config");
assert!(base["loaded_files"].is_number());
assert!(base["merged_keys"].is_number());
assert!(
base.get("section").is_none(),
"no section field without section arg"
);
// With a known section: should add section + section_value fields.
for section in &["model", "env", "hooks", "plugins"] {
let result = assert_json_command(&root, &["--output-format", "json", "config", section]);
assert_eq!(result["kind"], "config", "section={section}");
assert_eq!(
result["section"].as_str(),
Some(*section),
"section field must match requested section, got {result:?}"
);
assert!(
result.get("section_value").is_some(),
"section_value field must be present for section={section}"
);
}
// With an unsupported section: should return ok:false + error field.
let bad = assert_json_command(&root, &["--output-format", "json", "config", "unknown"]);
assert_eq!(bad["kind"], "config");
assert_eq!(bad["ok"], false);
assert!(bad["error"].as_str().is_some());
assert!(bad["section"].as_str().is_some());
}
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
assert_json_command_with_env(current_dir, args, &[])
}

View File

@@ -227,6 +227,8 @@ fn resumed_status_command_emits_structured_json_when_requested() {
// given
let temp_dir = unique_temp_dir("resume-status-json");
fs::create_dir_all(&temp_dir).expect("temp dir should exist");
let config_home = temp_dir.join("config-home");
fs::create_dir_all(&config_home).expect("isolated config home should exist");
let session_path = temp_dir.join("session.jsonl");
let mut session = workspace_session(&temp_dir);
@@ -238,7 +240,9 @@ fn resumed_status_command_emits_structured_json_when_requested() {
.expect("session should persist");
// when
let output = run_claw(
// Use an isolated CLAW_CONFIG_HOME so ~/.claw/settings.json is not loaded,
// which would cause loaded_config_files to be non-zero (#65).
let output = run_claw_with_env(
&temp_dir,
&[
"--output-format",
@@ -247,6 +251,7 @@ fn resumed_status_command_emits_structured_json_when_requested() {
session_path.to_str().expect("utf8 path"),
"/status",
],
&[("CLAW_CONFIG_HOME", config_home.to_str().expect("utf8 path"))],
);
// then

68
scripts/dogfood-build.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# dogfood-build.sh — Build claw from current checkout and verify provenance.
#
# Injects GIT_SHA at build time so version JSON is non-null.
# Suppresses Cargo compile noise on stderr.
# Prints the verified binary path on success. Use as:
#
# CLAW=$(bash scripts/dogfood-build.sh)
#
# Then dogfood with config isolation (avoids real user config bleeding in):
#
# CLAW_CONFIG_HOME=$(mktemp -d) $CLAW plugins list --output-format json
#
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
RUST_DIR="$REPO_ROOT/rust"
BINARY="$RUST_DIR/target/debug/claw"
EXPECTED_SHA="$(git -C "$REPO_ROOT" rev-parse --short HEAD)"
echo "▶ Building claw from $REPO_ROOT" >&2
echo " Commit: $(git -C "$REPO_ROOT" log --oneline -1)" >&2
# Inject GIT_SHA so version JSON returns a non-null sha.
# Redirect cargo stderr to /dev/null to suppress compile noise;
# on build failure cargo exits non-zero and set -e aborts.
if ! GIT_SHA="$EXPECTED_SHA" cargo build \
--manifest-path "$RUST_DIR/Cargo.toml" \
-p rusty-claude-cli -q 2>/dev/null; then
# Re-run with visible output so the user sees the error
echo "✗ Build failed — rerunning with output:" >&2
GIT_SHA="$EXPECTED_SHA" cargo build \
--manifest-path "$RUST_DIR/Cargo.toml" \
-p rusty-claude-cli 2>&1 | sed 's/^/ /' >&2
exit 1
fi
if [[ ! -x "$BINARY" ]]; then
echo "✗ Binary not found at $BINARY" >&2
exit 1
fi
BINARY_SHA=$("$BINARY" version --output-format json 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('git_sha') or 'null')" 2>/dev/null \
|| echo "null")
if [[ "$BINARY_SHA" == "null" || -z "$BINARY_SHA" ]]; then
echo "✗ Provenance check failed: binary reports git_sha: null" >&2
exit 1
fi
if [[ "$BINARY_SHA" != "$EXPECTED_SHA" ]]; then
echo "✗ Provenance mismatch: binary=$BINARY_SHA, HEAD=$EXPECTED_SHA" >&2
exit 1
fi
echo "✓ Binary verified: $BINARY_SHA == HEAD" >&2
echo "" >&2
echo " export CLAW=$BINARY" >&2
echo "" >&2
echo " Dogfood with isolated config (no real user config on stderr):" >&2
echo " CLAW_ISOLATED=\$(mktemp -d)" >&2
echo " CLAW_CONFIG_HOME=\$CLAW_ISOLATED \$CLAW plugins list --output-format json" >&2
echo " rm -rf \$CLAW_ISOLATED" >&2
echo "" >&2
echo " cargo run overhead: ~1s/invocation vs 7ms for pre-built binary." >&2
echo " Prefer pre-built binary (\$CLAW) for dogfood loops." >&2
echo "$BINARY"