Compare commits

...

28 Commits

Author SHA1 Message Date
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
17 changed files with 1241 additions and 100 deletions

View File

@@ -11,10 +11,21 @@ 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
echo "pre-push: cargo build --manifest-path rust/Cargo.toml --workspace" >&2
cargo build --manifest-path rust/Cargo.toml --workspace
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
- PHILOSOPHY.md
- ROADMAP.md
- scripts/roadmap-*.sh
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
pull_request:
@@ -41,6 +43,8 @@ on:
- PARITY.md
- PHILOSOPHY.md
- ROADMAP.md
- scripts/roadmap-*.sh
- tests/test_roadmap_helpers.py
- docs/**
- rust/**
workflow_dispatch:
@@ -72,6 +76,10 @@ jobs:
run: python .github/scripts/check_doc_source_of_truth.py
- name: Check release policy docs and local links
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:
name: cargo fmt

View File

@@ -33,6 +33,41 @@ cargo build --workspace
.\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
Run the smallest relevant tests for your change, then the broader checks when

View File

@@ -7583,3 +7583,37 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
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]

View File

@@ -17,7 +17,10 @@ covered by the Claw Code 2.0 board.
- Hook: `.github/hooks/pre-push`
- Install command: `git config core.hooksPath .github/hooks`
- Gate: `cargo build --manifest-path rust/Cargo.toml --workspace`
- 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.
@@ -40,8 +43,9 @@ 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
cargo build --manifest-path rust/Cargo.toml --workspace --locked
```

View File

@@ -1109,25 +1109,33 @@ 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> {
let v: Value = serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?;
let phase = v.get("phase").and_then(|x| x.as_str()).ok_or_else(|| {
json!({
"kind": "unknown_bootstrap_phase",
"field": "phase",
"received_value": v.get("phase").cloned().unwrap_or(Value::Null),
"message": "RAG response is missing a string phase; refusing to silently render phase as unknown"
})
.to_string()
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 phase.trim().is_empty() || phase == "unknown" {
return Err(json!({
"kind": "unknown_bootstrap_phase",
"field": "phase",
"received_value": phase,
"message": "RAG response phase must be a concrete phase name"
})
.to_string());
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
.get("hits")
@@ -2586,6 +2594,16 @@ mod tests {
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]

View File

@@ -2202,7 +2202,16 @@ pub fn handle_plugins_slash_command(
match action {
None | Some("list") => {
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();
Ok(PluginsCommandResult {
message: render_plugins_report_with_failures(&plugins, failures),
@@ -2301,8 +2310,28 @@ pub fn handle_plugins_slash_command(
reload_runtime: true,
})
}
Some("show" | "info" | "describe") => {
// Show a named plugin by filtering the installed registry.
// 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,
})
}
Some(other) => Err(PluginError::CommandFailed(format!(
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, install, enable, disable, uninstall, or update."
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, show, install, enable, disable, uninstall, or update."
))),
}
}
@@ -2323,10 +2352,50 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
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 = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
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 Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("agent not found: {name}"),
));
}
Ok(render_agents_report(&matched))
}
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
Some(args) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown agents subcommand: {args}. Supported: list, help"),
format!("unknown agents subcommand: {args}. Supported: list, show, help"),
)),
}
}
@@ -2347,10 +2416,53 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report_json(cwd, &agents))
}
Some(args) if args.starts_with("list ") => {
let filter = args["list ".len()..].trim().to_lowercase();
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 = args
.split_once(' ')
.map(|(_, name)| name)
.unwrap_or_default()
.trim()
.to_lowercase();
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,
}));
}
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) => Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("unknown agents subcommand: {args}. Supported: list, help"),
format!("unknown agents subcommand: {args}. Supported: list, show, help"),
)),
}
}
@@ -3636,6 +3748,14 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
}
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
.iter()
.filter(|agent| agent.shadowed_by.is_none())
@@ -3643,8 +3763,7 @@ fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
json!({
"kind": "agents",
"status": "ok",
"action": "list",
"status": "ok",
"action": action,
"working_directory": cwd.display().to_string(),
"count": agents.len(),
"summary": {
@@ -3763,7 +3882,6 @@ fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
"kind": "skills",
"status": "ok",
"action": "install",
"status": "ok",
"result": "installed",
"invocation_name": &skill.invocation_name,
"invoke_as": format!("${}", skill.invocation_name),
@@ -5362,13 +5480,23 @@ mod tests {
assert_eq!(help["status"], "ok");
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
// Unknown agents subcommands now return Err so CLI layer can exit 1.
let unexpected_err = handle_agents_slash_command_json(Some("show planner"), &workspace);
// `show <name>` is now valid. Known agent returns ok with matching entry.
let show_planner = handle_agents_slash_command_json(Some("show planner"), &workspace)
.expect("show planner should return Ok");
assert_eq!(show_planner["status"], "ok");
let show_agents = show_planner["agents"].as_array().expect("agents array");
assert_eq!(show_agents.len(), 1, "show by exact name returns one entry");
assert_eq!(show_agents[0]["name"], "planner");
// Missing agent returns Ok(json error) with error_kind:agent_not_found.
let show_missing =
handle_agents_slash_command_json(Some("show nonexistent-xyz"), &workspace)
.expect("show missing agent should return Ok");
assert_eq!(show_missing["status"], "error");
assert_eq!(show_missing["error_kind"], "agent_not_found");
assert_eq!(show_missing["requested"], "nonexistent-xyz");
// Truly unknown subcommands still Err.
let unexpected_err = handle_agents_slash_command_json(Some("frobnicate"), &workspace);
assert!(unexpected_err.is_err());
assert!(unexpected_err
.unwrap_err()
.to_string()
.contains("show planner"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
@@ -5520,14 +5648,23 @@ mod tests {
assert!(agents_help
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
// Unknown agents subcommands now return Err (typed error) instead of Ok+help text
// so that the CLI layer can exit 1. The error message names the unexpected input.
let agents_unexpected_err = super::handle_agents_slash_command(Some("show planner"), &cwd);
assert!(agents_unexpected_err.is_err());
assert!(agents_unexpected_err
.unwrap_err()
.to_string()
.contains("show planner"));
// `show <name>` is now valid. For an agent that doesn't exist it returns Err(NotFound).
let agents_show_missing = super::handle_agents_slash_command(Some("show planner"), &cwd);
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 =
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");

View File

@@ -413,6 +413,7 @@ impl Session {
.get("created_at_ms")
.map(|value| required_u64_from_value(value, "created_at_ms"))
.transpose()?
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
.unwrap_or(now);
let updated_at_ms = object
.get("updated_at_ms")
@@ -500,7 +501,10 @@ impl Session {
"session_meta" => {
version = required_u32(object, "version")?;
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")?);
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
workspace_root = object
@@ -543,11 +547,15 @@ impl Session {
}
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 {
version,
session_id: session_id.unwrap_or_else(generate_session_id),
created_at_ms: created_at_ms.unwrap_or(now),
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)),
session_id,
created_at_ms,
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms),
messages,
compaction,
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 {
let millis = current_time_millis();
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
@@ -1380,8 +1397,9 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
#[cfg(test)]
mod tests {
use super::{
cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
ConversationMessage, MessageRole, Session, SessionFork,
cleanup_rotated_logs, current_time_millis, parse_created_at_ms_from_session_id,
rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session,
SessionFork,
};
use crate::json::JsonValue;
use crate::usage::TokenUsage;
@@ -1502,6 +1520,44 @@ mod tests {
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]
fn appends_messages_to_persisted_jsonl_session() {
let path = temp_session_path("append");

View File

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

View File

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

View File

@@ -302,6 +302,8 @@ fn classify_error_kind(message: &str) -> &'static str {
"interactive_only"
} else if message.starts_with("unknown agents subcommand:") {
"unknown_agents_subcommand"
} else if message.starts_with("agent not found:") {
"agent_not_found"
} else if message.contains("is not installed") {
"plugin_not_found"
} else if (message.contains("skill source") && message.contains("not found"))
@@ -688,6 +690,13 @@ enum LocalHelpTopic {
SystemPrompt,
DumpManifests,
BootstrapPlan,
// #720: subsystem help topics so `claw help agents` etc. route to usage JSON
Agents,
Skills,
Plugins,
Mcp,
Config,
Diff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -921,6 +930,12 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
"system-prompt" => Some(LocalHelpTopic::SystemPrompt),
"dump-manifests" => Some(LocalHelpTopic::DumpManifests),
"bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan),
"agents" | "agent" => Some(LocalHelpTopic::Agents),
"skills" | "skill" => Some(LocalHelpTopic::Skills),
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
"mcp" => Some(LocalHelpTopic::Mcp),
"config" => Some(LocalHelpTopic::Config),
"diff" => Some(LocalHelpTopic::Diff),
_ => None,
};
if let Some(topic) = topic {
@@ -1257,6 +1272,39 @@ fn parse_single_word_command_alias(
// "doctor --help -h" is valid, routed to parse_local_help_action() instead
return None;
}
// #720: `claw help <topic>` — when the verb is "help" and exactly one
// non-flag argument follows, try to route to the topic's handler.
if verb == "help" && rest.len() == 2 {
let topic_name = rest[1].as_str();
let topic = match topic_name {
"status" => Some(LocalHelpTopic::Status),
"sandbox" => Some(LocalHelpTopic::Sandbox),
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"state" => Some(LocalHelpTopic::State),
"export" => Some(LocalHelpTopic::Export),
"version" => Some(LocalHelpTopic::Version),
"system-prompt" => Some(LocalHelpTopic::SystemPrompt),
"dump-manifests" => Some(LocalHelpTopic::DumpManifests),
"bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan),
"agents" | "agent" => Some(LocalHelpTopic::Agents),
"skills" | "skill" => Some(LocalHelpTopic::Skills),
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
"mcp" => Some(LocalHelpTopic::Mcp),
"config" => Some(LocalHelpTopic::Config),
"diff" => Some(LocalHelpTopic::Diff),
_ => None,
};
if let Some(t) = topic {
return Some(Ok(CliAction::HelpTopic {
topic: t,
output_format,
}));
}
// Unknown topic: fall through to generic help.
return Some(Ok(CliAction::Help { output_format }));
}
// Unrecognized suffix like "--json"
let mut msg = format!(
"unrecognized argument `{}` for subcommand `{}`",
@@ -1270,6 +1318,40 @@ fn parse_single_word_command_alias(
return Some(Err(msg));
}
// #720: `claw help <topic>` — when `help` is the verb and a topic follows,
// try to route to the topic's help handler instead of erroring.
if rest.len() == 2 && rest[0] == "help" {
let topic_name = rest[1].as_str();
let topic = match topic_name {
"status" => Some(LocalHelpTopic::Status),
"sandbox" => Some(LocalHelpTopic::Sandbox),
"doctor" => Some(LocalHelpTopic::Doctor),
"acp" => Some(LocalHelpTopic::Acp),
"init" => Some(LocalHelpTopic::Init),
"state" => Some(LocalHelpTopic::State),
"export" => Some(LocalHelpTopic::Export),
"version" => Some(LocalHelpTopic::Version),
"system-prompt" => Some(LocalHelpTopic::SystemPrompt),
"dump-manifests" => Some(LocalHelpTopic::DumpManifests),
"bootstrap-plan" => Some(LocalHelpTopic::BootstrapPlan),
"agents" | "agent" => Some(LocalHelpTopic::Agents),
"skills" | "skill" => Some(LocalHelpTopic::Skills),
"plugins" | "plugin" | "marketplace" => Some(LocalHelpTopic::Plugins),
"mcp" => Some(LocalHelpTopic::Mcp),
"config" => Some(LocalHelpTopic::Config),
"diff" => Some(LocalHelpTopic::Diff),
_ => None,
};
if let Some(t) = topic {
return Some(Ok(CliAction::HelpTopic {
topic: t,
output_format,
}));
}
// Unknown topic falls through to the generic help action.
return Some(Ok(CliAction::Help { output_format }));
}
if rest.len() != 1 {
return None;
}
@@ -2139,6 +2221,7 @@ impl DoctorReport {
let (ok_count, warn_count, fail_count) = self.counts();
json!({
"kind": "doctor",
"action": "doctor",
"status": self.status(),
"message": report,
"report": report,
@@ -2890,6 +2973,7 @@ fn dump_manifests_at_path(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "dump-manifests",
"action": "dump",
"commands": manifest.commands.entries().len(),
"tools": manifest.tools.entries().len(),
"bootstrap_phases": manifest.bootstrap.phases().len(),
@@ -2922,6 +3006,7 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn st
"{}",
serde_json::to_string_pretty(&json!({
"kind": "bootstrap-plan",
"action": "show",
"status": "ok",
"phases": phases,
}))?
@@ -2954,6 +3039,7 @@ fn print_system_prompt(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "system-prompt",
"action": "show",
"status": "ok",
"message": message,
"sections": sections,
@@ -2977,6 +3063,7 @@ fn version_json_value() -> serde_json::Value {
let executable_path = env::current_exe().ok().map(|p| p.display().to_string());
json!({
"kind": "version",
"action": "show",
"status": "ok",
"message": render_version_report(),
"version": VERSION,
@@ -3001,9 +3088,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": short_reason,
"kind": kind,
"action": "restore",
"status": "error",
"error_kind": kind,
"error": short_reason,
"exit_code": 1,
"hint": hint,
})
);
@@ -3021,6 +3111,8 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
"{}",
serde_json::json!({
"kind": "restored",
"action": "restore",
"status": "ok",
"session_id": session.session_id,
"path": handle.path.display().to_string(),
"message_count": session.messages.len(),
@@ -3054,9 +3146,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("/{cmd_root} is not yet implemented in this build"),
"kind": "unsupported_command",
"action": "resume",
"status": "error",
"error_kind": "unsupported_command",
"error": format!("/{cmd_root} is not yet implemented in this build"),
"exit_code": 2,
"command": raw_command,
})
);
@@ -3073,9 +3168,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"error": format!("unsupported resumed command: {raw_command}"),
"kind": "unsupported_resumed_command",
"action": "resume",
"status": "error",
"error_kind": "unsupported_resumed_command",
"error": format!("unsupported resumed command: {raw_command}"),
"exit_code": 2,
"command": raw_command,
})
);
@@ -3089,8 +3187,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"kind": "cli_parse",
"action": "resume",
"status": "error",
"error_kind": "cli_parse",
"error": error.to_string(),
"exit_code": 2,
"command": raw_command,
})
);
@@ -3126,8 +3228,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"kind": "resume_command_error",
"action": "resume",
"status": "error",
"error_kind": "resume_command_error",
"error": error.to_string(),
"exit_code": 2,
"command": raw_command,
})
);
@@ -3960,18 +4066,7 @@ fn run_resume_command(
let session_list_outcome = || -> Result<ResumeCommandOutcome, Box<dyn std::error::Error>> {
let sessions = list_managed_sessions().unwrap_or_default();
let session_ids: Vec<String> = sessions.iter().map(|s| s.id.clone()).collect();
let session_details: Vec<serde_json::Value> = sessions
.iter()
.map(|session| {
serde_json::json!({
"id": session.id,
"path": session.path.display().to_string(),
"message_count": session.message_count,
"updated_at_ms": session.updated_at_ms,
"lifecycle": session.lifecycle.json_value(),
})
})
.collect();
let session_details = session_details_json(&sessions);
let active_id = session.session_id.clone();
let text = render_session_list(&active_id).unwrap_or_else(|e| format!("error: {e}"));
Ok(ResumeCommandOutcome {
@@ -3992,7 +4087,9 @@ fn run_resume_command(
SlashCommand::Help => Ok(ResumeCommandOutcome {
session: session.clone(),
message: Some(render_repl_help()),
json: Some(serde_json::json!({ "kind": "help", "text": render_repl_help() })),
json: Some(
serde_json::json!({ "kind": "help", "action": "help", "status": "ok", "text": render_repl_help() }),
),
}),
SlashCommand::Compact => {
let result = runtime::trident::trident_compact_session(
@@ -4107,6 +4204,8 @@ fn run_resume_command(
message: Some(format_cost_report(usage)),
json: Some(serde_json::json!({
"kind": "cost",
"action": "show",
"status": "ok",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
@@ -4185,6 +4284,7 @@ fn run_resume_command(
)),
json: Some(serde_json::json!({
"kind": "export",
"action": "export",
"status": "ok",
"file": export_path.display().to_string(),
"message_count": msg_count,
@@ -4275,6 +4375,8 @@ fn run_resume_command(
message: Some(format_cost_report(usage)),
json: Some(serde_json::json!({
"kind": "stats",
"action": "show",
"status": "ok",
"input_tokens": usage.input_tokens,
"output_tokens": usage.output_tokens,
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
@@ -4295,6 +4397,8 @@ fn run_resume_command(
message: Some(render_prompt_history_report(&entries, limit)),
json: Some(serde_json::json!({
"kind": "history",
"action": "list",
"status": "ok",
"total": entries.len(),
"showing": shown.len(),
"entries": shown.iter().map(|e| serde_json::json!({
@@ -4429,8 +4533,12 @@ fn enforce_broad_cwd_policy(
eprintln!(
"{}",
serde_json::json!({
"type": "error",
"kind": "broad_cwd",
"action": "abort",
"status": "error",
"error_kind": "broad_cwd",
"error": message,
"exit_code": 1,
})
);
}
@@ -4550,6 +4658,7 @@ struct SessionHandle {
struct ManagedSessionSummary {
id: String,
path: PathBuf,
created_at_ms: u64,
updated_at_ms: u64,
modified_epoch_millis: u128,
message_count: usize,
@@ -5970,28 +6079,84 @@ impl LiveCli {
CliOutputFormat::Text => println!("{}", payload.message),
CliOutputFormat::Json => {
let action_str = action.unwrap_or("list");
let enabled_count = payload
.plugins
// For show/info/describe, filter to the named plugin (exact match).
// For list with a target, treat target as a substring filter.
let is_show_action = matches!(action_str, "show" | "info" | "describe");
let is_list_action = action_str == "list";
let filtered_plugins: Vec<_> = if is_show_action {
if let Some(name) = target {
let needle = name.to_lowercase();
payload
.plugins
.iter()
.filter(|p| {
p.get("id")
.and_then(|v| v.as_str())
.map(|id| id.to_lowercase() == needle)
.unwrap_or(false)
})
.cloned()
.collect()
} else {
payload.plugins.clone()
}
} else if is_list_action {
if let Some(filter) = target {
let needle = filter.to_lowercase();
payload
.plugins
.iter()
.filter(|p| {
p.get("id")
.and_then(|v| v.as_str())
.map(|id| id.to_lowercase().contains(&needle))
.unwrap_or(false)
})
.cloned()
.collect()
} else {
payload.plugins.clone()
}
} else {
payload.plugins.clone()
};
// Return not-found error for show with missing target.
if is_show_action {
if let Some(name) = target {
if filtered_plugins.is_empty() {
let obj = json!({
"kind": "plugin",
"action": action_str,
"status": "error",
"error_kind": "plugin_not_found",
"requested": name,
});
println!("{}", serde_json::to_string_pretty(&obj)?);
return Ok(());
}
}
}
let enabled_count = filtered_plugins
.iter()
.filter(|p| p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false))
.count();
let disabled_count = payload.plugins.len().saturating_sub(enabled_count);
let disabled_count = filtered_plugins.len().saturating_sub(enabled_count);
let mut obj = json!({
"kind": "plugin",
"action": action_str,
"status": payload.status,
"summary": {
"total": payload.plugins.len(),
"total": filtered_plugins.len(),
"enabled": enabled_count,
"disabled": disabled_count,
"load_failures": payload.load_failures.len(),
},
"config_load_error": payload.config_load_error,
"plugins": payload.plugins,
"plugins": filtered_plugins,
"load_failures": payload.load_failures,
});
// Only include operation-result fields for mutating actions
if action_str != "list" {
// Only include operation-result fields for mutating actions (not list/show)
if action_str != "list" && !is_show_action {
obj["target"] = json!(target);
obj["reload_runtime"] = json!(payload.reload_runtime);
obj["message"] = json!(payload.message);
@@ -6367,6 +6532,7 @@ fn list_managed_sessions() -> Result<Vec<ManagedSessionSummary>, Box<dyn std::er
.map(|session| ManagedSessionSummary {
id: session.id,
path: session.path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
@@ -6386,6 +6552,7 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
Ok(ManagedSessionSummary {
id: session.id,
path: session.path,
created_at_ms: session.created_at_ms,
updated_at_ms: session.updated_at_ms,
modified_epoch_millis: session.modified_epoch_millis,
message_count: session.message_count,
@@ -6443,6 +6610,7 @@ fn session_details_json(sessions: &[ManagedSessionSummary]) -> Vec<serde_json::V
"id": session.id,
"path": session.path.display().to_string(),
"message_count": session.message_count,
"created_at_ms": session.created_at_ms,
"updated_at_ms": session.updated_at_ms,
"modified_epoch_millis": session.modified_epoch_millis,
"parent_session_id": session.parent_session_id,
@@ -6465,6 +6633,8 @@ fn session_exists_json(
.map_or(target, |handle| handle.id.as_str());
Ok(serde_json::json!({
"kind": "session_exists",
"action": "exists",
"status": "ok",
"session_id": resolved_id,
"session": target,
"requested": target,
@@ -6560,6 +6730,8 @@ fn run_resumed_session_command(
)),
json: Some(serde_json::json!({
"kind": "session_delete",
"action": "delete",
"status": "ok",
"deleted": true,
"session_id": handle.id,
"path": handle.path.display().to_string(),
@@ -6752,6 +6924,7 @@ fn status_json_value(
let allowed_tool_entries = allowed_tools.map(|tools| tools.iter().cloned().collect::<Vec<_>>());
json!({
"kind": "status",
"action": "show",
"status": if degraded { "degraded" } else { "ok" },
"config_load_error": context.config_load_error,
"config_load_error_kind": context.config_load_error_kind,
@@ -7109,6 +7282,7 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
};
json!({
"kind": "sandbox",
"action": "status",
"status": top_status,
"enabled": status.enabled,
"active": status.active,
@@ -7209,6 +7383,40 @@ fn render_help_topic(topic: LocalHelpTopic) -> String {
Formats text (default), json
Related claw doctor · claw status"
.to_string(),
LocalHelpTopic::Agents => commands::handle_agents_slash_command(
Some("--help"),
&env::current_dir().unwrap_or_default(),
)
.unwrap_or_else(|_| "agents help unavailable".to_string()),
LocalHelpTopic::Skills => commands::handle_skills_slash_command(
Some("--help"),
&env::current_dir().unwrap_or_default(),
)
.unwrap_or_else(|_| "skills help unavailable".to_string()),
LocalHelpTopic::Plugins => "Plugins
Usage claw plugins [list|show <name>|install <path>|enable <name>|disable <name>|uninstall <name>]
Purpose manage lifecycle of plugins that extend tool and hook capabilities
Formats text (default), json
Related /plugins · claw plugins --help"
.to_string(),
LocalHelpTopic::Mcp => "MCP Servers
Usage claw mcp [list|show <server>] [--output-format <format>]
Purpose inspect configured MCP servers and their connection status
Formats text (default), json
Related /mcp · claw mcp list"
.to_string(),
LocalHelpTopic::Config => "Config
Usage claw config [section] [--output-format <format>]
Purpose show effective runtime configuration (model, hooks, plugins, env)
Formats text (default), json
Related /config · claw doctor"
.to_string(),
LocalHelpTopic::Diff => "Diff
Usage claw diff [--output-format <format>]
Purpose show the diff of changes relative to the expected base commit
Formats text (default), json
Related /diff · ROADMAP #148"
.to_string(),
}
}
@@ -7225,12 +7433,19 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
LocalHelpTopic::SystemPrompt => "system-prompt",
LocalHelpTopic::DumpManifests => "dump-manifests",
LocalHelpTopic::BootstrapPlan => "bootstrap-plan",
LocalHelpTopic::Agents => "agents",
LocalHelpTopic::Skills => "skills",
LocalHelpTopic::Plugins => "plugins",
LocalHelpTopic::Mcp => "mcp",
LocalHelpTopic::Config => "config",
LocalHelpTopic::Diff => "diff",
}
}
fn render_export_help_json() -> serde_json::Value {
json!({
"kind": "help",
"action": "help",
"status": "ok",
"topic": "export",
"command": "export",
@@ -7279,6 +7494,7 @@ fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value {
json!({
"kind": "help",
"action": "help",
"status": "ok",
"topic": local_help_topic_command(topic),
"command": local_help_topic_command(topic),
@@ -7290,6 +7506,29 @@ fn print_help_topic(
topic: LocalHelpTopic,
output_format: CliOutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir().unwrap_or_default();
// For subsystem topics in JSON mode, delegate to the subsystem's usage JSON.
if output_format == CliOutputFormat::Json {
match topic {
LocalHelpTopic::Agents => {
let json = commands::handle_agents_slash_command_json(Some("--help"), &cwd)
.unwrap_or_else(
|_| serde_json::json!({"kind":"agents","action":"help","status":"error"}),
);
println!("{}", serde_json::to_string_pretty(&json)?);
return Ok(());
}
LocalHelpTopic::Skills => {
let json = commands::handle_skills_slash_command_json(Some("--help"), &cwd)
.unwrap_or_else(
|_| serde_json::json!({"kind":"skills","action":"help","status":"error"}),
);
println!("{}", serde_json::to_string_pretty(&json)?);
return Ok(());
}
_ => {}
}
}
match output_format {
CliOutputFormat::Text => println!("{}", render_help_topic(topic)),
CliOutputFormat::Json => println!(
@@ -7308,6 +7547,7 @@ fn acp_status_json() -> serde_json::Value {
json!({
"schema_version": "1.0",
"kind": "acp",
"action": "status",
"status": "unsupported",
"phase": "discoverability_only",
"supported": false,
@@ -7405,9 +7645,17 @@ fn render_config_report(section: Option<&str>) -> Result<String, Box<dyn std::er
"plugins" => runtime_config
.get("plugins")
.or_else(|| runtime_config.get("enabledPlugins")),
"mcp" | "mcp_servers" | "mcpServers" => runtime_config
.get("mcp")
.or_else(|| runtime_config.get("mcp_servers"))
.or_else(|| runtime_config.get("mcpServers")),
"sandbox" => runtime_config.get("sandbox"),
"permissions" => runtime_config.get("permissions"),
"skills" => runtime_config.get("skills"),
"agents" => runtime_config.get("agents"),
other => {
lines.push(format!(
" Unsupported config section '{other}'. Use env, hooks, model, or plugins."
" Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents."
));
return Ok(lines.join(
"
@@ -7472,6 +7720,7 @@ fn render_config_json(
let base = serde_json::json!({
"kind": "config",
"action": if section.is_some() { "show" } else { "list" },
"status": "ok",
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
@@ -7488,14 +7737,27 @@ fn render_config_json(
.get("plugins")
.or_else(|| runtime_config.get("enabledPlugins"))
.map(|v| v.render()),
// These sections are structurally present in config files but may not have
// dedicated runtime_config keys yet; return null section_value rather than error.
"mcp" | "mcp_servers" | "mcpServers" => runtime_config
.get("mcp")
.or_else(|| runtime_config.get("mcp_servers"))
.or_else(|| runtime_config.get("mcpServers"))
.map(|v| v.render()),
"sandbox" => runtime_config.get("sandbox").map(|v| v.render()),
"permissions" => runtime_config.get("permissions").map(|v| v.render()),
"skills" => runtime_config.get("skills").map(|v| v.render()),
"agents" => runtime_config.get("agents").map(|v| v.render()),
other => {
return Ok(serde_json::json!({
"kind": "config",
"action": "show",
"status": "error",
"error_kind": "unsupported_config_section",
"section": other,
"ok": false,
"error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."),
"error": format!("Unsupported config section '{other}'. Use: env, hooks, model, plugins, mcp, sandbox, permissions, skills, or agents."),
"supported_sections": ["env", "hooks", "model", "plugins", "mcp", "sandbox", "permissions", "skills", "agents"],
"cwd": cwd.display().to_string(),
"loaded_files": loaded_paths.len(),
"files": files,
@@ -7576,6 +7838,8 @@ fn render_memory_json() -> Result<serde_json::Value, Box<dyn std::error::Error>>
.collect();
Ok(json!({
"kind": "memory",
"action": "list",
"status": "ok",
"cwd": cwd.display().to_string(),
"instruction_files": files.len(),
"files": files,
@@ -7610,6 +7874,7 @@ fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_jso
let status = "ok";
json!({
"kind": "init",
"action": "init",
"status": status,
"project_path": report.project_root.display().to_string(),
"created": report.artifacts_with_status(InitStatus::Created),
@@ -7680,8 +7945,10 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
if !in_git_repo {
return Ok(serde_json::json!({
"kind": "diff",
"action": "diff",
"status": "error",
"result": "no_git_repo",
"working_directory": cwd.display().to_string(),
"detail": format!("{} is not inside a git project", cwd.display()),
}));
}
@@ -7689,7 +7956,9 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
Ok(serde_json::json!({
"kind": "diff",
"action": "diff",
"status": "ok",
"working_directory": cwd.display().to_string(),
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
"staged": staged.trim(),
"unstaged": unstaged.trim(),
@@ -8212,6 +8481,7 @@ fn run_export(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"action": "export",
"status": "ok",
"message": report,
"session_id": handle.id,
@@ -8234,6 +8504,7 @@ fn run_export(
"{}",
serde_json::to_string_pretty(&json!({
"kind": "export",
"action": "export",
"status": "ok",
"session_id": handle.id,
"file": handle.path.display().to_string(),
@@ -10605,6 +10876,7 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
"{}",
serde_json::to_string_pretty(&json!({
"kind": "help",
"action": "help",
"status": "ok",
"message": message,
}))?
@@ -14266,6 +14538,33 @@ UU conflicted.rs",
assert_eq!(missing["session_id"], "missing-session");
assert!(missing["candidate_path"].as_str().is_some());
let list_command = SlashCommand::parse("/session list")
.expect("parse should succeed")
.expect("command should exist");
let list = run_resume_command(&active.path, &active_session, &list_command)
.expect("list should run")
.json
.expect("list should return json");
assert_eq!(list["kind"], "sessions");
let details = list["session_details"]
.as_array()
.expect("session_details should be an array");
let saved_path = saved.path.display().to_string();
let saved_detail = details
.iter()
.find(|detail| detail["path"] == saved_path)
.expect("saved session detail should exist");
let created_at_ms = saved_detail["created_at_ms"]
.as_u64()
.expect("created_at_ms should be present");
let updated_at_ms = saved_detail["updated_at_ms"]
.as_u64()
.expect("updated_at_ms should be present");
assert!(
created_at_ms <= updated_at_ms,
"created_at_ms should not be after updated_at_ms"
);
let delete_command = SlashCommand::parse("/session delete session-saved --force")
.expect("parse should succeed")
.expect("command should exist");

View File

@@ -73,6 +73,10 @@ fn version_emits_json_when_requested() {
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
assert_eq!(parsed["kind"], "version");
assert_eq!(
parsed["action"], "show",
"version JSON must have action:show (#711)"
);
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
// Provenance fields must be present for binary identification (#507).
assert!(
@@ -187,6 +191,72 @@ fn inventory_commands_emit_structured_json_when_requested() {
.expect("agents array")
.is_empty());
// #717: agents show <name> and agents list <filter> should be valid subcommands
let agents_show_env = [
("HOME", isolated_home.to_str().expect("utf8 home")),
(
"CLAW_CONFIG_HOME",
isolated_config.to_str().expect("utf8 config home"),
),
(
"CODEX_HOME",
isolated_codex.to_str().expect("utf8 codex home"),
),
];
let agents_show_missing = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"agents",
"show",
"nonexistent-xyz",
],
&agents_show_env,
);
assert_eq!(agents_show_missing["kind"], "agents", "agents show kind");
assert_eq!(agents_show_missing["action"], "show", "agents show action");
assert_eq!(
agents_show_missing["status"], "error",
"agents show not-found status"
);
assert_eq!(
agents_show_missing["error_kind"], "agent_not_found",
"agents show error_kind"
);
assert_eq!(
agents_show_missing["requested"], "nonexistent-xyz",
"agents show requested"
);
let agents_list_filtered = assert_json_command_with_env(
&root,
&[
"--output-format",
"json",
"agents",
"list",
"nonexistent-filter-xyz",
],
&agents_show_env,
);
assert_eq!(
agents_list_filtered["kind"], "agents",
"agents list filter kind"
);
assert_eq!(
agents_list_filtered["action"], "list",
"agents list filter action"
);
assert_eq!(
agents_list_filtered["status"], "ok",
"agents list filter status"
);
assert!(agents_list_filtered["agents"]
.as_array()
.expect("agents array")
.is_empty());
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
assert_eq!(mcp["kind"], "mcp");
assert_eq!(mcp["action"], "list");
@@ -478,6 +548,10 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
assert_eq!(prompt["kind"], "system-prompt");
assert_eq!(
prompt["action"], "show",
"system-prompt JSON must have action:show (#711)"
);
assert!(prompt["message"]
.as_str()
.expect("prompt text")
@@ -508,6 +582,10 @@ fn dump_manifests_and_init_emit_json_when_requested() {
fs::create_dir_all(&workspace).expect("workspace should exist");
let init = assert_json_command(&workspace, &["--output-format", "json", "init"]);
assert_eq!(init["kind"], "init");
assert_eq!(
init["action"], "init",
"init JSON must have action:init (#711)"
);
assert!(workspace.join("CLAUDE.md").exists());
}
@@ -941,6 +1019,124 @@ fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
assert!(failed.get("config_load_error").is_none());
}
#[test]
fn local_json_surfaces_have_non_empty_action_contract_714() {
let root = unique_temp_dir("json-action-sweep-714");
let workspace = root.join("workspace");
let init_workspace = root.join("init-workspace");
let git_workspace = root.join("git-workspace");
let home = root.join("home");
let config_home = root.join("config-home");
let codex_home = root.join("codex-home");
fs::create_dir_all(&workspace).expect("workspace should exist");
fs::create_dir_all(&init_workspace).expect("init workspace should exist");
fs::create_dir_all(&git_workspace).expect("git workspace should exist");
fs::create_dir_all(&home).expect("home should exist");
fs::create_dir_all(&config_home).expect("config home should exist");
fs::create_dir_all(&codex_home).expect("codex home should exist");
let session_path = write_session_fixture(&workspace, "action-sweep-export", Some("export me"));
let export_output = root.join("export.md");
let upstream = write_upstream_fixture(&root);
let git_init = Command::new("git")
.arg("init")
.current_dir(&git_workspace)
.output()
.expect("git init should launch");
assert!(
git_init.status.success(),
"git init stdout:\n{}\n\nstderr:\n{}",
String::from_utf8_lossy(&git_init.stdout),
String::from_utf8_lossy(&git_init.stderr)
);
let envs = [
("HOME", home.to_str().expect("home utf8")),
(
"CLAW_CONFIG_HOME",
config_home.to_str().expect("config utf8"),
),
("CODEX_HOME", codex_home.to_str().expect("codex utf8")),
];
let surfaces: Vec<(&Path, Vec<String>)> = vec![
(&workspace, strings(&["--output-format", "json", "help"])),
(&workspace, strings(&["--output-format", "json", "version"])),
(&workspace, strings(&["--output-format", "json", "doctor"])),
(&workspace, strings(&["--output-format", "json", "status"])),
(&workspace, strings(&["--output-format", "json", "sandbox"])),
(
&workspace,
strings(&["--output-format", "json", "bootstrap-plan"]),
),
(
&workspace,
strings(&["--output-format", "json", "system-prompt"]),
),
(
&workspace,
vec![
"--output-format".into(),
"json".into(),
"dump-manifests".into(),
"--manifests-dir".into(),
upstream.to_str().expect("upstream utf8").into(),
],
),
(
&workspace,
vec![
"--output-format".into(),
"json".into(),
"export".into(),
"--session".into(),
session_path.to_str().expect("session utf8").into(),
],
),
(
&workspace,
vec![
"--output-format".into(),
"json".into(),
"export".into(),
"--session".into(),
session_path.to_str().expect("session utf8").into(),
"--output".into(),
export_output.to_str().expect("export output utf8").into(),
],
),
(
&init_workspace,
strings(&["--output-format", "json", "init"]),
),
(&workspace, strings(&["--output-format", "json", "diff"])),
(
&git_workspace,
strings(&["--output-format", "json", "diff"]),
),
(&workspace, strings(&["--output-format", "json", "acp"])),
(&workspace, strings(&["--output-format", "json", "config"])),
(
&workspace,
strings(&["--output-format", "json", "config", "model"]),
),
(
&workspace,
strings(&["--output-format", "json", "config", "unknown"]),
),
(&workspace, strings(&["--output-format", "json", "skills"])),
(&workspace, strings(&["--output-format", "json", "agents"])),
(&workspace, strings(&["--output-format", "json", "plugins"])),
(&workspace, strings(&["--output-format", "json", "mcp"])),
];
for (current_dir, args) in surfaces {
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
let parsed = assert_json_command_with_env(current_dir, &arg_refs, &envs);
assert_non_empty_action(&parsed, &arg_refs);
}
}
#[test]
fn inventory_commands_deduplicate_config_deprecation_warnings_per_process() {
let root = unique_temp_dir("config-warning-dedup");
@@ -993,7 +1189,21 @@ fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("stdout should be valid json")
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("stdout should be valid json");
assert_non_empty_action(&parsed, args);
parsed
}
fn assert_non_empty_action(parsed: &Value, args: &[&str]) {
let action = parsed
.get("action")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
!action.trim().is_empty(),
"JSON output for args={args:?} must include a non-empty stable action field: {parsed}"
);
}
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
@@ -1005,6 +1215,10 @@ fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output
command.output().expect("claw should launch")
}
fn strings(items: &[&str]) -> Vec<String> {
items.iter().map(|item| (*item).to_string()).collect()
}
fn write_upstream_fixture(root: &Path) -> PathBuf {
let upstream = root.join("claw-code");
let src = upstream.join("src");
@@ -1114,6 +1328,18 @@ fn diff_json_has_status_and_result_field_702() {
parsed.get("result").is_some(),
"diff JSON must have result field"
);
// #710: diff JSON must have action:diff and working_directory
assert_eq!(
parsed["action"], "diff",
"diff JSON must have action:diff (#710)"
);
assert!(
parsed
.get("working_directory")
.and_then(|v| v.as_str())
.is_some(),
"diff JSON must have working_directory field (#710)"
);
}
#[test]

View File

@@ -523,7 +523,14 @@ fn resumed_stub_command_emits_not_implemented_json() {
assert!(!output.status.success());
let stderr = String::from_utf8(output.stderr).expect("utf8");
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!(
parsed["error"]
.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()