mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-25 15:06:44 +00:00
Compare commits
54 Commits
fix-683-un
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dedad14ae4 | ||
|
|
f84799c8ef | ||
|
|
732007da8e | ||
|
|
8f809d9a9e | ||
|
|
f6cab2711f | ||
|
|
1a6f54b970 | ||
|
|
1555785294 | ||
|
|
2f9429cbf0 | ||
|
|
4daefc7bd5 | ||
|
|
a7a30627a9 | ||
|
|
5bca9ef039 | ||
|
|
b8eca2a68e | ||
|
|
566992c331 | ||
|
|
36b36267ec | ||
|
|
21a986034e | ||
|
|
ee24ff2d83 | ||
|
|
9e6f753640 | ||
|
|
de2e32c5d4 | ||
|
|
9d1998b3fd | ||
|
|
181b12f0a9 | ||
|
|
47521cf178 | ||
|
|
9c5f190fcc | ||
|
|
9f14a7aa9e | ||
|
|
f9e98a2634 | ||
|
|
c08395ca92 | ||
|
|
10957f59c5 | ||
|
|
eb7c14c4ae | ||
|
|
11a6e081a2 | ||
|
|
16604a111b | ||
|
|
cc1462a7f8 | ||
|
|
f2a90228fb | ||
|
|
0581894b7e | ||
|
|
5b79413e87 | ||
|
|
85e736c73f | ||
|
|
b64df99134 | ||
|
|
c345ce6d02 | ||
|
|
91a0681ae9 | ||
|
|
c613e8e676 | ||
|
|
1003510a75 | ||
|
|
63a5a87471 | ||
|
|
da7924d079 | ||
|
|
bb2a9238d9 | ||
|
|
8806e62a9f | ||
|
|
78a0ff615a | ||
|
|
706ac0f8e1 | ||
|
|
bd8a27b100 | ||
|
|
6f5465aeaf | ||
|
|
fdbc789694 | ||
|
|
779cf1c234 | ||
|
|
1f330c6737 | ||
|
|
3489ec51d5 | ||
|
|
fa8eecaf8f | ||
|
|
2033c90921 | ||
|
|
8cada12c48 |
20
.github/hooks/pre-push
vendored
Executable file
20
.github/hooks/pre-push
vendored
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/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 [[ ! -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
27
ROADMAP.md
27
ROADMAP.md
@@ -7552,3 +7552,30 @@ 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 1–3 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.
|
||||||
|
|||||||
47
docs/g013-roadmap-pinpoints-693-695-verification-map.md
Normal file
47
docs/g013-roadmap-pinpoints-693-695-verification-map.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 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`
|
||||||
|
- 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
|
||||||
|
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
|
||||||
|
```
|
||||||
1
rust/Cargo.lock
generated
Normal file → Executable file
1
rust/Cargo.lock
generated
Normal file → Executable file
@@ -2124,6 +2124,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"telemetry",
|
"telemetry",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1111,7 +1111,24 @@ enum BlockKind {
|
|||||||
|
|
||||||
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(|| {
|
||||||
|
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()
|
||||||
|
})?;
|
||||||
|
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());
|
||||||
|
}
|
||||||
let hits = v
|
let hits = v
|
||||||
.get("hits")
|
.get("hits")
|
||||||
.and_then(|h| h.as_array())
|
.and_then(|h| h.as_array())
|
||||||
@@ -2557,6 +2574,20 @@ 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""#));
|
||||||
|
}
|
||||||
|
|
||||||
#[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();
|
||||||
|
|||||||
@@ -2260,7 +2260,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 +2301,9 @@ pub fn handle_plugins_slash_command(
|
|||||||
reload_runtime: true,
|
reload_runtime: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Some(other) => Ok(PluginsCommandResult {
|
Some(other) => Err(PluginError::CommandFailed(format!(
|
||||||
message: format!(
|
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, install, enable, disable, uninstall, or update."
|
||||||
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
|
))),
|
||||||
),
|
|
||||||
reload_runtime: false,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2327,7 +2324,10 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
|||||||
Ok(render_agents_report(&agents))
|
Ok(render_agents_report(&agents))
|
||||||
}
|
}
|
||||||
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}. Supported: list, help"),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2348,7 +2348,10 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
|||||||
Ok(render_agents_report_json(cwd, &agents))
|
Ok(render_agents_report_json(cwd, &agents))
|
||||||
}
|
}
|
||||||
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}. Supported: list, help"),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2482,6 +2485,17 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|s| s.name.to_lowercase() == name)
|
.filter(|s| s.name.to_lowercase() == name)
|
||||||
.collect();
|
.collect();
|
||||||
|
// #706: return typed error when named skill is not found instead of silent empty list
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
Ok(render_skills_report_json(&matched))
|
Ok(render_skills_report_json(&matched))
|
||||||
}
|
}
|
||||||
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
||||||
@@ -2800,7 +2814,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 +3248,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");
|
||||||
@@ -3619,7 +3642,9 @@ fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
|
|||||||
.count();
|
.count();
|
||||||
json!({
|
json!({
|
||||||
"kind": "agents",
|
"kind": "agents",
|
||||||
|
"status": "ok",
|
||||||
"action": "list",
|
"action": "list",
|
||||||
|
"status": "ok",
|
||||||
"working_directory": cwd.display().to_string(),
|
"working_directory": cwd.display().to_string(),
|
||||||
"count": agents.len(),
|
"count": agents.len(),
|
||||||
"summary": {
|
"summary": {
|
||||||
@@ -3701,7 +3726,9 @@ fn render_skills_report_json(skills: &[SkillSummary]) -> Value {
|
|||||||
.count();
|
.count();
|
||||||
json!({
|
json!({
|
||||||
"kind": "skills",
|
"kind": "skills",
|
||||||
|
"status": "ok",
|
||||||
"action": "list",
|
"action": "list",
|
||||||
|
"status": "ok",
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": skills.len(),
|
"total": skills.len(),
|
||||||
"active": active,
|
"active": active,
|
||||||
@@ -3735,7 +3762,9 @@ fn render_skill_install_report(skill: &InstalledSkill) -> String {
|
|||||||
fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
|
fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"kind": "skills",
|
"kind": "skills",
|
||||||
|
"status": "ok",
|
||||||
"action": "install",
|
"action": "install",
|
||||||
|
"status": "ok",
|
||||||
"result": "installed",
|
"result": "installed",
|
||||||
"invocation_name": &skill.invocation_name,
|
"invocation_name": &skill.invocation_name,
|
||||||
"invoke_as": format!("${}", skill.invocation_name),
|
"invoke_as": format!("${}", skill.invocation_name),
|
||||||
@@ -3878,6 +3907,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,6 +3915,8 @@ 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,
|
||||||
@@ -3924,6 +3956,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 +3987,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"],
|
||||||
@@ -3995,6 +4031,8 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
|
|||||||
json!({
|
json!({
|
||||||
"kind": "mcp",
|
"kind": "mcp",
|
||||||
"action": "help",
|
"action": "help",
|
||||||
|
"ok": unexpected.is_none(),
|
||||||
|
"status": if unexpected.is_some() { "error" } else { "ok" },
|
||||||
"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 +4133,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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4131,7 +4177,7 @@ 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),
|
||||||
@@ -5298,6 +5344,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 +5360,16 @@ 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)
|
// Unknown agents subcommands now return Err so CLI layer can exit 1.
|
||||||
.expect("agents usage");
|
let unexpected_err = handle_agents_slash_command_json(Some("show planner"), &workspace);
|
||||||
assert_eq!(unexpected["action"], "help");
|
assert!(unexpected_err.is_err());
|
||||||
assert_eq!(unexpected["unexpected"], "show planner");
|
assert!(unexpected_err
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("show planner"));
|
||||||
|
|
||||||
let _ = fs::remove_dir_all(workspace);
|
let _ = fs::remove_dir_all(workspace);
|
||||||
let _ = fs::remove_dir_all(user_home);
|
let _ = fs::remove_dir_all(user_home);
|
||||||
@@ -5424,17 +5475,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
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 +5520,14 @@ mod tests {
|
|||||||
assert!(agents_help
|
assert!(agents_help
|
||||||
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
|
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
|
||||||
|
|
||||||
let agents_unexpected =
|
// Unknown agents subcommands now return Err (typed error) instead of Ok+help text
|
||||||
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
// so that the CLI layer can exit 1. The error message names the unexpected input.
|
||||||
assert!(agents_unexpected.contains("Unexpected show planner"));
|
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"));
|
||||||
|
|
||||||
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 +5563,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 +5949,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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -301,7 +316,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());
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,118 @@ 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-path", "."])
|
||||||
|
.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.to_path_buf()
|
||||||
|
} else {
|
||||||
|
path.parent().unwrap_or(path).to_path_buf()
|
||||||
|
};
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
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 +1448,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 +1596,66 @@ 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")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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();
|
||||||
|
|||||||
@@ -217,16 +217,22 @@ fn main() {
|
|||||||
.any(|w| w[0] == "--output-format" && w[1] == "json")
|
.any(|w| w[0] == "--output-format" && w[1] == "json")
|
||||||
|| argv.iter().any(|a| a == "--output-format=json");
|
|| argv.iter().any(|a| a == "--output-format=json");
|
||||||
if json_output {
|
if json_output {
|
||||||
// #77: classify error by prefix so downstream claws can route without
|
// #77/#696: classify error by prefix so downstream claws can route
|
||||||
// regex-scraping the prose. Split short-reason from hint-runbook.
|
// without regex-scraping prose. Keep the legacy `type`/`kind`
|
||||||
|
// fields and add the stable status/error_kind/action contract used
|
||||||
|
// by non-interactive command guards.
|
||||||
let kind = classify_error_kind(&message);
|
let kind = classify_error_kind(&message);
|
||||||
let (short_reason, hint) = split_error_hint(&message);
|
let (short_reason, hint) = split_error_hint(&message);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"{}",
|
"{}",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"error": short_reason,
|
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
|
"status": "error",
|
||||||
|
"error_kind": kind,
|
||||||
|
"error": short_reason,
|
||||||
|
"message": short_reason,
|
||||||
|
"action": "abort",
|
||||||
"hint": hint,
|
"hint": hint,
|
||||||
"exit_code": 1,
|
"exit_code": 1,
|
||||||
})
|
})
|
||||||
@@ -292,6 +298,22 @@ fn classify_error_kind(message: &str) -> &'static str {
|
|||||||
"malformed_mcp_config"
|
"malformed_mcp_config"
|
||||||
} else if message.starts_with("empty prompt") {
|
} else if message.starts_with("empty prompt") {
|
||||||
"empty_prompt"
|
"empty_prompt"
|
||||||
|
} else if message.starts_with("interactive_only:") || message.contains("stdin is not a TTY") {
|
||||||
|
"interactive_only"
|
||||||
|
} else if message.starts_with("unknown agents subcommand:") {
|
||||||
|
"unknown_agents_subcommand"
|
||||||
|
} else if message.contains("is not installed") {
|
||||||
|
"plugin_not_found"
|
||||||
|
} else if (message.contains("skill source") && message.contains("not found"))
|
||||||
|
|| message.starts_with("skill '")
|
||||||
|
{
|
||||||
|
"skill_not_found"
|
||||||
|
} else if message.contains("Unsupported config section") {
|
||||||
|
"unsupported_config_section"
|
||||||
|
} else if message.contains("unknown_plugins_action") {
|
||||||
|
"unknown_plugins_action"
|
||||||
|
} else if message.contains("is a slash command") || message.starts_with("interactive_only:") {
|
||||||
|
"interactive_only"
|
||||||
} else {
|
} else {
|
||||||
"unknown"
|
"unknown"
|
||||||
}
|
}
|
||||||
@@ -940,6 +962,12 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
allow_broad_cwd,
|
allow_broad_cwd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Non-TTY stdin with no piped content: refuse to start the interactive
|
||||||
|
// REPL (it would block forever waiting for input that will never arrive).
|
||||||
|
// (#696: emit a typed error instead of hanging indefinitely)
|
||||||
|
// Skip this guard in test builds (parse_args tests run in non-TTY context).
|
||||||
|
#[cfg(not(test))]
|
||||||
|
return Err("interactive_only: claw requires an interactive terminal (stdin is not a TTY and no prompt was provided — pipe a prompt or run in a TTY)".into());
|
||||||
}
|
}
|
||||||
return Ok(CliAction::Repl {
|
return Ok(CliAction::Repl {
|
||||||
model,
|
model,
|
||||||
@@ -956,6 +984,16 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||||||
if let Some(action) = parse_local_help_action(&rest, output_format) {
|
if let Some(action) = parse_local_help_action(&rest, output_format) {
|
||||||
return action;
|
return action;
|
||||||
}
|
}
|
||||||
|
// #696: `claw compact` is the bare name of the interactive `/compact`
|
||||||
|
// slash command, not a prompt. When extra args such as `--help` appear
|
||||||
|
// after the word `compact`, the generic prompt fallback used to send
|
||||||
|
// `compact --help` to provider startup and could hang under closed stdin /
|
||||||
|
// JSON output. Fail closed before any provider, prompt, TUI, or spinner
|
||||||
|
// startup. `claw --resume SESSION.jsonl /compact` remains the supported
|
||||||
|
// non-interactive session compaction path.
|
||||||
|
if rest.first().map(String::as_str) == Some("compact") {
|
||||||
|
return Err(compact_interactive_only_error());
|
||||||
|
}
|
||||||
if let Some(action) = parse_single_word_command_alias(
|
if let Some(action) = parse_single_word_command_alias(
|
||||||
&rest,
|
&rest,
|
||||||
&model,
|
&model,
|
||||||
@@ -1291,6 +1329,11 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
|||||||
Some(guidance)
|
Some(guidance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn compact_interactive_only_error() -> String {
|
||||||
|
"interactive_only: `claw compact` is an interactive/session command. Start `claw` and run `/compact`, or use `claw --resume SESSION.jsonl /compact` to compact an existing session."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn removed_auth_surface_error(command_name: &str) -> String {
|
fn removed_auth_surface_error(command_name: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead."
|
"`claw {command_name}` has been removed. Set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN instead."
|
||||||
@@ -1977,7 +2020,14 @@ impl DiagnosticCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn json_value(&self) -> Value {
|
fn json_value(&self) -> Value {
|
||||||
|
// Derive a stable snake_case id from the check name for machine-readable keying (#704).
|
||||||
|
let id = self
|
||||||
|
.name
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.replace(' ', "_")
|
||||||
|
.replace('-', "_");
|
||||||
let mut value = Map::from_iter([
|
let mut value = Map::from_iter([
|
||||||
|
("id".to_string(), Value::String(id.clone())),
|
||||||
(
|
(
|
||||||
"name".to_string(),
|
"name".to_string(),
|
||||||
Value::String(self.name.to_ascii_lowercase()),
|
Value::String(self.name.to_ascii_lowercase()),
|
||||||
@@ -1997,6 +2047,37 @@ impl DiagnosticCheck {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
// #701: structured key/value pairs parsed from prose detail strings.
|
||||||
|
// Each detail string is `"Key Label value"` separated by 2+ spaces.
|
||||||
|
// Booleans (`true`/`false`) and integers are emitted as JSON scalars.
|
||||||
|
"detail_entries".to_string(),
|
||||||
|
Value::Array(
|
||||||
|
self.details
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
// Split on first run of 2+ spaces to separate key from value.
|
||||||
|
let parts: Vec<&str> = s.splitn(2, " ").collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let k = parts[0].trim().to_string();
|
||||||
|
let v_str = parts[1].trim();
|
||||||
|
let v: Value = if v_str == "true" {
|
||||||
|
Value::Bool(true)
|
||||||
|
} else if v_str == "false" {
|
||||||
|
Value::Bool(false)
|
||||||
|
} else if let Ok(n) = v_str.parse::<i64>() {
|
||||||
|
Value::Number(n.into())
|
||||||
|
} else {
|
||||||
|
Value::String(v_str.to_string())
|
||||||
|
};
|
||||||
|
json!({"key": k, "value": v})
|
||||||
|
} else {
|
||||||
|
json!({"key": s.trim(), "value": Value::Null})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
value.extend(self.data.clone());
|
value.extend(self.data.clone());
|
||||||
Value::Object(value)
|
Value::Object(value)
|
||||||
@@ -2579,7 +2660,12 @@ fn check_boot_preflight_health(context: &StatusContext) -> DiagnosticCheck {
|
|||||||
format!("Worktree exists {}", preflight.worktree_exists),
|
format!("Worktree exists {}", preflight.worktree_exists),
|
||||||
format!("Git dir exists {}", preflight.git_dir_exists),
|
format!("Git dir exists {}", preflight.git_dir_exists),
|
||||||
format!("Branch behind {}", preflight.branch_freshness.behind),
|
format!("Branch behind {}", preflight.branch_freshness.behind),
|
||||||
format!("Trust allowlist {:?}", preflight.trust_gate_allowed),
|
format!(
|
||||||
|
"Trust allowlist {}",
|
||||||
|
preflight
|
||||||
|
.trust_gate_allowed
|
||||||
|
.map_or("unknown".to_string(), |v| v.to_string())
|
||||||
|
),
|
||||||
format!("Trusted roots {}", preflight.trusted_roots_count),
|
format!("Trusted roots {}", preflight.trusted_roots_count),
|
||||||
format!(
|
format!(
|
||||||
"MCP eligible {} · servers {}",
|
"MCP eligible {} · servers {}",
|
||||||
@@ -2836,6 +2922,7 @@ fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box<dyn st
|
|||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"kind": "bootstrap-plan",
|
"kind": "bootstrap-plan",
|
||||||
|
"status": "ok",
|
||||||
"phases": phases,
|
"phases": phases,
|
||||||
}))?
|
}))?
|
||||||
),
|
),
|
||||||
@@ -2867,6 +2954,7 @@ fn print_system_prompt(
|
|||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"kind": "system-prompt",
|
"kind": "system-prompt",
|
||||||
|
"status": "ok",
|
||||||
"message": message,
|
"message": message,
|
||||||
"sections": sections,
|
"sections": sections,
|
||||||
}))?
|
}))?
|
||||||
@@ -2889,6 +2977,7 @@ fn version_json_value() -> serde_json::Value {
|
|||||||
let executable_path = env::current_exe().ok().map(|p| p.display().to_string());
|
let executable_path = env::current_exe().ok().map(|p| p.display().to_string());
|
||||||
json!({
|
json!({
|
||||||
"kind": "version",
|
"kind": "version",
|
||||||
|
"status": "ok",
|
||||||
"message": render_version_report(),
|
"message": render_version_report(),
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"git_sha": GIT_SHA,
|
"git_sha": GIT_SHA,
|
||||||
@@ -3889,7 +3978,9 @@ fn run_resume_command(
|
|||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(text),
|
message: Some(text),
|
||||||
json: Some(serde_json::json!({
|
json: Some(serde_json::json!({
|
||||||
"kind": "session_list",
|
"kind": "sessions",
|
||||||
|
"status": "ok",
|
||||||
|
"action": "list",
|
||||||
"sessions": session_ids,
|
"sessions": session_ids,
|
||||||
"session_details": session_details,
|
"session_details": session_details,
|
||||||
"active": active_id,
|
"active": active_id,
|
||||||
@@ -4021,7 +4112,7 @@ fn run_resume_command(
|
|||||||
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
||||||
"cache_read_input_tokens": usage.cache_read_input_tokens,
|
"cache_read_input_tokens": usage.cache_read_input_tokens,
|
||||||
"total_tokens": usage.total_tokens(),
|
"total_tokens": usage.total_tokens(),
|
||||||
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()),
|
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(),
|
||||||
"pricing": "estimated-default",
|
"pricing": "estimated-default",
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
@@ -4094,6 +4185,7 @@ fn run_resume_command(
|
|||||||
)),
|
)),
|
||||||
json: Some(serde_json::json!({
|
json: Some(serde_json::json!({
|
||||||
"kind": "export",
|
"kind": "export",
|
||||||
|
"status": "ok",
|
||||||
"file": export_path.display().to_string(),
|
"file": export_path.display().to_string(),
|
||||||
"message_count": msg_count,
|
"message_count": msg_count,
|
||||||
})),
|
})),
|
||||||
@@ -4137,17 +4229,31 @@ fn run_resume_command(
|
|||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
let payload = plugins_command_payload_for(&cwd, action.as_deref(), target.as_deref())?;
|
let payload = plugins_command_payload_for(&cwd, action.as_deref(), target.as_deref())?;
|
||||||
let action_str = action.as_deref().unwrap_or("list");
|
let action_str = action.as_deref().unwrap_or("list");
|
||||||
let json = serde_json::json!({
|
let enabled_count = payload
|
||||||
|
.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 mut json = serde_json::json!({
|
||||||
"kind": "plugin",
|
"kind": "plugin",
|
||||||
"action": action_str,
|
"action": action_str,
|
||||||
"target": target,
|
|
||||||
"status": payload.status,
|
"status": payload.status,
|
||||||
|
"summary": {
|
||||||
|
"total": payload.plugins.len(),
|
||||||
|
"enabled": enabled_count,
|
||||||
|
"disabled": disabled_count,
|
||||||
|
"load_failures": payload.load_failures.len(),
|
||||||
|
},
|
||||||
"config_load_error": payload.config_load_error,
|
"config_load_error": payload.config_load_error,
|
||||||
"message": &payload.message,
|
|
||||||
"reload_runtime": payload.reload_runtime,
|
|
||||||
"plugins": payload.plugins,
|
"plugins": payload.plugins,
|
||||||
"load_failures": payload.load_failures,
|
"load_failures": payload.load_failures,
|
||||||
});
|
});
|
||||||
|
if action_str != "list" {
|
||||||
|
json["target"] = serde_json::json!(target);
|
||||||
|
json["reload_runtime"] = serde_json::json!(payload.reload_runtime);
|
||||||
|
json["message"] = serde_json::json!(&payload.message);
|
||||||
|
}
|
||||||
Ok(ResumeCommandOutcome {
|
Ok(ResumeCommandOutcome {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(payload.message),
|
message: Some(payload.message),
|
||||||
@@ -4174,7 +4280,7 @@ fn run_resume_command(
|
|||||||
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
"cache_creation_input_tokens": usage.cache_creation_input_tokens,
|
||||||
"cache_read_input_tokens": usage.cache_read_input_tokens,
|
"cache_read_input_tokens": usage.cache_read_input_tokens,
|
||||||
"total_tokens": usage.total_tokens(),
|
"total_tokens": usage.total_tokens(),
|
||||||
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()),
|
"estimated_cost_usd": format_usd(usage.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.estimate_cost_usd().total_cost_usd(),
|
||||||
"pricing": "estimated-default",
|
"pricing": "estimated-default",
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
@@ -5818,7 +5924,8 @@ impl LiveCli {
|
|||||||
// Propagate ok:false → non-zero exit so automation callers
|
// Propagate ok:false → non-zero exit so automation callers
|
||||||
// can rely on exit code instead of inspecting the envelope.
|
// can rely on exit code instead of inspecting the envelope.
|
||||||
// (#68: mcp error envelopes previously always exited 0.)
|
// (#68: mcp error envelopes previously always exited 0.)
|
||||||
let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false);
|
let is_error = value.get("ok").and_then(|v| v.as_bool()) == Some(false)
|
||||||
|
|| value.get("status").and_then(|v| v.as_str()) == Some("error");
|
||||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||||
if is_error {
|
if is_error {
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
@@ -5835,10 +5942,19 @@ impl LiveCli {
|
|||||||
let cwd = env::current_dir()?;
|
let cwd = env::current_dir()?;
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?),
|
CliOutputFormat::Text => println!("{}", handle_skills_slash_command(args, &cwd)?),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => {
|
||||||
"{}",
|
let result = handle_skills_slash_command_json(args, &cwd)?;
|
||||||
serde_json::to_string_pretty(&handle_skills_slash_command_json(args, &cwd)?)?
|
let is_error = result.get("status").and_then(|v| v.as_str()) == Some("error");
|
||||||
),
|
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||||
|
if is_error {
|
||||||
|
return Err(result
|
||||||
|
.get("message")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("skills command failed")
|
||||||
|
.to_string()
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -5852,20 +5968,36 @@ impl LiveCli {
|
|||||||
let payload = plugins_command_payload_for(&cwd, action, target)?;
|
let payload = plugins_command_payload_for(&cwd, action, target)?;
|
||||||
match output_format {
|
match output_format {
|
||||||
CliOutputFormat::Text => println!("{}", payload.message),
|
CliOutputFormat::Text => println!("{}", payload.message),
|
||||||
CliOutputFormat::Json => println!(
|
CliOutputFormat::Json => {
|
||||||
"{}",
|
let action_str = action.unwrap_or("list");
|
||||||
serde_json::to_string_pretty(&json!({
|
let enabled_count = payload
|
||||||
|
.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 mut obj = json!({
|
||||||
"kind": "plugin",
|
"kind": "plugin",
|
||||||
"action": action.unwrap_or("list"),
|
"action": action_str,
|
||||||
"target": target,
|
|
||||||
"status": payload.status,
|
"status": payload.status,
|
||||||
|
"summary": {
|
||||||
|
"total": payload.plugins.len(),
|
||||||
|
"enabled": enabled_count,
|
||||||
|
"disabled": disabled_count,
|
||||||
|
"load_failures": payload.load_failures.len(),
|
||||||
|
},
|
||||||
"config_load_error": payload.config_load_error,
|
"config_load_error": payload.config_load_error,
|
||||||
"message": payload.message,
|
|
||||||
"reload_runtime": payload.reload_runtime,
|
|
||||||
"plugins": payload.plugins,
|
"plugins": payload.plugins,
|
||||||
"load_failures": payload.load_failures,
|
"load_failures": payload.load_failures,
|
||||||
}))?
|
});
|
||||||
),
|
// Only include operation-result fields for mutating actions
|
||||||
|
if action_str != "list" {
|
||||||
|
obj["target"] = json!(target);
|
||||||
|
obj["reload_runtime"] = json!(payload.reload_runtime);
|
||||||
|
obj["message"] = json!(payload.message);
|
||||||
|
}
|
||||||
|
println!("{}", serde_json::to_string_pretty(&obj)?);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -6361,7 +6493,9 @@ fn run_resumed_session_command(
|
|||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
message: Some(text),
|
message: Some(text),
|
||||||
json: Some(serde_json::json!({
|
json: Some(serde_json::json!({
|
||||||
"kind": "session_list",
|
"kind": "sessions",
|
||||||
|
"status": "ok",
|
||||||
|
"action": "list",
|
||||||
"sessions": session_ids,
|
"sessions": session_ids,
|
||||||
"session_details": session_details_json(&sessions),
|
"session_details": session_details_json(&sessions),
|
||||||
"active": active_id,
|
"active": active_id,
|
||||||
@@ -6643,7 +6777,7 @@ fn status_json_value(
|
|||||||
"cumulative_cache_creation_input": usage.cumulative.cache_creation_input_tokens,
|
"cumulative_cache_creation_input": usage.cumulative.cache_creation_input_tokens,
|
||||||
"cumulative_cache_read_input": usage.cumulative.cache_read_input_tokens,
|
"cumulative_cache_read_input": usage.cumulative.cache_read_input_tokens,
|
||||||
"cumulative_total": usage.cumulative.total_tokens(),
|
"cumulative_total": usage.cumulative.total_tokens(),
|
||||||
"estimated_cost_usd": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()),
|
"estimated_cost_usd": format_usd(usage.cumulative.estimate_cost_usd().total_cost_usd()), "estimated_cost_usd_num": usage.cumulative.estimate_cost_usd().total_cost_usd(),
|
||||||
"pricing": "estimated-default",
|
"pricing": "estimated-default",
|
||||||
"estimated_tokens": usage.estimated_tokens,
|
"estimated_tokens": usage.estimated_tokens,
|
||||||
},
|
},
|
||||||
@@ -6959,8 +7093,23 @@ fn print_sandbox_status_snapshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
|
||||||
|
// Derive top-level status so automation can do a single field check
|
||||||
|
// instead of combining enabled/active/supported booleans.
|
||||||
|
// ok = not enabled (not requested), OR enabled and active
|
||||||
|
// warn = enabled and supported but not yet active (degraded)
|
||||||
|
// error = enabled but unsupported on this platform
|
||||||
|
let top_status = if !status.enabled {
|
||||||
|
"ok"
|
||||||
|
} else if status.active {
|
||||||
|
"ok"
|
||||||
|
} else if status.supported {
|
||||||
|
"warn"
|
||||||
|
} else {
|
||||||
|
"error"
|
||||||
|
};
|
||||||
json!({
|
json!({
|
||||||
"kind": "sandbox",
|
"kind": "sandbox",
|
||||||
|
"status": top_status,
|
||||||
"enabled": status.enabled,
|
"enabled": status.enabled,
|
||||||
"active": status.active,
|
"active": status.active,
|
||||||
"supported": status.supported,
|
"supported": status.supported,
|
||||||
@@ -7082,6 +7231,7 @@ fn local_help_topic_command(topic: LocalHelpTopic) -> &'static str {
|
|||||||
fn render_export_help_json() -> serde_json::Value {
|
fn render_export_help_json() -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
"kind": "help",
|
"kind": "help",
|
||||||
|
"status": "ok",
|
||||||
"topic": "export",
|
"topic": "export",
|
||||||
"command": "export",
|
"command": "export",
|
||||||
"usage": "claw export [--session <id|latest>] [--output <path>] [--output-format <format>]",
|
"usage": "claw export [--session <id|latest>] [--output <path>] [--output-format <format>]",
|
||||||
@@ -7129,6 +7279,7 @@ fn render_help_topic_json(topic: LocalHelpTopic) -> serde_json::Value {
|
|||||||
|
|
||||||
json!({
|
json!({
|
||||||
"kind": "help",
|
"kind": "help",
|
||||||
|
"status": "ok",
|
||||||
"topic": local_help_topic_command(topic),
|
"topic": local_help_topic_command(topic),
|
||||||
"command": local_help_topic_command(topic),
|
"command": local_help_topic_command(topic),
|
||||||
"message": render_help_topic(topic),
|
"message": render_help_topic(topic),
|
||||||
@@ -7321,6 +7472,7 @@ fn render_config_json(
|
|||||||
|
|
||||||
let base = serde_json::json!({
|
let base = serde_json::json!({
|
||||||
"kind": "config",
|
"kind": "config",
|
||||||
|
"status": "ok",
|
||||||
"cwd": cwd.display().to_string(),
|
"cwd": cwd.display().to_string(),
|
||||||
"loaded_files": loaded_paths.len(),
|
"loaded_files": loaded_paths.len(),
|
||||||
"merged_keys": runtime_config.merged().len(),
|
"merged_keys": runtime_config.merged().len(),
|
||||||
@@ -7339,6 +7491,8 @@ fn render_config_json(
|
|||||||
other => {
|
other => {
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"kind": "config",
|
"kind": "config",
|
||||||
|
"status": "error",
|
||||||
|
"error_kind": "unsupported_config_section",
|
||||||
"section": other,
|
"section": other,
|
||||||
"ok": false,
|
"ok": false,
|
||||||
"error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."),
|
"error": format!("Unsupported config section '{other}'. Use env, hooks, model, or plugins."),
|
||||||
@@ -7451,8 +7605,12 @@ fn run_init(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Er
|
|||||||
/// string so claws can detect per-artifact state without substring matching.
|
/// string so claws can detect per-artifact state without substring matching.
|
||||||
fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value {
|
fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value {
|
||||||
use crate::init::InitStatus;
|
use crate::init::InitStatus;
|
||||||
|
// Derive top-level status: "ok" when all artifacts succeeded (created or
|
||||||
|
// skipped = idempotent); no failure path exists today so always "ok".
|
||||||
|
let status = "ok";
|
||||||
json!({
|
json!({
|
||||||
"kind": "init",
|
"kind": "init",
|
||||||
|
"status": status,
|
||||||
"project_path": report.project_root.display().to_string(),
|
"project_path": report.project_root.display().to_string(),
|
||||||
"created": report.artifacts_with_status(InitStatus::Created),
|
"created": report.artifacts_with_status(InitStatus::Created),
|
||||||
"updated": report.artifacts_with_status(InitStatus::Updated),
|
"updated": report.artifacts_with_status(InitStatus::Updated),
|
||||||
@@ -7522,6 +7680,7 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
|
|||||||
if !in_git_repo {
|
if !in_git_repo {
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"kind": "diff",
|
"kind": "diff",
|
||||||
|
"status": "error",
|
||||||
"result": "no_git_repo",
|
"result": "no_git_repo",
|
||||||
"detail": format!("{} is not inside a git project", cwd.display()),
|
"detail": format!("{} is not inside a git project", cwd.display()),
|
||||||
}));
|
}));
|
||||||
@@ -7530,6 +7689,7 @@ fn render_diff_json_for(cwd: &Path) -> Result<serde_json::Value, Box<dyn std::er
|
|||||||
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
let unstaged = run_git_diff_command_in(cwd, &["diff"])?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"kind": "diff",
|
"kind": "diff",
|
||||||
|
"status": "ok",
|
||||||
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
|
"result": if staged.trim().is_empty() && unstaged.trim().is_empty() { "clean" } else { "changes" },
|
||||||
"staged": staged.trim(),
|
"staged": staged.trim(),
|
||||||
"unstaged": unstaged.trim(),
|
"unstaged": unstaged.trim(),
|
||||||
@@ -8052,6 +8212,7 @@ fn run_export(
|
|||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"kind": "export",
|
"kind": "export",
|
||||||
|
"status": "ok",
|
||||||
"message": report,
|
"message": report,
|
||||||
"session_id": handle.id,
|
"session_id": handle.id,
|
||||||
"file": path.display().to_string(),
|
"file": path.display().to_string(),
|
||||||
@@ -8073,6 +8234,7 @@ fn run_export(
|
|||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"kind": "export",
|
"kind": "export",
|
||||||
|
"status": "ok",
|
||||||
"session_id": handle.id,
|
"session_id": handle.id,
|
||||||
"file": handle.path.display().to_string(),
|
"file": handle.path.display().to_string(),
|
||||||
"messages": session.messages.len(),
|
"messages": session.messages.len(),
|
||||||
@@ -10443,6 +10605,7 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::
|
|||||||
"{}",
|
"{}",
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"kind": "help",
|
"kind": "help",
|
||||||
|
"status": "ok",
|
||||||
"message": message,
|
"message": message,
|
||||||
}))?
|
}))?
|
||||||
),
|
),
|
||||||
@@ -11287,6 +11450,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_allowed_tools() {
|
fn rejects_unknown_allowed_tools() {
|
||||||
|
let _env_guard = env_lock();
|
||||||
|
let _cwd_guard = cwd_guard();
|
||||||
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
|
let error = parse_args(&["--allowedTools".to_string(), "teleport".to_string()])
|
||||||
.expect_err("tool should be rejected");
|
.expect_err("tool should be rejected");
|
||||||
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
assert!(error.contains("unsupported tool in --allowedTools: teleport"));
|
||||||
@@ -11294,6 +11459,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_empty_allowed_tools_flag() {
|
fn rejects_empty_allowed_tools_flag() {
|
||||||
|
let _env_guard = env_lock();
|
||||||
|
let _cwd_guard = cwd_guard();
|
||||||
for raw in ["", ",,"] {
|
for raw in ["", ",,"] {
|
||||||
let error = parse_args(&["--allowedTools".to_string(), raw.to_string()])
|
let error = parse_args(&["--allowedTools".to_string(), raw.to_string()])
|
||||||
.expect_err("empty allowedTools should be rejected");
|
.expect_err("empty allowedTools should be rejected");
|
||||||
|
|||||||
@@ -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,84 @@ 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}"
|
||||||
|
);
|
||||||
|
|
||||||
|
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 +344,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)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ fn help_emits_json_when_requested() {
|
|||||||
|
|
||||||
let parsed = assert_json_command(&root, &["--output-format", "json", "help"]);
|
let parsed = assert_json_command(&root, &["--output-format", "json", "help"]);
|
||||||
assert_eq!(parsed["kind"], "help");
|
assert_eq!(parsed["kind"], "help");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["status"], "ok",
|
||||||
|
"help JSON must have status:ok (#700)"
|
||||||
|
);
|
||||||
assert!(parsed["message"]
|
assert!(parsed["message"]
|
||||||
.as_str()
|
.as_str()
|
||||||
.expect("help text")
|
.expect("help text")
|
||||||
@@ -29,6 +33,10 @@ fn export_help_emits_bounded_json_when_requested_384() {
|
|||||||
|
|
||||||
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
|
let parsed = assert_json_command(&root, &["export", "--help", "--output-format", "json"]);
|
||||||
assert_eq!(parsed["kind"], "help");
|
assert_eq!(parsed["kind"], "help");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["status"], "ok",
|
||||||
|
"export help JSON must have status:ok (#700)"
|
||||||
|
);
|
||||||
assert_eq!(parsed["topic"], "export");
|
assert_eq!(parsed["topic"], "export");
|
||||||
assert_eq!(parsed["command"], "export");
|
assert_eq!(parsed["command"], "export");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -194,13 +202,31 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
|||||||
assert_eq!(plugins["action"], "list");
|
assert_eq!(plugins["action"], "list");
|
||||||
assert_eq!(plugins["status"], "ok");
|
assert_eq!(plugins["status"], "ok");
|
||||||
assert!(plugins["config_load_error"].is_null());
|
assert!(plugins["config_load_error"].is_null());
|
||||||
|
// reload_runtime and target are operation-result fields; list response omits them (#703)
|
||||||
assert!(
|
assert!(
|
||||||
plugins["reload_runtime"].is_boolean(),
|
!plugins
|
||||||
"plugins reload_runtime should be a boolean"
|
.as_object()
|
||||||
|
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||||
|
"plugins list should not include reload_runtime"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
plugins["target"].is_null(),
|
!plugins
|
||||||
"plugins target should be null when no plugin is targeted"
|
.as_object()
|
||||||
|
.map_or(false, |o| o.contains_key("target")),
|
||||||
|
"plugins list should not include target"
|
||||||
|
);
|
||||||
|
// #703: structured summary replaces prose message
|
||||||
|
assert!(
|
||||||
|
plugins["summary"]["total"].is_number(),
|
||||||
|
"plugins list should have summary.total"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plugins["summary"]["enabled"].is_number(),
|
||||||
|
"plugins list should have summary.enabled"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plugins["summary"]["disabled"].is_number(),
|
||||||
|
"plugins list should have summary.disabled"
|
||||||
);
|
);
|
||||||
assert_eq!(plugins["status"], "ok");
|
assert_eq!(plugins["status"], "ok");
|
||||||
let plugin_entries = plugins["plugins"].as_array().expect("plugins array");
|
let plugin_entries = plugins["plugins"].as_array().expect("plugins array");
|
||||||
@@ -351,6 +377,8 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
|
|||||||
assert_eq!(parsed["summary"]["shadowed"], 1);
|
assert_eq!(parsed["summary"]["shadowed"], 1);
|
||||||
assert_eq!(parsed["agents"][0]["name"], "planner");
|
assert_eq!(parsed["agents"][0]["name"], "planner");
|
||||||
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
|
assert_eq!(parsed["agents"][0]["source"]["id"], "project_claw");
|
||||||
|
assert_eq!(parsed["agents"][0]["source"]["label"], "Project roots");
|
||||||
|
assert_eq!(parsed["agents"][0]["source"]["detail_label"], Value::Null);
|
||||||
assert_eq!(parsed["agents"][0]["active"], true);
|
assert_eq!(parsed["agents"][0]["active"], true);
|
||||||
assert_eq!(parsed["agents"][1]["name"], "verifier");
|
assert_eq!(parsed["agents"][1]["name"], "verifier");
|
||||||
assert_eq!(parsed["agents"][2]["name"], "planner");
|
assert_eq!(parsed["agents"][2]["name"], "planner");
|
||||||
@@ -358,6 +386,83 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
|
|||||||
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
|
assert_eq!(parsed["agents"][2]["shadowed_by"]["id"], "project_claw");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn agents_and_skills_inventory_share_source_schema_702() {
|
||||||
|
let root = unique_temp_dir("inventory-source-schema-702");
|
||||||
|
let workspace = root.join("workspace");
|
||||||
|
let project_agents = workspace.join(".codex").join("agents");
|
||||||
|
let project_skills = workspace.join(".codex").join("skills");
|
||||||
|
let legacy_commands = workspace.join(".claude").join("commands");
|
||||||
|
let home = root.join("home");
|
||||||
|
let isolated_config = root.join("config-home");
|
||||||
|
let isolated_codex = root.join("codex-home");
|
||||||
|
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||||
|
fs::create_dir_all(&home).expect("home should exist");
|
||||||
|
|
||||||
|
write_agent(
|
||||||
|
&project_agents,
|
||||||
|
"planner",
|
||||||
|
"Project planner",
|
||||||
|
"gpt-5.4",
|
||||||
|
"medium",
|
||||||
|
);
|
||||||
|
write_skill(&project_skills, "plan", "Project planning guidance");
|
||||||
|
write_legacy_command(&legacy_commands, "deploy", "Legacy deployment guidance");
|
||||||
|
|
||||||
|
let envs = [
|
||||||
|
("HOME", 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 =
|
||||||
|
assert_json_command_with_env(&workspace, &["--output-format", "json", "agents"], &envs);
|
||||||
|
let skills =
|
||||||
|
assert_json_command_with_env(&workspace, &["--output-format", "json", "skills"], &envs);
|
||||||
|
|
||||||
|
let agent_source = &agents["agents"][0]["source"];
|
||||||
|
let skill_source = &skills["skills"][0]["source"];
|
||||||
|
for source in [agent_source, skill_source] {
|
||||||
|
assert!(
|
||||||
|
source.get("id").is_some(),
|
||||||
|
"inventory source must expose id: {source}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source.get("label").is_some(),
|
||||||
|
"inventory source must expose label: {source}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
source.get("detail_label").is_some(),
|
||||||
|
"inventory source must expose detail_label for a stable cross-resource path: {source}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(agent_source["id"], "project_claw");
|
||||||
|
assert_eq!(agent_source["label"], "Project roots");
|
||||||
|
assert_eq!(agent_source["detail_label"], Value::Null);
|
||||||
|
assert_eq!(skill_source["id"], "project_claw");
|
||||||
|
assert_eq!(skill_source["label"], "Project roots");
|
||||||
|
assert_eq!(skill_source["detail_label"], Value::Null);
|
||||||
|
|
||||||
|
let legacy_skill = skills["skills"]
|
||||||
|
.as_array()
|
||||||
|
.expect("skills array")
|
||||||
|
.iter()
|
||||||
|
.find(|skill| skill["name"] == "deploy")
|
||||||
|
.expect("legacy command skill should be listed");
|
||||||
|
assert_eq!(legacy_skill["source"]["id"], "project_claw");
|
||||||
|
assert_eq!(legacy_skill["source"]["label"], "Project roots");
|
||||||
|
assert_eq!(legacy_skill["source"]["detail_label"], "legacy /commands");
|
||||||
|
assert_eq!(
|
||||||
|
legacy_skill["origin"]["id"], "legacy_commands_dir",
|
||||||
|
"legacy origin stays for compatibility while generic parsers use source"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
||||||
@@ -365,6 +470,10 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
|||||||
|
|
||||||
let plan = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"]);
|
let plan = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"]);
|
||||||
assert_eq!(plan["kind"], "bootstrap-plan");
|
assert_eq!(plan["kind"], "bootstrap-plan");
|
||||||
|
assert_eq!(
|
||||||
|
plan["status"], "ok",
|
||||||
|
"bootstrap-plan JSON must have status:ok (#458)"
|
||||||
|
);
|
||||||
assert!(plan["phases"].as_array().expect("phases").len() > 1);
|
assert!(plan["phases"].as_array().expect("phases").len() > 1);
|
||||||
|
|
||||||
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
|
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
|
||||||
@@ -427,6 +536,12 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
|||||||
assert!(check["status"].as_str().is_some());
|
assert!(check["status"].as_str().is_some());
|
||||||
assert!(check["summary"].as_str().is_some());
|
assert!(check["summary"].as_str().is_some());
|
||||||
assert!(check["details"].is_array());
|
assert!(check["details"].is_array());
|
||||||
|
// #704: each check must have a stable snake_case id
|
||||||
|
assert!(
|
||||||
|
check["id"].as_str().is_some(),
|
||||||
|
"doctor check missing stable id field: {:?}",
|
||||||
|
check["name"]
|
||||||
|
);
|
||||||
check["name"].as_str().expect("doctor check name")
|
check["name"].as_str().expect("doctor check name")
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -600,13 +715,22 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
|||||||
assert_eq!(plugins["action"], "list");
|
assert_eq!(plugins["action"], "list");
|
||||||
assert_eq!(plugins["status"], "ok");
|
assert_eq!(plugins["status"], "ok");
|
||||||
assert!(plugins["config_load_error"].is_null());
|
assert!(plugins["config_load_error"].is_null());
|
||||||
|
// reload_runtime and target are operation-result fields; list response omits them (#703)
|
||||||
assert!(
|
assert!(
|
||||||
plugins["reload_runtime"].is_boolean(),
|
!plugins
|
||||||
"plugins reload_runtime should be a boolean"
|
.as_object()
|
||||||
|
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||||
|
"plugins list should not include reload_runtime"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
plugins["target"].is_null(),
|
!plugins
|
||||||
"plugins target should be null when no plugin is targeted"
|
.as_object()
|
||||||
|
.map_or(false, |o| o.contains_key("target")),
|
||||||
|
"plugins list should not include target"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
plugins["summary"]["total"].is_number(),
|
||||||
|
"plugins list should have summary.total"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,6 +1017,25 @@ fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasonin
|
|||||||
.expect("agent fixture should write");
|
.expect("agent fixture should write");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_skill(root: &Path, name: &str, description: &str) {
|
||||||
|
let skill_root = root.join(name);
|
||||||
|
fs::create_dir_all(&skill_root).expect("skill root should exist");
|
||||||
|
fs::write(
|
||||||
|
skill_root.join("SKILL.md"),
|
||||||
|
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
|
||||||
|
)
|
||||||
|
.expect("skill fixture should write");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_legacy_command(root: &Path, name: &str, description: &str) {
|
||||||
|
fs::create_dir_all(root).expect("legacy command root should exist");
|
||||||
|
fs::write(
|
||||||
|
root.join(format!("{name}.md")),
|
||||||
|
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
|
||||||
|
)
|
||||||
|
.expect("legacy command fixture should write");
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -904,3 +1047,87 @@ fn unique_temp_dir(label: &str) -> PathBuf {
|
|||||||
std::process::id()
|
std::process::id()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_json_has_status_and_result_field_702() {
|
||||||
|
// #458/#702: `claw diff --output-format json` must have status ∈ {ok,error}
|
||||||
|
// and a `result` field to distinguish clean/changes/no-repo states.
|
||||||
|
let root = unique_temp_dir("diff-json-status");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
// In a non-git directory, diff should report status:ok + result:no_git_repo
|
||||||
|
// or status:error; in a git repo it should report ok + result:clean|changes.
|
||||||
|
// We only assert the shape, not the value, to avoid flakiness.
|
||||||
|
let parsed = assert_json_command(&root, &["--output-format", "json", "diff"]);
|
||||||
|
assert_eq!(
|
||||||
|
parsed["kind"], "diff",
|
||||||
|
"diff JSON must have kind:diff (#458)"
|
||||||
|
);
|
||||||
|
let status = parsed["status"]
|
||||||
|
.as_str()
|
||||||
|
.expect("diff JSON must have status field (#458/#702)");
|
||||||
|
assert!(
|
||||||
|
matches!(status, "ok" | "error"),
|
||||||
|
"diff status must be ok or error, got {status:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed.get("result").is_some(),
|
||||||
|
"diff JSON must have result field"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_json_has_kind_702() {
|
||||||
|
// #458/#702: `claw export --output-format json` must emit kind:export.
|
||||||
|
// We check only the kind field to avoid flakiness from session-store state.
|
||||||
|
// A success path with an actual session would also carry status:ok.
|
||||||
|
let root = unique_temp_dir("export-json-kind");
|
||||||
|
fs::create_dir_all(&root).expect("temp dir should exist");
|
||||||
|
|
||||||
|
// Run without asserting exit code — may fail with no sessions or legacy sessions.
|
||||||
|
use std::process::Command;
|
||||||
|
let bin = env!("CARGO_BIN_EXE_claw");
|
||||||
|
let output = Command::new(bin)
|
||||||
|
.current_dir(&root)
|
||||||
|
.args(["--output-format", "json", "export"])
|
||||||
|
.env("ANTHROPIC_API_KEY", "test")
|
||||||
|
.output()
|
||||||
|
.expect("claw binary should run");
|
||||||
|
|
||||||
|
// On success stdout has kind:export; on failure stderr has type:error.
|
||||||
|
// Either way, both envelopes must be valid JSON.
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr)
|
||||||
|
.lines()
|
||||||
|
.filter(|l| l.starts_with('{'))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&stdout).expect("export success stdout must be valid JSON");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["kind"], "export",
|
||||||
|
"export JSON must have kind:export (#458)"
|
||||||
|
);
|
||||||
|
let status = parsed["status"]
|
||||||
|
.as_str()
|
||||||
|
.expect("export JSON must have status");
|
||||||
|
assert!(
|
||||||
|
matches!(status, "ok" | "error"),
|
||||||
|
"export status must be ok or error"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Error envelope on stderr must be parseable JSON.
|
||||||
|
assert!(
|
||||||
|
!stderr.is_empty(),
|
||||||
|
"export failure must emit JSON to stderr"
|
||||||
|
);
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(&stderr).expect("export error stderr must be valid JSON");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["type"], "error",
|
||||||
|
"export error envelope must have type:error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user