Compare commits

..

169 Commits

Author SHA1 Message Date
YeonGyu-Kim
87b7e74770 fix(#806): plugins show <not-found> in text mode returned empty success instead of error 2026-05-27 22:34:10 +09:00
Bellman
ae6a207d4e fix(#3129): handle trailing json format for diff errors (#3161)
Keep malformed diff invocations with trailing JSON format flags on the parser error path and lock the contract with focused output-format regressions.

Constraint: Do not touch tracked .omx state files.

Rejected: Repeating direct binary smoke loops | local auth/provider configuration intercepts those invocations and obscures parser behavior.

Confidence: high

Scope-risk: narrow

Tested: git diff --check; cargo fmt --check; cargo test -p rusty-claude-cli diff_extra_args_have_typed_error_kind_and_hint_766 --test output_format_contract; cargo test -p rusty-claude-cli diff_trailing_json_after_malformed_args_is_bounded_json_3129 --test output_format_contract; cargo test -p rusty-claude-cli diff_non_git_dir_has_error_kind_and_hint_801 --test output_format_contract
2026-05-27 21:57:26 +09:00
YeonGyu-Kim
efd34c151a fix(#805): skills show <not-found> in text mode silently returned empty success instead of error 2026-05-27 21:05:41 +09:00
YeonGyu-Kim
2c3c0f60e7 fix(#804): agents/skills show <name> <extra> in text mode returned wrong error instead of unexpected_extra_args 2026-05-27 20:05:39 +09:00
YeonGyu-Kim
bad1b97f8e fix(#803): agents/skills/plugins list --flag in text mode silently returned empty success 2026-05-27 19:38:31 +09:00
YeonGyu-Kim
fcebf64468 fix(#802): four resume-mode and broad-cwd error envelopes now include hint field 2026-05-27 19:04:15 +09:00
YeonGyu-Kim
53953a8157 fix(#801): diff non-git-dir error envelope now includes error_kind, hint, and message fields 2026-05-27 18:34:58 +09:00
YeonGyu-Kim
1201dc60ef docs(roadmap): add deferred entries #798-#800 (plugins extra-arg, empty-prompt, classifier coverage) 2026-05-27 18:21:35 +09:00
Bellman
23d7761e50 docs(roadmap): add #786 installed binary provenance gap (#3126) 2026-05-27 18:21:02 +09:00
YeonGyu-Kim
6ee67d6c61 test: add unit test coverage for invalid_history_count and unknown_option classifier arms
Two classifier arms had no corresponding assert_eq! in
test_classify_error_kind_returns_correct_discriminants: invalid_history_count
(both prefix and contains paths) and unknown_option (#790). Now 49/39 = full
coverage of all classify_error_kind return values.
2026-05-27 18:05:33 +09:00
YeonGyu-Kim
efb1542a39 fix: empty-prompt error now returns non-null hint via newline-delimited usage string
claw '' and claw '   ' returned empty_prompt + hint:null because the
error message had no newline delimiter. Added usage hint. 61 CLI
contract tests pass.
2026-05-27 16:34:37 +09:00
YeonGyu-Kim
bff370003b fix: plugins extra-arg errors now return non-null hint via newline-delimited usage string
Parity with #791 (config extra-arg fix). The plugins arg parser emitted
'unexpected extra arguments after claw plugins show ...' with no newline
delimiter, so split_error_hint returned None. Added usage hint after newline.
60 CLI contract tests pass.
2026-05-27 15:04:03 +09:00
YeonGyu-Kim
9976585f87 fix(#796): agents/skills show <name> <extra> returned wrong not-found instead of unexpected_extra_args 2026-05-27 14:07:04 +09:00
YeonGyu-Kim
18b4cee5fd fix(#795): skill_not_found and unsupported_skills_action now return non-null hints via fallback table 2026-05-27 13:34:09 +09:00
YeonGyu-Kim
491f179a03 fix(#794): plugins install not-found path returns typed plugin_source_not_found instead of unknown+null 2026-05-27 13:08:14 +09:00
YeonGyu-Kim
57a57ef771 fix(#793): plugins list --flag silent success + uninstall not-found hint:null 2026-05-27 12:34:35 +09:00
YeonGyu-Kim
abfa2e4cf7 fix(#792): agents/skills list --flag silently returned empty success; now returns unknown_option error 2026-05-27 11:39:44 +09:00
YeonGyu-Kim
93a159dca5 fix(#791): config extra-arg errors now return non-null hint via \n-delimited usage string 2026-05-27 11:04:50 +09:00
YeonGyu-Kim
9968a27e92 fix(#790): system-prompt unknown-option errors now return typed unknown_option kind + non-null hint 2026-05-27 10:36:12 +09:00
YeonGyu-Kim
e4c3c1aa80 fix(#789): agents show and plugins show not-found now exit 1; parity with skills (#788) and mcp (#68) 2026-05-27 10:07:51 +09:00
YeonGyu-Kim
abdbf61acf fix(#788): skills show not-found emitted duplicate JSON error envelope; use exit(1) instead of Err propagation 2026-05-27 09:36:11 +09:00
YeonGyu-Kim
113145a42a fix(#787): --resume with directory path returns session_path_is_directory kind + hint; wire fallback_hint_for_error_kind into both resume error emission sites 2026-05-27 09:06:28 +09:00
YeonGyu-Kim
22b423b651 fix(#786): dump-manifests --manifests-dir missing-value errors now return typed missing_flag_value kind + non-null hint 2026-05-27 08:39:11 +09:00
YeonGyu-Kim
87f4334728 fix(#785): add unknown_subcommand classifier arm for unknown subcommand: prose prefix 2026-05-27 08:36:41 +09:00
YeonGyu-Kim
e628b4bb68 fix(#784): export --output missing-value and extra-positional errors now return typed error_kind + non-null hint 2026-05-27 08:07:32 +09:00
YeonGyu-Kim
81fe0ccbb7 fix(#783): init JSON envelope now includes hint and already_initialized fields for orchestrator parity 2026-05-27 08:04:15 +09:00
YeonGyu-Kim
32c9276fdb fix(#782): acp unsupported invocation now returns non-null hint with newline-delimited remediation text 2026-05-27 07:37:26 +09:00
YeonGyu-Kim
16c1117af6 fix(#781): sub-classify api_auth_error/api_rate_limit_error from api_http_error; add fallback_hint_for_error_kind for hint-less API errors 2026-05-27 07:34:57 +09:00
YeonGyu-Kim
d9844cfe8d fix(#780): classifier arm ordering bug — legacy_session_no_workspace_binding and no_managed_sessions shadowed by generic session_load_failed arm 2026-05-27 05:34:49 +09:00
YeonGyu-Kim
364e7909f4 fix(#779): resumed /skills invocation returns interactive_only error_kind + non-null hint 2026-05-27 05:09:07 +09:00
YeonGyu-Kim
fded4f6b11 fix(#778): doctor check JSON objects now include hint field with stable remediation text for warn/fail checks 2026-05-27 05:07:02 +09:00
YeonGyu-Kim
e02030364d fix(#777): resumed /plugins mutations return interactive_only error_kind + non-null hint instead of unknown+null 2026-05-27 04:44:06 +09:00
YeonGyu-Kim
2684737d9e fix(#776): resume command errors now return typed error_kind + non-null hint (invalid_history_count, session action errors) 2026-05-27 04:39:43 +09:00
YeonGyu-Kim
028998d040 test(#775): integration tests for #769-#771 interactive-only guards and #774 hint fields; fix stale classifier unit test string 2026-05-27 04:03:52 +09:00
YeonGyu-Kim
c760a49c47 fix(#774): agents/plugins/mcp unknown-subcommand errors now include non-null hint 2026-05-27 03:37:00 +09:00
YeonGyu-Kim
727a1ea4a3 fix(#773): config --output-format json now surfaces deprecation warnings in warnings[] array instead of only stderr text 2026-05-27 03:05:14 +09:00
YeonGyu-Kim
212f0b2ad4 fix(#772): slash command aliases now resolve to canonical forms in interactive_only guidance 2026-05-27 02:37:17 +09:00
YeonGyu-Kim
bf212b986d fix(#771): init rejects extra args; usage/stats/fork return interactive_only instead of credential check 2026-05-27 02:33:55 +09:00
YeonGyu-Kim
3a1d88386c fix(#770): cost/clear/memory/ultraplan/model with args now return interactive_only instead of falling to credential check 2026-05-27 02:10:41 +09:00
YeonGyu-Kim
9e1be05634 fix(#769): claw session <arg> now returns interactive_only instead of falling to credential check 2026-05-27 02:05:14 +09:00
YeonGyu-Kim
b778d4e3d4 fix(#768): --resume non-slash trailing arg now has error_kind:invalid_resume_argument + hint 2026-05-27 01:35:46 +09:00
YeonGyu-Kim
89735dbd33 fix(#766): claw diff extra args now classified as unexpected_extra_args with hint; track #767 session subcommand gap 2026-05-27 01:33:24 +09:00
YeonGyu-Kim
d29a8e216b fix(#765): login/logout removed_subcommand now has error_kind + non-null hint 2026-05-27 01:28:35 +09:00
YeonGyu-Kim
4ea255ca6a fix(#764): config_parse_error now populates hint field via Display newline delimiter 2026-05-27 01:23:00 +09:00
YeonGyu-Kim
c86dc73d8c fix(#763): config JSON parse errors now classify as config_parse_error 2026-05-27 01:16:04 +09:00
YeonGyu-Kim
88ce181031 test(#762): classify_error_kind now covers all 23 classifier arms (was 8 of 23) 2026-05-27 00:33:11 +09:00
YeonGyu-Kim
d83de563c1 fix(#761): mcp server_not_found and skill_not_found envelopes now include hint field 2026-05-27 00:03:53 +09:00
YeonGyu-Kim
7fa81b5dae fix(#760): agent_not_found and plugin_not_found envelopes now include hint field 2026-05-26 23:36:30 +09:00
YeonGyu-Kim
ef31328aab fix(#759): validate_model_syntax error strings now use newline separator so hint is non-null 2026-05-26 23:04:04 +09:00
YeonGyu-Kim
b8b3af6fc9 fix(#758): --cwd, --date, --session missing-value errors now use missing_flag_value prefix + hint 2026-05-26 22:34:18 +09:00
YeonGyu-Kim
02d77ae1f1 fix(#757): --permission-mode invalid and --allowedTools missing now emit typed error_kind and hint 2026-05-26 22:04:00 +09:00
YeonGyu-Kim
4df146188f fix+test(#756): missing/invalid flag-value errors now emit typed error_kind and non-null hint 2026-05-26 21:37:28 +09:00
YeonGyu-Kim
0e8a449ea9 fix+test(#755): -p consumes exactly one token; flags after prompt text now parse normally 2026-05-26 21:27:39 +09:00
YeonGyu-Kim
c70312bd04 fix(#754): missing_credentials hint now newline-delimited so JSON hint field is non-null 2026-05-26 21:23:03 +09:00
YeonGyu-Kim
e93271356f fix+test(#753): claw -p (no arg) parity with #750: error_kind:missing_prompt with non-null hint 2026-05-26 20:46:27 +09:00
YeonGyu-Kim
cfc26729cf fix(#752): cli_parse unrecognized-arg errors now emit non-null hint for all subcommands 2026-05-26 20:41:12 +09:00
YeonGyu-Kim
ddc71b5620 test(#751): regression guard for #750 prompt no-arg error_kind and hint contract 2026-05-26 20:05:34 +09:00
YeonGyu-Kim
ac925ed41c fix(#750): claw prompt (no arg) now emits error_kind:missing_prompt with non-null hint 2026-05-26 20:03:14 +09:00
YeonGyu-Kim
2dfb7af66e fix+test(#749): compact interactive-only hint now non-null; extend compact JSON test for hint contract 2026-05-26 19:38:09 +09:00
YeonGyu-Kim
3975f2b3ab fix(#748): mcp unknown subcommand now emits error_kind:unknown_mcp_action matching agents/plugins parity 2026-05-26 19:35:55 +09:00
YeonGyu-Kim
04eb661e57 test(#747): regression guard for #745 bare slash command hint contract (issue/pr/commit) 2026-05-26 19:06:59 +09:00
YeonGyu-Kim
18e7744e42 fix(#746): non-TTY interactive-only error populates hint field via newline split 2026-05-26 19:04:56 +09:00
YeonGyu-Kim
3c5459a33b fix(#745): bare slash command guidance adds newline before hint; claw issue/pr/commit etc now have non-null hint 2026-05-26 18:36:21 +09:00
YeonGyu-Kim
92e053a133 test(#744): regression guard for #741 config unsupported-section hint contract 2026-05-26 18:06:35 +09:00
YeonGyu-Kim
1d5db5f77d fix(#743): plugins help --output-format json now emits usage envelope matching agents/mcp/skills help shape; resolves #420 2026-05-26 18:04:04 +09:00
YeonGyu-Kim
2036f0bd4c test(#742): add git-fixture test for diff changed_file_count dedup; fixes unreachable branch in #740 coverage 2026-05-26 17:41:02 +09:00
YeonGyu-Kim
6e78c1fc8b fix(#741): config unsupported_config_section error now populates hint field; list/show/help verbs get usage hint 2026-05-26 17:38:02 +09:00
YeonGyu-Kim
5d072d21e9 test(#740): diff JSON contract test now asserts changed_file_count field behavior per #733 2026-05-26 16:45:02 +09:00
YeonGyu-Kim
d5f0d6ed3e fix(#739): skills unknown-subcommand JSON path no longer emits double error envelope; help action not propagated as Err 2026-05-26 16:38:17 +09:00
YeonGyu-Kim
4c3cb0f347 fix(#738): interactive-only slash command error adds newline before hint; hint field now non-null with remediation text 2026-05-26 16:06:38 +09:00
YeonGyu-Kim
c592313d9a test(#737): add boot_preflight details non-null-value regression guard to output_format_contract 2026-05-26 15:05:00 +09:00
YeonGyu-Kim
ad982d20c2 fix(#736): boot_preflight doctor details[] null-value entries: add double-space separator to Required binary, Last failed boot, MCP/Plugin eligible format strings 2026-05-26 14:33:18 +09:00
YeonGyu-Kim
b3242e8c04 fix(#735): classify_error_kind: /compact and other interactive-only slash commands now emit error_kind:interactive_only not unknown 2026-05-26 14:08:53 +09:00
YeonGyu-Kim
d4494a8aeb fix(#734): agents/plugins show not-found envelopes gain message field; parity with skills show 2026-05-26 13:34:36 +09:00
YeonGyu-Kim
cc86f54d65 fix(#701): doctor JSON details[] now {key,value} objects; prose preserved as details_prose[]; acceptance check passes 2026-05-26 13:10:05 +09:00
YeonGyu-Kim
db80c9b96e fix(#733): diff JSON adds changed_file_count; run git diff --name-only for staged+unstaged and deduplicate into BTreeSet 2026-05-26 13:05:44 +09:00
YeonGyu-Kim
4c16a42f39 fix(#732): status JSON allowed_tools.entries:null→[] when unrestricted; callers can use .entries|length without null guard 2026-05-26 12:36:13 +09:00
YeonGyu-Kim
29dcd478a0 fix(#731): sandbox JSON status:error→warn when filesystem sandbox active but namespace unsupported (macOS degraded state) 2026-05-26 12:05:11 +09:00
YeonGyu-Kim
425d94ee43 fix(#730): add path field to plugins list/show JSON; completes path-discoverability trio (agents #728, skills #729, plugins #730) 2026-05-26 11:38:48 +09:00
YeonGyu-Kim
8f44ad308d fix(#729): add path field to skills list/show JSON; SkillSummary parity with AgentSummary (#728) 2026-05-26 11:32:53 +09:00
YeonGyu-Kim
fa29909f05 fix(#728): add path field to agents list/show JSON; AgentSummary now stores on-disk .toml path from discovery loop 2026-05-26 11:09:46 +09:00
YeonGyu-Kim
9757fef8a7 fix(#727): add has_upstream bool to branch_freshness JSON to disambiguate fresh:null-no-upstream from fresh:null-unknown 2026-05-26 10:34:28 +09:00
YeonGyu-Kim
a0c6c8ba53 fix(#726): classify legacy_session_no_workspace_binding error_kind in export path 2026-05-26 10:04:32 +09:00
Bellman
49d5b3fcdc Prevent poisoned ROADMAP ids before allocation (#3116)
Constraint: roadmap-next-id.sh must preserve single-id stdout on success while failing closed if duplicate validation cannot run.
Rejected: Relying only on CI/pre-push duplicate checks | the helper is used immediately before appending and must not certify an already-poisoned file.
Confidence: high
Scope-risk: narrow
Directive: Keep roadmap-next-id.sh stdout machine-clean; route validation failures and checker availability errors to stderr, and keep focused helper behavior coverage in the docs/ROADMAP CI path.
Tested: scripts/roadmap-next-id.sh ROADMAP.md printed 725 before appending #725 and 726 after; temp ROADMAP with duplicate 999 exited nonzero and listed duplicate id; scripts/roadmap-check-ids.sh ROADMAP.md; bash -n scripts/roadmap-next-id.sh scripts/roadmap-check-ids.sh; python -m unittest discover -s tests -p test_roadmap_helpers.py; python -m pytest tests/test_roadmap_helpers.py -q; SKIP_CLAW_PRE_PUSH_BUILD=1 bash .github/hooks/pre-push
Not-tested: full cargo workspace build, unchanged docs/script-only path
2026-05-26 09:10:02 +09:00
Bellman
25ee5f3d30 Prevent helper-era ROADMAP id collisions before review (#3115)
Add a lightweight ROADMAP duplicate-id guard and wire it into the low-risk docs/pre-push paths so optimistic append collisions introduced after the next-id helper are caught before merge.

Constraint: Current ROADMAP contains legacy numbered lists and pre-helper duplicate low ids, so the default guard checks helper-era ids >=723 while preserving --min-id 1 for a future strict audit.
Rejected: Fail CI on every numeric duplicate in the whole historical ROADMAP | current main would fail before this PR because old prose/list numbering is already duplicated.
Confidence: high
Scope-risk: narrow
Directive: Keep roadmap-next-id.sh paired with roadmap-check-ids.sh when changing ROADMAP append workflows.
Tested: bash -n scripts/roadmap-check-ids.sh scripts/roadmap-next-id.sh .github/hooks/pre-push; scripts/roadmap-check-ids.sh; temp ROADMAP copy with duplicate 723 failed nonzero and listed id 723; SKIP_CLAW_PRE_PUSH_BUILD=1 .github/hooks/pre-push; git diff --check; python3 .github/scripts/check_doc_source_of_truth.py; python3 .github/scripts/check_release_readiness.py
Not-tested: full cargo workspace build/test because this is docs/scripts-only and the local pre-push cargo build was smoke-tested with its documented skip path.
2026-05-26 08:49:23 +09:00
YeonGyu-Kim
922c239863 fix(#723): add scripts/roadmap-next-id.sh to prevent concurrent ROADMAP id collision; document optimistic-append pattern 2026-05-26 08:09:54 +09:00
YeonGyu-Kim
d8a6109085 docs(#721/#722): re-add ROADMAP entry for config section expansion after rebase conflict 2026-05-26 08:06:11 +09:00
Bellman
6e44da10fe Record stale local dogfood probe trap (#3114)
Constraint: Docs-only ROADMAP pinpoint requested; existing #324/#695 cover adjacent stale-binary and stale-worktree cases but not stale local cargo-run current-main misclassification.
Rejected: Implementing provenance warnings now | scope was to keep implementation out unless trivially obvious and safe.
Confidence: high
Scope-risk: narrow
Directive: Preserve #719 as the cargo-run/local-checkout sibling of #324/#695 when implementing provenance freshness.
Tested: python3 .github/scripts/check_doc_source_of_truth.py; python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json; git diff --check
Not-tested: Runtime provenance warning implementation not changed.
2026-05-26 07:00:36 +08:00
YeonGyu-Kim
02d1f6a04d fix(#720): claw help <topic> now routes to subsystem help instead of cli_parse error; add Agents/Skills/Plugins/Mcp/Config/Diff help topics 2026-05-26 07:36:50 +09:00
YeonGyu-Kim
fe2b13a46a fix(#719): plugins list <filter> now applies substring filter on plugin id, matching agents/skills parity 2026-05-26 07:03:22 +09:00
Bellman
92539cad68 Prevent pre-push contract drift (#3113)
Add a lightweight regression for the documented local pre-push build gate so the skip hatch and lockfile-grade cargo build command stay aligned with operator docs.

Constraint: Issue #696 scope is limited to pre-push hook contract drift after ROADMAP #694.\nRejected: Reworking the hook harness or roadmap board | unnecessary for the focused drift guard.\nConfidence: high\nScope-risk: narrow\nDirective: Keep the hook docs, skip hatch, and cargo --locked command in sync when changing local push gates.\nTested: bash -n .github/hooks/pre-push; python3 tests/test_pre_push_hook_contract.py -v; git diff --check\nNot-tested: Full cargo workspace build; Rust code was not touched.
2026-05-26 06:00:45 +08:00
YeonGyu-Kim
556a598f2d fix(#718): implement plugins show/info/describe command with not-found error, parity with agents/skills show 2026-05-26 06:33:52 +09:00
YeonGyu-Kim
8d80f2ffe7 test(#717): add contract tests for agents show not-found and agents list filter in output_format_contract 2026-05-26 06:04:23 +09:00
Bellman
8280f66aa1 Warn before unwritable git metadata blocks worker commits (#3112)
Use git rev-parse --git-dir so startup preflight follows worktree .git indirections to the real metadata directory, then check directory permission metadata without creating probe files. Add a regression that verifies both the warning kind and structured event path for a read-only external gitdir.

Constraint: ROADMAP #695 requires early startup/worktree diagnostics without destructive writes or broad sandbox redesign.

Rejected: write-probe detection | it mutates git metadata during a diagnostic path.

Confidence: high

Scope-risk: narrow

Directive: Keep startup preflight warnings non-destructive and structured by warning kind/path.

Tested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p runtime startup_preflight -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p runtime worker_boot -- --nocapture; cargo check --manifest-path rust/Cargo.toml --workspace

Not-tested: full cargo test --manifest-path rust/Cargo.toml --workspace
2026-05-26 05:01:39 +08:00
YeonGyu-Kim
a0b375c157 fix(#717): implement agents show/info/describe and list filter commands, mirror skills handler parity 2026-05-26 05:36:27 +09:00
YeonGyu-Kim
6a007344ae Merge pull request #3111 from Yeachan-Heo/fix/issue-694-prepush-build-gate
Fix ROADMAP #694: add local pre-push build gate
2026-05-26 05:29:31 +09:00
Yeachan-Heo
920d5c6c3a Catch stale Rust compile drift before push
Add a repository-local pre-push gate that runs the locked Rust workspace build from the repo root, plus concise install and verification docs for maintainers.

Constraint: ROADMAP #694 requires a local installable hook artifact only; branch protection remains external.
Rejected: CI or branch-protection changes | outside the requested local pre-push gate scope.
Confidence: high
Scope-risk: narrow
Directive: Keep .github/hooks/pre-push and contributor docs synchronized if the Rust workspace build command changes.
Tested: bash -n .github/hooks/pre-push; SKIP_CLAW_PRE_PUSH_BUILD=1 .github/hooks/pre-push; git diff --check; python3 .github/scripts/check_doc_source_of_truth.py; cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo build --manifest-path rust/Cargo.toml --workspace --locked; .github/hooks/pre-push
Not-tested: shellcheck and markdownlint unavailable in environment.
2026-05-25 20:06:23 +00:00
YeonGyu-Kim
98f8926998 fix(#716): align 5 resume-path error JSON envelopes from legacy type:error shape to standard kind/action/status/error_kind/exit_code contract 2026-05-26 05:04:50 +09:00
YeonGyu-Kim
76c8d4801e Merge pull request #3110 from Yeachan-Heo/fix/issue-693-analog-phase-error
Fix claw-analog bootstrap phase drift errors
2026-05-26 04:39:36 +09:00
YeonGyu-Kim
4b8731ba11 fix(#715): add action+status fields to resume-path json responses: compact/clear/cost/stats/history/session_exists/session_delete/memory/restored 2026-05-26 04:35:46 +09:00
Yeachan-Heo
789ea9aac8 Reject drifted claw-analog bootstrap phases
The analog RAG/bootstrap formatter already rejected missing and literal unknown phases, but still accepted arbitrary phase strings. Keep the parser aligned with the runtime/service contract by allowlisting the known emitted phases and returning the existing typed error shape with received value and field context when drift appears.

Constraint: Scope is limited to claw-analog bootstrap/RAG phase parsing for ROADMAP #693.
Rejected: Preserve arbitrary non-empty phases | would continue hiding producer/parser phase drift.
Confidence: high
Scope-risk: narrow
Directive: Update KNOWN_RAG_BOOTSTRAP_PHASES when claw-rag-service deliberately adds a new response phase.
Tested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p claw-analog rag_response -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p claw-analog -- --nocapture; cargo check --manifest-path rust/Cargo.toml -p claw-analog; cargo check --manifest-path rust/Cargo.toml --workspace; git diff --check
Not-tested: Full workspace cargo test; remote CI
2026-05-25 19:33:37 +00:00
YeonGyu-Kim
590b5b614c Merge pull request #3109 from Yeachan-Heo/fix/issue-714-json-action-contract
Fix JSON action contract gaps
2026-05-26 04:09:55 +09:00
Yeachan-Heo
45dc4f6ff0 Stabilize JSON action contract for local CLI surfaces
Guard the local/no-credential JSON command sweep so future additions fail fast when action is absent or empty.

Constraint: ROADMAP #710-#713 fixed most JSON surfaces; #714 dogfood sweep found remaining help and sandbox gaps.

Rejected: Schema redesign for help output | outside the action-field contract scope.

Confidence: high

Scope-risk: narrow

Directive: Keep --output-format json envelopes carrying a stable non-empty action on every local CLI surface.

Tested: cargo fmt --manifest-path rust/Cargo.toml --all; cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract -- --nocapture; cargo check --manifest-path rust/Cargo.toml -p rusty-claude-cli; git diff --check

Not-tested: full workspace cargo test
2026-05-25 19:06:16 +00:00
YeonGyu-Kim
7037d84d52 fix(#714): add action:help to top-level help json, render_export_help_json, render_help_topic_json, and resume repl help json 2026-05-26 04:03:34 +09:00
YeonGyu-Kim
7d6b2044d5 fix(#713): add missing action fields to acp and config json responses; acp->status, config bare->list, config section->show 2026-05-26 03:32:02 +09:00
YeonGyu-Kim
fdde5e45cf fix(#712): add missing action fields to doctor/status/bootstrap-plan/dump-manifests json responses 2026-05-26 03:02:57 +09:00
YeonGyu-Kim
bae0099c7c fix(#711): add missing action fields to version/system-prompt/export/init json responses; add contract test assertions 2026-05-26 02:33:26 +09:00
YeonGyu-Kim
42c17bc4bf Merge pull request #3108 from Yeachan-Heo/fix/issue-335-session-created-at-ms
Expose created_at_ms in session list JSON
2026-05-26 02:15:40 +09:00
YeonGyu-Kim
f8a901c2a5 fix(#710): diff --output-format json adds missing action:diff and working_directory fields to both ok and error branches 2026-05-26 02:07:46 +09:00
Yeachan-Heo
a30624d6d4 Expose creation time in session list metadata
Preserve session-list consumers from depending on encoded session IDs by carrying persisted creation timestamps through managed summaries and JSON detail output, with an ID timestamp fallback only for legacy metadata that lacks created_at_ms.\n\nConstraint: ROADMAP #335 requires created_at_ms in session_details with the same millisecond unit as updated_at_ms.\nRejected: Making callers parse session IDs | undocumented ID structure is brittle and was the issue being fixed.\nRejected: Session storage redesign | scope is limited to detail metadata propagation and legacy compatibility.\nConfidence: high\nScope-risk: narrow\nDirective: Keep ID timestamp parsing fallback-only; persisted session_meta.created_at_ms remains the source of truth when present.\nTested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo check --manifest-path rust/Cargo.toml --workspace; cargo build --manifest-path rust/Cargo.toml --workspace; cargo test --manifest-path rust/Cargo.toml --workspace; cargo clippy --manifest-path rust/Cargo.toml -p runtime -p rusty-claude-cli --all-targets\nNot-tested: live interactive /session list against a real provider-backed REPL
2026-05-25 17:06:08 +00:00
YeonGyu-Kim
8f8eb41e0f fix(#709): remove duplicate status:ok keys from render_agents_report_json and render_skill_install_report_json; silent overwrite risk in serde_json json! macro 2026-05-26 01:32:37 +09:00
YeonGyu-Kim
47c0226a61 fix(#708): skills show/info/describe responses now emit action:show instead of action:list; remove duplicate status key from render_skills_report_json 2026-05-26 01:05:07 +09:00
YeonGyu-Kim
26a50d918b Merge pull request #3107 from Yeachan-Heo/fix/issue-698-config-warning-dedup
test: cover config warning dedup for inventory commands
2026-05-26 00:41:39 +09:00
YeonGyu-Kim
401f6b152c fix(#707): init test temp_dir combines AtomicU64 counter+nanos to prevent same-process parallel test collisions 2026-05-26 00:36:07 +09:00
Yeachan-Heo
1b5a9b02c2 test: cover config warning dedup for inventory commands
Add a CLI contract proving plugins list and mcp list emit a deprecated enabledPlugins warning only once per process after config warning dedup landed. Covers ROADMAP #698.
2026-05-25 15:30:48 +00:00
YeonGyu-Kim
dedad14ae4 fix(#706): skills show <name> returns error+exit1 when skill not found; classify_error_kind covers skill_not_found from prose message 2026-05-26 00:04:39 +09:00
YeonGyu-Kim
f84799c8ef fix: auto_compact runs before every iteration break, including terminal no-tool turns; closes #3106 2026-05-25 23:59:04 +09:00
YeonGyu-Kim
732007da8e fix(#705): add estimated_cost_usd_num (float) to usage JSON alongside string field; doc entry filed 2026-05-25 23:33:14 +09:00
YeonGyu-Kim
8f809d9a9e fix(#704): DiagnosticCheck.json_value now emits stable snake_case id field; doctor checks addressable without scraping name prose 2026-05-25 23:04:06 +09:00
YeonGyu-Kim
f6cab2711f docs(roadmap): add #704 doctor checks label:null makes check identity unaddressable by machine parsers 2026-05-25 23:01:22 +09:00
YeonGyu-Kim
1a6f54b970 fix(#703): plugins list JSON now has summary:{total,enabled,disabled,load_failures}; drop reload_runtime/target from list response in both top-level and resume paths 2026-05-25 22:34:20 +09:00
YeonGyu-Kim
1555785294 Merge pull request #3104 from Yeachan-Heo/fix/issue-702-allowed-tools-ci
Stabilize allowedTools rejection contract in CI
2026-05-25 22:03:40 +09:00
YeonGyu-Kim
2f9429cbf0 fix: slash-command guard errors now emit error_kind:interactive_only instead of unknown; covers memory, permissions, review, and any bare_slash_command_guidance path 2026-05-25 22:02:30 +09:00
Yeachan-Heo
4daefc7bd5 Stabilize allowedTools rejection contract in CI
Serialize the allowedTools rejection tests with the existing environment/current-directory test guards so full parallel cargo test runs cannot observe a transient project config or cwd from another test while building the tool registry.\n\nConstraint: Post-merge Rust build workflow run 26399647443 failed only in the full cargo test job while the focused test passed locally.\nRejected: Changing allowedTools parser output | the product contract remains correct and focused reproduction preserves the expected unsupported-tool error.\nConfidence: high\nScope-risk: narrow\nDirective: Keep this as test isolation only; do not bundle inventory provenance or ROADMAP work into this follow-up.\nTested: cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli tests::rejects_unknown_allowed_tools -- --exact --nocapture; cargo test --verbose -p rusty-claude-cli --bin claw; cargo test --verbose; cargo fmt --check; cargo check --workspace --locked; cargo build --workspace --locked\nNot-tested: GitHub Actions rerun before opening PR
2026-05-25 12:55:53 +00:00
YeonGyu-Kim
a7a30627a9 docs(roadmap): add #703 plugins list JSON missing structured summary; leaks reload_runtime/target 2026-05-25 21:31:01 +09:00
YeonGyu-Kim
5bca9ef039 Merge pull request #3103 from Yeachan-Heo/fix/issue-702-inventory-provenance
Fix #702 inventory provenance schema
2026-05-25 21:10:18 +09:00
YeonGyu-Kim
b8eca2a68e fix(#349): plugins unknown action emits status:error + error_kind:unknown_plugins_action + exit 1 instead of status:ok with prose 2026-05-25 21:08:14 +09:00
Yeachan-Heo
566992c331 Unify inventory provenance for generic parsers
Expose a stable source object on agent and skill inventory entries with the same id/label/detail_label keys while preserving skill origin for compatibility.\n\nConstraint: ROADMAP #702 scope only; keep existing skills origin field for compatibility.\nRejected: Rename agent source to origin | would break existing agents consumers and still require per-resource branching during migration.\nConfidence: high\nScope-risk: narrow\nDirective: Future inventory resources should expose provenance through source.id, source.label, and source.detail_label.\nTested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p commands renders_skills_reports_as_json -- --nocapture; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test output_format_contract -- --nocapture; cargo build --manifest-path rust/Cargo.toml --workspace --locked; git diff --check\nNot-tested: full cargo test --manifest-path rust/Cargo.toml --workspace
2026-05-25 12:05:50 +00:00
YeonGyu-Kim
36b36267ec fix(#458): add status:ok to config JSON envelope; unknown section now emits status:error + error_kind:unsupported_config_section 2026-05-25 20:33:36 +09:00
YeonGyu-Kim
21a986034e docs(roadmap): add #702 agents source vs skills origin field name inconsistency 2026-05-25 20:02:44 +09:00
YeonGyu-Kim
ee24ff2d83 Merge pull request #3102 from Yeachan-Heo/fix/issue-696-compact-nontty
Fix compact non-TTY hang
2026-05-25 19:41:27 +09:00
Yeachan-Heo
9e6f753640 Fail closed for compact without an interactive session
Route bare compact invocations to a typed interactive-only error before prompt/provider startup so non-TTY JSON probes terminate predictably. Keep the existing resume-session /compact path as the supported automation surface.\n\nConstraint: ROADMAP pinpoint #696 requires no spinner/hang for closed stdin and --output-format json.\nRejected: Broad help-schema rewrites | #699/#700/#701 are outside this PR scope.\nConfidence: high\nScope-risk: narrow\nDirective: Do not route bare slash-command names through the prompt fallback when they require a session.\nTested: cargo fmt --manifest-path rust/Cargo.toml --all -- --check; cargo test --manifest-path rust/Cargo.toml -p rusty-claude-cli --test compact_output compact_subcommand_ -- --nocapture; cargo build --manifest-path rust/Cargo.toml --workspace; cargo build --manifest-path rust/Cargo.toml --workspace --locked; timeout probes for compact JSON/text with stdin closed.\nNot-tested: Full workspace test suite.
2026-05-25 10:37:12 +00:00
YeonGyu-Kim
de2e32c5d4 fix: skills install nonexistent path emits skill_not_found error kind with descriptive message; classify_error_kind adds skill_not_found branch 2026-05-25 19:34:25 +09:00
YeonGyu-Kim
9d1998b3fd test(#458/#700/#701/#702): add status:ok assertions for help/bootstrap-plan/export-help contracts; add diff/export JSON shape tests 2026-05-25 19:07:03 +09:00
YeonGyu-Kim
181b12f0a9 fix: mcp show <nonexistent> now returns status:error + error_kind:server_not_found + exit 1; extend ok:false gate to also check status:error 2026-05-25 18:34:43 +09:00
YeonGyu-Kim
47521cf178 fix(#701): add detail_entries structured key/value to doctor check JSON; booleans/ints emitted as JSON scalars 2026-05-25 18:02:03 +09:00
YeonGyu-Kim
9c5f190fcc docs(roadmap): add #701 doctor details prose-string gap; details[] should be structured key/value objects 2026-05-25 17:32:04 +09:00
Yeachan-Heo
9f14a7aa9e docs(roadmap): add #700 help JSON prompt fallthrough 2026-05-25 08:30:57 +00:00
YeonGyu-Kim
f9e98a2634 fix(#700): add status:ok to all help JSON envelopes; rename session_list kind to sessions with action:list 2026-05-25 17:05:28 +09:00
YeonGyu-Kim
c08395ca92 docs(roadmap): add #700 help JSON missing status + session_list kind inconsistency 2026-05-25 17:03:31 +09:00
Yeachan-Heo
10957f59c5 docs(roadmap): add #699 bootstrap-plan/dump-manifests local dispatch gap 2026-05-25 08:00:28 +00:00
YeonGyu-Kim
eb7c14c4ae fix(#458): add status:ok to bootstrap-plan JSON envelope; all 12 JSON surfaces now have uniform status field 2026-05-25 16:34:33 +09:00
YeonGyu-Kim
11a6e081a2 fix(#458): add status field to export and diff JSON envelopes 2026-05-25 16:07:16 +09:00
Bellman
16604a111b fix(#458): add status assertions to skills/agents JSON envelope tests
Adds test coverage asserting status:ok in skills list, agents list, skills install, skills help, and agents help JSON envelopes. Status fields themselves were already landed in 0581894b and cc1462a7; this PR adds the regression test assertions that were missing. Duplicate status keys in json! macros are benign (serde_json takes last value) but harmless given all tests pass.
2026-05-25 15:35:40 +09:00
YeonGyu-Kim
cc1462a7f8 fix(#458): add status:ok to skills install JSON envelope (missed in previous sweep) 2026-05-25 15:30:22 +09:00
YeonGyu-Kim
f2a90228fb fix: doctor boot preflight detail shows Some(false) for trust_gate_allowed; use Display instead of Debug 2026-05-25 15:21:15 +09:00
YeonGyu-Kim
0581894b7e fix(#458): add status:ok to agents and skills list JSON envelopes; all 9 subcommands now pass uniform status check 2026-05-25 15:02:25 +09:00
YeonGyu-Kim
5b79413e87 fix(#458): add status field to version/init/system-prompt JSON envelopes; all 9 subcommands now have uniform status field 2026-05-25 14:36:12 +09:00
YeonGyu-Kim
85e736c73f fix: add status field to sandbox JSON envelope (ok/warn/error derived from enabled+active+supported) 2026-05-25 14:34:00 +09:00
YeonGyu-Kim
b64df99134 fix(#698): dedup config deprecation warnings per process; add tempfile dev-dep to runtime crate (fixes pre-existing test compile error) 2026-05-25 14:11:37 +09:00
YeonGyu-Kim
c345ce6d02 fix: mcp/agents/skills help envelopes set ok:false + status:error on unknown subcommand; exit 1 propagates correctly 2026-05-25 13:50:51 +09:00
YeonGyu-Kim
91a0681ae9 fix(#697): agents unknown subcommand exits 1 with typed error; plugins remove aliases uninstall and errors on not-found 2026-05-25 13:39:23 +09:00
Yeachan-Heo
c613e8e676 feat: sweep 2026-05-25 04:36:30 +00:00
YeonGyu-Kim
1003510a75 docs(roadmap): add #697 — plugins remove silent ok on missing plugin; agents unknown subcommand exit 0 2026-05-25 13:32:15 +09:00
YeonGyu-Kim
63a5a87471 fix(#696): exit with typed error when stdin is not a TTY and no prompt piped; fix anthropic/ prefix detection in metadata_for_model 2026-05-25 13:16:12 +09:00
YeonGyu-Kim
da7924d079 docs(roadmap): add #696 — compact hangs in non-interactive mode with no TTY guard 2026-05-25 13:08:51 +09:00
YeonGyu-Kim
bb2a9238d9 Merge pull request #2839 from ultraworkers/docs/roadmap-324-resume-stats-zero
docs(roadmap): add #330 — resume mode stats/cost always zero
2026-05-25 13:01:13 +09:00
YeonGyu-Kim
8806e62a9f docs(roadmap): add #330 — resume mode stats/cost always zero 2026-05-25 13:00:54 +09:00
YeonGyu-Kim
78a0ff615a Merge pull request #3014 from wangguan1995/fix_qwen
Add Qwen model token limits for DashScope compatibility
2026-05-25 12:58:59 +09:00
YeonGyu-Kim
706ac0f8e1 Merge pull request #3097 from ultraworkers/fix-683-unsupported-skills-action
fix(#683): claw skills remove/add/uninstall/delete emits typed error, exit 1
2026-05-25 12:55:01 +09:00
YeonGyu-Kim
bd8a27b100 Merge pull request #3096 from ultraworkers/fix-160-session-store-lifecycle
fix(#160): add regression test for SessionStore lifecycle
2026-05-25 12:54:42 +09:00
YeonGyu-Kim
6f5465aeaf fix(test): update client_integration version string 0.1.0 -> 0.1.3 2026-05-25 12:49:36 +09:00
Yeachan-Heo
fdbc789694 fix(api): skip preflight for unknown model limits 2026-05-25 12:49:36 +09:00
Yeachan-Heo
779cf1c234 test(api): fill thinking in stream chunk fixtures 2026-05-25 12:49:36 +09:00
YeonGyu-Kim
1f330c6737 chore: cargo fmt --all on fix-160 branch 2026-05-25 12:49:36 +09:00
YeonGyu-Kim
3489ec51d5 fix(#160): add regression test for SessionStore lifecycle (list_sessions, delete_session, session_exists)
Adds session_store_lifecycle_regression_160 test that verifies the full
SessionStore CRUD lifecycle. Also fixes pre-existing non-exhaustive match
errors in trident.rs for the ContentBlock::Thinking variant.
2026-05-25 12:49:36 +09:00
wangguan1995
fa8eecaf8f fix 2026-05-10 13:23:40 +00:00
wangguan1995
2033c90921 fix log 2026-05-10 13:21:58 +00:00
wangguan1995
8cada12c48 Add Qwen model token limits for DashScope compatibility 2026-05-10 13:09:07 +00:00
30 changed files with 5664 additions and 247 deletions

31
.github/hooks/pre-push vendored Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Claw Code local pre-push safety gate.
#
# Install with:
# git config core.hooksPath .github/hooks
#
# This intentionally mirrors the CI build gate so stale field/enum references are
# caught before pushing to main or PR branches.
set -euo pipefail
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"
cd "$repo_root"
if [[ -x scripts/roadmap-check-ids.sh ]]; then
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
scripts/roadmap-check-ids.sh
fi
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
echo "pre-push: SKIP_CLAW_PRE_PUSH_BUILD=1 set; skipping cargo workspace build" >&2
exit 0
fi
if [[ ! -f rust/Cargo.toml ]]; then
echo "pre-push: rust/Cargo.toml not found; skipping cargo workspace build" >&2
exit 0
fi
build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)
echo "pre-push: ${build_cmd[*]}" >&2
"${build_cmd[@]}"

View File

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

View File

@@ -3,11 +3,11 @@
"duplicate_roadmap_heading_lines": [], "duplicate_roadmap_heading_lines": [],
"roadmap_actions_mapped": 542, "roadmap_actions_mapped": 542,
"roadmap_actions_total": 542, "roadmap_actions_total": 542,
"roadmap_headings_mapped": 124, "roadmap_headings_mapped": 127,
"roadmap_headings_total": 124, "roadmap_headings_total": 127,
"unmapped_roadmap_heading_lines": [] "unmapped_roadmap_heading_lines": []
}, },
"generated_at": "2026-05-14T08:13:45+00:00", "generated_at": "2026-05-25T04:30:33+00:00",
"generation_policy": { "generation_policy": {
"release_buckets": [ "release_buckets": [
"2.x_intake", "2.x_intake",
@@ -14823,6 +14823,69 @@
"status": "context", "status": "context",
"title": "Parity source metadata: openai/codex", "title": "Parity source metadata: openai/codex",
"verification_required": "none_context_only" "verification_required": "none_context_only"
},
{
"category": "boot",
"deferral_rationale": "",
"dependencies": [
"stream_0_governance"
],
"id": "CC2-RM-H0125-pinpoint-693-claw-analog-bootstrap-plan",
"lifecycle_status": "done_verify",
"owner_lane": "stream_1_worker_boot_session_control",
"release_bucket": "alpha_blocker",
"source_anchor": "ROADMAP.md:L7528",
"source_context": "Clawable Coding Harness Roadmap > Pinpoint follow-up intake",
"source_level": 2,
"source_line": 7528,
"source_ordinal": null,
"source_path": "ROADMAP.md",
"source_type": "roadmap_heading",
"status": "done_verify",
"title": "Pinpoint #693. `claw-analog` bootstrap-plan phase parser silently falls back to `\"unknown\"` \u2014 `lib.rs:1114` uses `.unwrap_or(\"unknown\")` for phase field; unrecognized phases emit opaque kind instead of typed error",
"verification_required": "targeted_regression_or_acceptance_test_required"
},
{
"category": "branch_recovery",
"deferral_rationale": "",
"dependencies": [
"stream_0_governance"
],
"id": "CC2-RM-H0126-pinpoint-694-no-pre-push-cargo-build-gat",
"lifecycle_status": "done_verify",
"owner_lane": "stream_3_branch_test_recovery",
"release_bucket": "alpha_blocker",
"source_anchor": "ROADMAP.md:L7538",
"source_context": "Clawable Coding Harness Roadmap > Pinpoint follow-up intake",
"source_level": 2,
"source_line": 7538,
"source_ordinal": null,
"source_path": "ROADMAP.md",
"source_type": "roadmap_heading",
"status": "done_verify",
"title": "Pinpoint #694. No pre-push `cargo build` gate \u2014 stale field refs (`retry_after`, `Team` variant, `config_load_error_kind`) broke main build undetected until CI",
"verification_required": "git_fixture_or_recovery_recipe_test"
},
{
"category": "boot",
"deferral_rationale": "",
"dependencies": [
"stream_0_governance"
],
"id": "CC2-RM-H0127-pinpoint-695-agent-starts-in-stale-wrong",
"lifecycle_status": "done_verify",
"owner_lane": "stream_1_worker_boot_session_control",
"release_bucket": "alpha_blocker",
"source_anchor": "ROADMAP.md:L7548",
"source_context": "Clawable Coding Harness Roadmap > Pinpoint follow-up intake",
"source_level": 2,
"source_line": 7548,
"source_ordinal": null,
"source_path": "ROADMAP.md",
"source_type": "roadmap_heading",
"status": "done_verify",
"title": "Pinpoint #695. Agent starts in stale/wrong worktree and burns a full turn before noticing \u2014 no pre-flight check for \"file exists on current branch\" or \"this .git is writable from sandbox\"",
"verification_required": "worker_boot_state_machine_or_cli_json_contract_test"
} }
], ],
"schema_version": "cc2.board.v1", "schema_version": "cc2.board.v1",
@@ -14839,7 +14902,7 @@
"root": "/Users/bellman/Documents/Workspace/claw-code/.omx/research" "root": "/Users/bellman/Documents/Workspace/claw-code/.omx/research"
}, },
"roadmap": { "roadmap": {
"heading_count": 124, "heading_count": 127,
"ordered_action_count": 542, "ordered_action_count": 542,
"path": "ROADMAP.md", "path": "ROADMAP.md",
"sha256_prefix": "2aba3315e52f3079" "sha256_prefix": "2aba3315e52f3079"
@@ -14850,15 +14913,15 @@
"adoption_overlay": 357, "adoption_overlay": 357,
"parity_overlay": 20, "parity_overlay": 20,
"stream_0_governance": 221, "stream_0_governance": 221,
"stream_1_worker_boot_session_control": 15, "stream_1_worker_boot_session_control": 17,
"stream_2_event_reporting_contracts": 73, "stream_2_event_reporting_contracts": 73,
"stream_3_branch_test_recovery": 16, "stream_3_branch_test_recovery": 17,
"stream_4_claws_first_execution": 5, "stream_4_claws_first_execution": 5,
"stream_5_plugin_mcp_lifecycle": 22 "stream_5_plugin_mcp_lifecycle": 22
}, },
"by_release_bucket": { "by_release_bucket": {
"2.x_intake": 30, "2.x_intake": 30,
"alpha_blocker": 240, "alpha_blocker": 243,
"beta_adoption": 417, "beta_adoption": 417,
"context": 15, "context": 15,
"ga_ecosystem": 22, "ga_ecosystem": 22,
@@ -14870,13 +14933,13 @@
"latest_open_issue": 30, "latest_open_issue": 30,
"parity_repo_context": 2, "parity_repo_context": 2,
"roadmap_action": 542, "roadmap_action": 542,
"roadmap_heading": 124 "roadmap_heading": 127
}, },
"by_status": { "by_status": {
"active": 73, "active": 73,
"context": 15, "context": 15,
"deferred_with_rationale": 9, "deferred_with_rationale": 9,
"done_verify": 313, "done_verify": 316,
"open": 285, "open": 285,
"rejected_not_claw": 2, "rejected_not_claw": 2,
"stale_done": 31, "stale_done": 31,

View File

@@ -1,6 +1,6 @@
# Claw Code 2.0 Canonical Board # Claw Code 2.0 Canonical Board
Generated from board schema: `2026-05-14T08:13:45+00:00` Generated from board schema: `2026-05-25T04:30:33+00:00`
Schema version: `cc2.board.v1` Schema version: `cc2.board.v1`
Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified by this rendering task. Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified by this rendering task.
@@ -8,7 +8,7 @@ Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified
| Source | Frozen evidence | | Source | Frozen evidence |
| --- | --- | | --- | --- |
| Roadmap | `ROADMAP.md` sha256 prefix `2aba3315e52f3079`; 124 headings; 542 ordered actions | | Roadmap | `ROADMAP.md` sha256 prefix `2aba3315e52f3079`; 127 headings; 542 ordered actions |
| Approved plan | `.omx/plans/claw-code-2-0-adaptive-plan.md` sha256 prefix `e7ef6faf23bfc16b` | | Approved plan | `.omx/plans/claw-code-2-0-adaptive-plan.md` sha256 prefix `e7ef6faf23bfc16b` |
| Research bundle | root `/Users/bellman/Documents/Workspace/claw-code/.omx/research`; latest open issues 30; issue corpus 1000; codex/opencode clone metadata included | | Research bundle | root `/Users/bellman/Documents/Workspace/claw-code/.omx/research`; latest open issues 30; issue corpus 1000; codex/opencode clone metadata included |
@@ -16,11 +16,11 @@ Ultragoal mutation policy: `.omx/ultragoal` is leader-owned and was not modified
| Coverage gate | Mapped | Total | Status | | Coverage gate | Mapped | Total | Status |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| ROADMAP headings | 124 | 124 | PASS | | ROADMAP headings | 127 | 127 | PASS |
| ROADMAP ordered actions | 542 | 542 | PASS | | ROADMAP ordered actions | 542 | 542 | PASS |
| Duplicate heading lines | 0 | 0 | PASS | | Duplicate heading lines | 0 | 0 | PASS |
Total canonical board items: **729** Total canonical board items: **732**
## Lifecycle Enum Reference ## Lifecycle Enum Reference
@@ -29,7 +29,7 @@ Total canonical board items: **729**
| `active` | 73 | Current Claw Code 2.0 implementation surface that should remain visible on the board. | | `active` | 73 | Current Claw Code 2.0 implementation surface that should remain visible on the board. |
| `context` | 15 | Context-only heading or evidence anchor; not an implementation work item. | | `context` | 15 | Context-only heading or evidence anchor; not an implementation work item. |
| `deferred_with_rationale` | 9 | Intentionally deferred; rationale must be present in the board item. | | `deferred_with_rationale` | 9 | Intentionally deferred; rationale must be present in the board item. |
| `done_verify` | 313 | Marked as done upstream but retained for verification against current CC2 behavior. | | `done_verify` | 316 | Marked as done upstream but retained for verification against current CC2 behavior. |
| `open` | 285 | Actionable unresolved work that needs implementation or acceptance evidence. | | `open` | 285 | Actionable unresolved work that needs implementation or acceptance evidence. |
| `rejected_not_claw` | 2 | Excluded because it is not Claw Code product work. | | `rejected_not_claw` | 2 | Excluded because it is not Claw Code product work. |
| `stale_done` | 31 | Historically completed or merged work that may be stale and needs freshness checks before relying on it. | | `stale_done` | 31 | Historically completed or merged work that may be stale and needs freshness checks before relying on it. |
@@ -40,7 +40,7 @@ Total canonical board items: **729**
| Bucket | Count | Meaning | | Bucket | Count | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `2.x_intake` | 30 | Post-2.0 intake or follow-up candidate retained for sequencing. | | `2.x_intake` | 30 | Post-2.0 intake or follow-up candidate retained for sequencing. |
| `alpha_blocker` | 240 | Must be resolved before alpha-quality autonomous coding lanes are dependable. | | `alpha_blocker` | 243 | Must be resolved before alpha-quality autonomous coding lanes are dependable. |
| `beta_adoption` | 417 | Important for broader dogfood/adoption once alpha blockers are controlled. | | `beta_adoption` | 417 | Important for broader dogfood/adoption once alpha blockers are controlled. |
| `context` | 15 | Non-actionable roadmap context. | | `context` | 15 | Non-actionable roadmap context. |
| `ga_ecosystem` | 22 | Required for mature plugin/MCP/provider ecosystem behavior. | | `ga_ecosystem` | 22 | Required for mature plugin/MCP/provider ecosystem behavior. |
@@ -54,9 +54,9 @@ Total canonical board items: **729**
| Adoption overlay — user-visible parity and release polish | 357 | 329 | `deferred_with_rationale` 3, `done_verify` 237, `open` 92, `rejected_not_claw` 2, `stale_done` 23 | | Adoption overlay — user-visible parity and release polish | 357 | 329 | `deferred_with_rationale` 3, `done_verify` 237, `open` 92, `rejected_not_claw` 2, `stale_done` 23 |
| Parity overlay — opencode/codex comparison context | 20 | 16 | `context` 2, `deferred_with_rationale` 1, `done_verify` 5, `open` 11, `stale_done` 1 | | Parity overlay — opencode/codex comparison context | 20 | 16 | `context` 2, `deferred_with_rationale` 1, `done_verify` 5, `open` 11, `stale_done` 1 |
| Stream 0 — Governance, intake, and cross-cutting roadmap triage | 221 | 198 | `active` 6, `context` 13, `deferred_with_rationale` 4, `done_verify` 45, `open` 147, `stale_done` 5, `superseded` 1 | | Stream 0 — Governance, intake, and cross-cutting roadmap triage | 221 | 198 | `active` 6, `context` 13, `deferred_with_rationale` 4, `done_verify` 45, `open` 147, `stale_done` 5, `superseded` 1 |
| Stream 1 — Worker boot and session control | 15 | 14 | `active` 8, `deferred_with_rationale` 1, `open` 6 | | Stream 1 — Worker boot and session control | 17 | 16 | `active` 8, `deferred_with_rationale` 1, `done_verify` 2, `open` 6 |
| Stream 2 — Event/reporting contracts | 73 | 73 | `active` 45, `done_verify` 20, `open` 8 | | Stream 2 — Event/reporting contracts | 73 | 73 | `active` 45, `done_verify` 20, `open` 8 |
| Stream 3 — Branch/test recovery | 16 | 14 | `active` 6, `done_verify` 1, `open` 7, `stale_done` 2 | | Stream 3 — Branch/test recovery | 17 | 15 | `active` 6, `done_verify` 2, `open` 7, `stale_done` 2 |
| Stream 4 — Claws-first task execution | 5 | 5 | `active` 4, `done_verify` 1 | | Stream 4 — Claws-first task execution | 5 | 5 | `active` 4, `done_verify` 1 |
| Stream 5 — Plugin/MCP lifecycle | 22 | 22 | `active` 4, `done_verify` 4, `open` 14 | | Stream 5 — Plugin/MCP lifecycle | 22 | 22 | `active` 4, `done_verify` 4, `open` 14 |
@@ -68,7 +68,7 @@ Total canonical board items: **729**
| `latest_open_issue` | 30 | | `latest_open_issue` | 30 |
| `parity_repo_context` | 2 | | `parity_repo_context` | 2 |
| `roadmap_action` | 542 | | `roadmap_action` | 542 |
| `roadmap_heading` | 124 | | `roadmap_heading` | 127 |
## Board Items by Stream ## Board Items by Stream
@@ -704,6 +704,8 @@ Total canonical board items: **729**
| `CC2-RM-A0363-surface-inconsistency-cluster-of-3-after` | **Surface inconsistency (cluster of 3)**: after #143 Phase 1, the behavior matrix is: | `ROADMAP.md:L5515` / `roadmap_action` | `alpha_blocker` | `open` | `plugin_mcp_lifecycle_contract_test` | `stream_1_worker_boot_session_control` | — | | `CC2-RM-A0363-surface-inconsistency-cluster-of-3-after` | **Surface inconsistency (cluster of 3)**: after #143 Phase 1, the behavior matrix is: | `ROADMAP.md:L5515` / `roadmap_action` | `alpha_blocker` | `open` | `plugin_mcp_lifecycle_contract_test` | `stream_1_worker_boot_session_control` | — |
| `CC2-RM-A0391-remove-the-error-prefix-from-format-unkn` | Remove the "error:" prefix from format_unknown_verb_option (already added by top-level handler) | `ROADMAP.md:L5916` / `roadmap_action` | `alpha_blocker` | `open` | `worker_boot_state_machine_or_cli_json_contract_test` | none | — | | `CC2-RM-A0391-remove-the-error-prefix-from-format-unkn` | Remove the "error:" prefix from format_unknown_verb_option (already added by top-level handler) | `ROADMAP.md:L5916` / `roadmap_action` | `alpha_blocker` | `open` | `worker_boot_state_machine_or_cli_json_contract_test` | none | — |
| `CC2-RM-A0512-system-prompt-output-format-json-exposes` | **`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. | `ROADMAP.md:L6333` / `roadmap_action` | `beta_adoption` | `open` | `worker_boot_state_machine_or_cli_json_contract_test` | none | — | | `CC2-RM-A0512-system-prompt-output-format-json-exposes` | **`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. | `ROADMAP.md:L6333` / `roadmap_action` | `beta_adoption` | `open` | `worker_boot_state_machine_or_cli_json_contract_test` | none | — |
| `CC2-RM-H0125-pinpoint-693-claw-analog-bootstrap-plan` | Pinpoint #693. `claw-analog` bootstrap-plan phase parser silently falls back to `"unknown"` — `lib.rs:1114` uses `.unwrap_or("unknown")` for phase field; unrecognized phases emit opaque kind instead of typed error | `ROADMAP.md:L7528` / `roadmap_heading` | `alpha_blocker` | `done_verify` | `targeted_regression_or_acceptance_test_required` | `stream_0_governance` | — |
| `CC2-RM-H0127-pinpoint-695-agent-starts-in-stale-wrong` | Pinpoint #695. Agent starts in stale/wrong worktree and burns a full turn before noticing — no pre-flight check for "file exists on current branch" or "this .git is writable from sandbox" | `ROADMAP.md:L7548` / `roadmap_heading` | `alpha_blocker` | `done_verify` | `worker_boot_state_machine_or_cli_json_contract_test` | `stream_0_governance` | — |
### Stream 2 — Event/reporting contracts ### Stream 2 — Event/reporting contracts
@@ -803,6 +805,7 @@ Total canonical board items: **729**
| `CC2-RM-A0410-remediation-registry-a-function-remediat` | **Remediation registry:** A function `remediation_for(kind: &str, operation: &str) -> Remediation` that maps `(error_kind, operation_context)` pairs to stable remediation structs: | `ROADMAP.md:L6041` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — | | `CC2-RM-A0410-remediation-registry-a-function-remediat` | **Remediation registry:** A function `remediation_for(kind: &str, operation: &str) -> Remediation` that maps `(error_kind, operation_context)` pairs to stable remediation structs: | `ROADMAP.md:L6041` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — |
| `CC2-RM-A0411-stable-hint-outputs-per-class-each-error` | **Stable hint outputs per class:** Each `error_kind` maps to exactly one remediation shape. No more prose splitting. | `ROADMAP.md:L6049` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — | | `CC2-RM-A0411-stable-hint-outputs-per-class-each-error` | **Stable hint outputs per class:** Each `error_kind` maps to exactly one remediation shape. No more prose splitting. | `ROADMAP.md:L6049` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — |
| `CC2-RM-A0412-golden-fixture-tests-test-each-kind-oper` | **Golden fixture tests:** Test each `(kind, operation)` pair against expected remediation output as golden fixtures instead of the current `split_error_hint()` string hacks. | `ROADMAP.md:L6050` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — | | `CC2-RM-A0412-golden-fixture-tests-test-each-kind-oper` | **Golden fixture tests:** Test each `(kind, operation)` pair against expected remediation output as golden fixtures instead of the current `split_error_hint()` string hacks. | `ROADMAP.md:L6050` / `roadmap_action` | `alpha_blocker` | `open` | `targeted_regression_or_acceptance_test_required` | `stream_2_event_reporting_contracts` | — |
| `CC2-RM-H0126-pinpoint-694-no-pre-push-cargo-build-gat` | Pinpoint #694. No pre-push `cargo build` gate — stale field refs (`retry_after`, `Team` variant, `config_load_error_kind`) broke main build undetected until CI | `ROADMAP.md:L7538` / `roadmap_heading` | `alpha_blocker` | `done_verify` | `git_fixture_or_recovery_recipe_test` | `stream_0_governance` | — |
### Stream 4 — Claws-first task execution ### Stream 4 — Claws-first task execution

View File

@@ -1,7 +1,7 @@
{ {
"version": 1, "version": 1,
"createdAt": "2026-05-14T07:53:46.061Z", "createdAt": "2026-05-14T07:53:46.061Z",
"updatedAt": "2026-05-15T04:38:54.887Z", "updatedAt": "2026-05-25T04:18:52.711Z",
"briefPath": ".omx/ultragoal/brief.md", "briefPath": ".omx/ultragoal/brief.md",
"goalsPath": ".omx/ultragoal/goals.json", "goalsPath": ".omx/ultragoal/goals.json",
"ledgerPath": ".omx/ultragoal/ledger.jsonl", "ledgerPath": ".omx/ultragoal/ledger.jsonl",
@@ -148,7 +148,19 @@
"updatedAt": "2026-05-15T04:38:54.887Z", "updatedAt": "2026-05-15T04:38:54.887Z",
"evidence": "G012-final-gate complete: team g012-final-gate-ultra-e61d2271 8/8 tasks complete; final gate log /tmp/g012-final-quality-gate-pass4.log; commit 04c2abb pushed; docs/pr-triage-g012-final-gate.json docs/pr-issue-resolution-gate.md docs/g012-final-release-readiness-report.md; .omx/ultragoal/goals.json and ledger.jsonl updated; aiSlopCleaner and codeReview evidence included in quality gate JSON.", "evidence": "G012-final-gate complete: team g012-final-gate-ultra-e61d2271 8/8 tasks complete; final gate log /tmp/g012-final-quality-gate-pass4.log; commit 04c2abb pushed; docs/pr-triage-g012-final-gate.json docs/pr-issue-resolution-gate.md docs/g012-final-release-readiness-report.md; .omx/ultragoal/goals.json and ledger.jsonl updated; aiSlopCleaner and codeReview evidence included in quality gate JSON.",
"completedAt": "2026-05-15T04:38:54.887Z" "completedAt": "2026-05-15T04:38:54.887Z"
},
{
"id": "G013-implement-roadmap-pinpoints-693-695",
"title": "Implement ROADMAP pinpoints #693-#695",
"objective": "Map and implement the newly appended ROADMAP.md pinpoints #693, #694, and #695 after reset to origin/main: typed claw-analog bootstrap phase errors, a local pre-push cargo build gate, and startup/worktree preflight diagnostics; update CC2 board/coverage and verify with targeted and workspace checks.",
"status": "in_progress",
"attempt": 1,
"createdAt": "2026-05-25T04:18:43.420Z",
"updatedAt": "2026-05-25T04:18:52.711Z",
"evidence": "Current-head verification after reset: python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json failed with unmapped ROADMAP headings [7528,7538,7548], corresponding to Pinpoints #693-#695.",
"startedAt": "2026-05-25T04:18:52.711Z"
} }
], ],
"codexObjective": "Complete the approved Claw Code 2.0 ultragoal delivery: implement all classified ROADMAP.md backlog work through execution-sized stream goals G001-G012, using .omx/ultragoal/ledger.jsonl as the durable audit trail and .omx/plans/claw-code-2-0-adaptive-plan.md as the source plan." "codexObjective": "Complete the approved Claw Code 2.0 ultragoal delivery: implement all classified ROADMAP.md backlog work through execution-sized stream goals G001-G012, using .omx/ultragoal/ledger.jsonl as the durable audit trail and .omx/plans/claw-code-2-0-adaptive-plan.md as the source plan.",
"activeGoalId": "G013-implement-roadmap-pinpoints-693-695"
} }

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
# G013 ROADMAP pinpoints #693-#695 verification map
This map records the current-head follow-up that was discovered after resetting
`main` to `origin/main`: ROADMAP.md contained three new Pinpoint headings not
covered by the Claw Code 2.0 board.
## Pinpoint #693 — typed phase error instead of silent `unknown`
- Code: `rust/crates/claw-analog/src/lib.rs`
- Behavior: `format_rag_query_json_for_model` now rejects missing, empty, or
literal `"unknown"` phase values with a structured error envelope containing
`kind:"unknown_bootstrap_phase"`, `field:"phase"`, and `received_value`.
- Regression tests: `rag_response_missing_phase_returns_typed_error` and
`rag_response_unknown_phase_returns_typed_error`.
## Pinpoint #694 — local pre-push build gate
- Hook: `.github/hooks/pre-push`
- Install command: `git config core.hooksPath .github/hooks`
- Gate: `cargo build --manifest-path rust/Cargo.toml --workspace --locked`
- Escape hatch: `SKIP_CLAW_PRE_PUSH_BUILD=1` prints an explicit skip message.
- Regression test: `tests/test_pre_push_hook_contract.py` locks the skip
hatch and `--locked` build command contract.
- Purpose: mirror the CI build job locally so stale field/variant references are
caught before push.
## Pinpoint #695 — startup/worktree preflight diagnostics
- Code: `rust/crates/runtime/src/worker_boot.rs`
- Behavior: `startup_preflight_warnings` and
`WorkerRegistry::observe_startup_preflight` emit structured warnings before
the first model turn when a task mentions a path not tracked on the current
branch (`file_absent_on_branch`) or git metadata is not writable
(`git_metadata_not_writable`).
- Regression tests:
- `startup_preflight_warns_when_task_file_is_absent_on_branch`
- `startup_preflight_records_structured_warning_event`
## Verification commands
```bash
python3 scripts/generate_cc2_board.py
python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json
python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json
bash -n .github/hooks/pre-push
python3 tests/test_pre_push_hook_contract.py -v
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
cargo test --manifest-path rust/Cargo.toml -p claw-analog rag_response_ -- --nocapture
cargo test --manifest-path rust/Cargo.toml -p runtime startup_preflight -- --nocapture
cargo build --manifest-path rust/Cargo.toml --workspace --locked
```

1
rust/Cargo.lock generated Normal file → Executable file
View File

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

View File

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

View File

@@ -234,7 +234,7 @@ pub fn resolve_model_alias(model: &str) -> String {
#[must_use] #[must_use]
pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> { pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
let canonical = resolve_model_alias(model); let canonical = resolve_model_alias(model);
if canonical.starts_with("claude") { if canonical.starts_with("claude") || canonical.starts_with("anthropic/") {
return Some(ProviderMetadata { return Some(ProviderMetadata {
provider: ProviderKind::Anthropic, provider: ProviderKind::Anthropic,
auth_env: "ANTHROPIC_API_KEY", auth_env: "ANTHROPIC_API_KEY",
@@ -640,6 +640,14 @@ pub fn model_token_limit(model: &str) -> Option<ModelTokenLimit> {
max_output_tokens: 16_384, max_output_tokens: 16_384,
context_window_tokens: 256_000, context_window_tokens: 256_000,
}), }),
"qwen-max" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
"qwen-plus" => Some(ModelTokenLimit {
max_output_tokens: 8_192,
context_window_tokens: 131_072,
}),
_ => None, _ => None,
} }
} }
@@ -1641,10 +1649,15 @@ NO_EQUALS_LINE
rendered.starts_with("missing Anthropic credentials;"), rendered.starts_with("missing Anthropic credentials;"),
"canonical base message should still lead the rendered error: {rendered}" "canonical base message should still lead the rendered error: {rendered}"
); );
// #754: hint delimiter changed from " — hint: " to "\n" so split_error_hint works
assert!( assert!(
rendered.contains(" — hint: I see OPENAI_API_KEY is set"), rendered.contains("I see OPENAI_API_KEY is set"),
"rendered error should carry the env-driven hint: {rendered}" "rendered error should carry the env-driven hint: {rendered}"
); );
assert!(
rendered.contains('\n'),
"rendered error must use newline separator (#754): {rendered}"
);
} }
#[test] #[test]

View File

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

View File

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

View File

@@ -16,5 +16,8 @@ telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
walkdir = "2" walkdir = "2"
[dev-dependencies]
tempfile = "3"
[lints] [lints]
workspace = true workspace = true

View File

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

View File

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

View File

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

View File

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

View File

@@ -438,13 +438,24 @@ fn normalize_path(path: &Path) -> PathBuf {
/// Extract repository name from a path for event context. /// Extract repository name from a path for event context.
fn extract_repo_name(cwd: &str) -> Option<String> { fn extract_repo_name(cwd: &str) -> Option<String> {
let path = Path::new(cwd); let path = Path::new(cwd);
// Try to find a .git directory to identify repo root // Ask git from the cwd itself. Walking ancestors manually can accidentally
let mut current = Some(path); // classify synthetic/nonexistent paths as an unrelated parent repo (for
while let Some(p) = current { // example `/tmp/.git`), which makes trust events point at the wrong repo.
if p.join(".git").is_dir() { if path.is_dir() {
return p.file_name().map(|n| n.to_string_lossy().to_string()); if let Ok(output) = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(path)
.output()
{
if output.status.success() {
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !root.is_empty() {
return Path::new(&root)
.file_name()
.map(|n| n.to_string_lossy().to_string());
}
}
} }
current = p.parent();
} }
// Fallback: use the last component of the path // Fallback: use the last component of the path
path.file_name().map(|n| n.to_string_lossy().to_string()) path.file_name().map(|n| n.to_string_lossy().to_string())

View File

@@ -13,6 +13,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -73,6 +74,7 @@ pub struct WorkerFailure {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkerEventKind { pub enum WorkerEventKind {
Spawning, Spawning,
StartupPreflightWarning,
TrustRequired, TrustRequired,
ToolPermissionRequired, ToolPermissionRequired,
TrustResolved, TrustResolved,
@@ -102,6 +104,21 @@ pub enum WorkerPromptTarget {
Unknown, Unknown,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkerStartupPreflightWarningKind {
FileAbsentOnBranch,
GitMetadataNotWritable,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkerStartupPreflightWarning {
pub kind: WorkerStartupPreflightWarningKind,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
/// Classification of startup failure when no evidence is available. /// Classification of startup failure when no evidence is available.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -212,6 +229,12 @@ pub enum WorkerEventPayload {
evidence: StartupEvidenceBundle, evidence: StartupEvidenceBundle,
classification: StartupFailureClassification, classification: StartupFailureClassification,
}, },
StartupPreflightWarning {
kind: WorkerStartupPreflightWarningKind,
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
},
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -329,6 +352,34 @@ impl WorkerRegistry {
inner.workers.get(worker_id).cloned() inner.workers.get(worker_id).cloned()
} }
pub fn observe_startup_preflight(
&self,
worker_id: &str,
task_prompt: &str,
) -> Result<Worker, String> {
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
let worker = inner
.workers
.get_mut(worker_id)
.ok_or_else(|| format!("worker not found: {worker_id}"))?;
for warning in startup_preflight_warnings(Path::new(&worker.cwd), task_prompt) {
push_event(
worker,
WorkerEventKind::StartupPreflightWarning,
worker.status,
Some(warning.message.clone()),
Some(WorkerEventPayload::StartupPreflightWarning {
kind: warning.kind,
message: warning.message,
path: warning.path,
}),
);
}
Ok(worker.clone())
}
pub fn observe(&self, worker_id: &str, screen_text: &str) -> Result<Worker, String> { pub fn observe(&self, worker_id: &str, screen_text: &str) -> Result<Worker, String> {
let mut inner = self.inner.lock().expect("worker registry lock poisoned"); let mut inner = self.inner.lock().expect("worker registry lock poisoned");
let worker = inner let worker = inner
@@ -1064,6 +1115,128 @@ fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
(!server.is_empty()).then(|| server.to_string()) (!server.is_empty()).then(|| server.to_string())
} }
pub fn startup_preflight_warnings(
cwd: &Path,
task_prompt: &str,
) -> Vec<WorkerStartupPreflightWarning> {
let mut warnings = Vec::new();
if let Some(git_path) = git_metadata_path(cwd) {
if !path_is_writable(&git_path) {
warnings.push(WorkerStartupPreflightWarning {
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
message: format!(
"git metadata is not writable; commits or pushes may fail: {}",
git_path.display()
),
path: Some(git_path.display().to_string()),
});
}
}
for path in mentioned_repo_paths(task_prompt) {
if !git_tracks_path(cwd, &path) {
warnings.push(WorkerStartupPreflightWarning {
kind: WorkerStartupPreflightWarningKind::FileAbsentOnBranch,
message: format!(
"task mentions {path}, but git does not track it on the current branch"
),
path: Some(path),
});
}
}
warnings
}
fn mentioned_repo_paths(task_prompt: &str) -> Vec<String> {
let mut out = Vec::new();
for raw in task_prompt.split_whitespace() {
let token = raw.trim_matches(|ch: char| {
matches!(
ch,
'`' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | ':'
)
});
if !token.contains('/') || token.contains("://") || token.starts_with('/') {
continue;
}
let token = token.trim_start_matches("./");
if token.contains("..") {
continue;
}
if token
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '_' | '-' | '.'))
&& token
.rsplit('/')
.next()
.is_some_and(|name| name.contains('.'))
&& !out.iter().any(|seen| seen == token)
{
out.push(token.to_string());
}
}
out
}
fn git_tracks_path(cwd: &Path, path: &str) -> bool {
Command::new("git")
.arg("ls-files")
.arg("--error-unmatch")
.arg("--")
.arg(path)
.current_dir(cwd)
.output()
.is_ok_and(|output| output.status.success())
}
fn git_metadata_path(cwd: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
if text.is_empty() {
return None;
}
let path = PathBuf::from(text);
Some(if path.is_absolute() {
path
} else {
cwd.join(path)
})
}
fn path_is_writable(path: &Path) -> bool {
let probe_dir = if path.is_dir() {
path
} else {
path.parent().unwrap_or(path)
};
std::fs::metadata(probe_dir)
.ok()
.filter(std::fs::Metadata::is_dir)
.is_some_and(|metadata| metadata_allows_directory_writes(&metadata))
}
#[cfg(unix)]
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
use std::os::unix::fs::PermissionsExt;
let mode = metadata.permissions().mode();
mode & 0o222 != 0 && mode & 0o111 != 0
}
#[cfg(not(unix))]
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
!metadata.permissions().readonly()
}
fn detect_trust_prompt(lowered: &str) -> bool { fn detect_trust_prompt(lowered: &str) -> bool {
[ [
"do you trust the files in this folder", "do you trust the files in this folder",
@@ -1285,6 +1458,8 @@ fn cwd_matches_observed_target(expected_cwd: &str, observed_cwd: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::fs;
use std::process::Command;
#[test] #[test]
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() { fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
@@ -1431,6 +1606,116 @@ mod tests {
assert!(!readiness.ready); assert!(!readiness.ready);
} }
#[test]
fn startup_preflight_warns_when_task_file_is_absent_on_branch() {
let tmp = tempfile::tempdir().expect("tempdir");
Command::new("git")
.arg("init")
.current_dir(tmp.path())
.output()
.expect("git init should run");
fs::create_dir_all(tmp.path().join("src")).expect("src dir");
fs::write(tmp.path().join("src/lib.rs"), "pub fn present() {}\n").expect("write file");
Command::new("git")
.args(["add", "src/lib.rs"])
.current_dir(tmp.path())
.output()
.expect("git add should run");
let warnings = startup_preflight_warnings(
tmp.path(),
"Fix src/lib.rs and rust/crates/runtime/src/trident.rs before testing.",
);
assert!(warnings.iter().any(|warning| {
warning.kind == WorkerStartupPreflightWarningKind::FileAbsentOnBranch
&& warning.path.as_deref() == Some("rust/crates/runtime/src/trident.rs")
}));
assert!(!warnings.iter().any(|warning| {
warning.kind == WorkerStartupPreflightWarningKind::FileAbsentOnBranch
&& warning.path.as_deref() == Some("src/lib.rs")
}));
}
#[cfg(unix)]
#[test]
fn startup_preflight_warns_when_git_metadata_is_not_writable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let worktree = tmp.path().join("worktree");
let git_dir = tmp.path().join("external-gitdir");
fs::create_dir_all(&worktree).expect("worktree dir");
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", git_dir.display()),
)
.expect(".git file");
let original_permissions = fs::metadata(&git_dir)
.expect("gitdir metadata")
.permissions();
let mut read_only_permissions = original_permissions.clone();
read_only_permissions.set_mode(0o555);
fs::set_permissions(&git_dir, read_only_permissions).expect("make gitdir read-only");
let warnings = startup_preflight_warnings(&worktree, "Audit repository.");
let registry = WorkerRegistry::new();
let worker = registry.create(&worktree.display().to_string(), &[], true);
let observed = registry
.observe_startup_preflight(&worker.worker_id, "Audit repository.")
.expect("preflight should run");
fs::set_permissions(&git_dir, original_permissions).expect("restore gitdir permissions");
assert!(warnings.iter().any(|warning| {
warning.kind == WorkerStartupPreflightWarningKind::GitMetadataNotWritable
&& warning.path.as_deref() == Some(git_dir.to_string_lossy().as_ref())
}));
assert!(observed.events.iter().any(|event| {
matches!(
&event.payload,
Some(WorkerEventPayload::StartupPreflightWarning {
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
path: Some(path),
..
}) if path == git_dir.to_string_lossy().as_ref()
)
}));
}
#[test]
fn startup_preflight_records_structured_warning_event() {
let tmp = tempfile::tempdir().expect("tempdir");
Command::new("git")
.arg("init")
.current_dir(tmp.path())
.output()
.expect("git init should run");
let registry = WorkerRegistry::new();
let worker = registry.create(&tmp.path().display().to_string(), &[], true);
let observed = registry
.observe_startup_preflight(&worker.worker_id, "Open missing/file.rs")
.expect("preflight should run");
let event = observed
.events
.iter()
.find(|event| event.kind == WorkerEventKind::StartupPreflightWarning)
.expect("preflight warning event");
assert!(matches!(
event.payload,
Some(WorkerEventPayload::StartupPreflightWarning {
kind: WorkerStartupPreflightWarningKind::FileAbsentOnBranch,
..
})
));
}
#[test] #[test]
fn startup_timeout_classifies_tool_permission_prompt() { fn startup_timeout_classifies_tool_permission_prompt() {
let registry = WorkerRegistry::new(); let registry = WorkerRegistry::new();

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

72
scripts/roadmap-check-ids.sh Executable file
View File

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

57
scripts/roadmap-next-id.sh Executable file
View File

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

View File

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

View File

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