mirror of
https://github.com/instructkr/claude-code.git
synced 2026-05-28 16:36:45 +00:00
Compare commits
75 Commits
docs/roadm
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd12e49f2f | ||
|
|
49d5b3fcdc | ||
|
|
25ee5f3d30 | ||
|
|
922c239863 | ||
|
|
d8a6109085 | ||
|
|
6e44da10fe | ||
|
|
02d1f6a04d | ||
|
|
fe2b13a46a | ||
|
|
92539cad68 | ||
|
|
556a598f2d | ||
|
|
8d80f2ffe7 | ||
|
|
8280f66aa1 | ||
|
|
a0b375c157 | ||
|
|
6a007344ae | ||
|
|
920d5c6c3a | ||
|
|
98f8926998 | ||
|
|
76c8d4801e | ||
|
|
4b8731ba11 | ||
|
|
789ea9aac8 | ||
|
|
590b5b614c | ||
|
|
45dc4f6ff0 | ||
|
|
7037d84d52 | ||
|
|
7d6b2044d5 | ||
|
|
fdde5e45cf | ||
|
|
bae0099c7c | ||
|
|
42c17bc4bf | ||
|
|
f8a901c2a5 | ||
|
|
a30624d6d4 | ||
|
|
8f8eb41e0f | ||
|
|
47c0226a61 | ||
|
|
26a50d918b | ||
|
|
401f6b152c | ||
|
|
1b5a9b02c2 | ||
|
|
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 |
31
.github/hooks/pre-push
vendored
Executable file
31
.github/hooks/pre-push
vendored
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# Claw Code local pre-push safety gate.
|
||||
#
|
||||
# Install with:
|
||||
# git config core.hooksPath .github/hooks
|
||||
#
|
||||
# This intentionally mirrors the CI build gate so stale field/enum references are
|
||||
# caught before pushing to main or PR branches.
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel 2>/dev/null)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ -x scripts/roadmap-check-ids.sh ]]; then
|
||||
echo "pre-push: scripts/roadmap-check-ids.sh" >&2
|
||||
scripts/roadmap-check-ids.sh
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_CLAW_PRE_PUSH_BUILD:-}" == "1" ]]; then
|
||||
echo "pre-push: SKIP_CLAW_PRE_PUSH_BUILD=1 set; skipping cargo workspace build" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f rust/Cargo.toml ]]; then
|
||||
echo "pre-push: rust/Cargo.toml not found; skipping cargo workspace build" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)
|
||||
echo "pre-push: ${build_cmd[*]}" >&2
|
||||
"${build_cmd[@]}"
|
||||
8
.github/workflows/rust-ci.yml
vendored
8
.github/workflows/rust-ci.yml
vendored
@@ -21,6 +21,8 @@ on:
|
||||
- PARITY.md
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
pull_request:
|
||||
@@ -41,6 +43,8 @@ on:
|
||||
- PARITY.md
|
||||
- PHILOSOPHY.md
|
||||
- ROADMAP.md
|
||||
- scripts/roadmap-*.sh
|
||||
- tests/test_roadmap_helpers.py
|
||||
- docs/**
|
||||
- rust/**
|
||||
workflow_dispatch:
|
||||
@@ -72,6 +76,10 @@ jobs:
|
||||
run: python .github/scripts/check_doc_source_of_truth.py
|
||||
- name: Check release policy docs and local links
|
||||
run: python .github/scripts/check_release_readiness.py
|
||||
- name: Check ROADMAP ids
|
||||
run: scripts/roadmap-check-ids.sh
|
||||
- name: Check ROADMAP helper behavior
|
||||
run: python -m unittest discover -s tests -p test_roadmap_helpers.py
|
||||
|
||||
fmt:
|
||||
name: cargo fmt
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"duplicate_roadmap_heading_lines": [],
|
||||
"roadmap_actions_mapped": 542,
|
||||
"roadmap_actions_total": 542,
|
||||
"roadmap_headings_mapped": 124,
|
||||
"roadmap_headings_total": 124,
|
||||
"roadmap_headings_mapped": 127,
|
||||
"roadmap_headings_total": 127,
|
||||
"unmapped_roadmap_heading_lines": []
|
||||
},
|
||||
"generated_at": "2026-05-14T08:13:45+00:00",
|
||||
"generated_at": "2026-05-25T04:30:33+00:00",
|
||||
"generation_policy": {
|
||||
"release_buckets": [
|
||||
"2.x_intake",
|
||||
@@ -14823,6 +14823,69 @@
|
||||
"status": "context",
|
||||
"title": "Parity source metadata: openai/codex",
|
||||
"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",
|
||||
@@ -14839,7 +14902,7 @@
|
||||
"root": "/Users/bellman/Documents/Workspace/claw-code/.omx/research"
|
||||
},
|
||||
"roadmap": {
|
||||
"heading_count": 124,
|
||||
"heading_count": 127,
|
||||
"ordered_action_count": 542,
|
||||
"path": "ROADMAP.md",
|
||||
"sha256_prefix": "2aba3315e52f3079"
|
||||
@@ -14850,15 +14913,15 @@
|
||||
"adoption_overlay": 357,
|
||||
"parity_overlay": 20,
|
||||
"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_3_branch_test_recovery": 16,
|
||||
"stream_3_branch_test_recovery": 17,
|
||||
"stream_4_claws_first_execution": 5,
|
||||
"stream_5_plugin_mcp_lifecycle": 22
|
||||
},
|
||||
"by_release_bucket": {
|
||||
"2.x_intake": 30,
|
||||
"alpha_blocker": 240,
|
||||
"alpha_blocker": 243,
|
||||
"beta_adoption": 417,
|
||||
"context": 15,
|
||||
"ga_ecosystem": 22,
|
||||
@@ -14870,13 +14933,13 @@
|
||||
"latest_open_issue": 30,
|
||||
"parity_repo_context": 2,
|
||||
"roadmap_action": 542,
|
||||
"roadmap_heading": 124
|
||||
"roadmap_heading": 127
|
||||
},
|
||||
"by_status": {
|
||||
"active": 73,
|
||||
"context": 15,
|
||||
"deferred_with_rationale": 9,
|
||||
"done_verify": 313,
|
||||
"done_verify": 316,
|
||||
"open": 285,
|
||||
"rejected_not_claw": 2,
|
||||
"stale_done": 31,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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`
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| 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 |
|
||||
| --- | --- | --- | --- |
|
||||
| ROADMAP headings | 124 | 124 | PASS |
|
||||
| ROADMAP headings | 127 | 127 | PASS |
|
||||
| ROADMAP ordered actions | 542 | 542 | PASS |
|
||||
| Duplicate heading lines | 0 | 0 | PASS |
|
||||
|
||||
Total canonical board items: **729**
|
||||
Total canonical board items: **732**
|
||||
|
||||
## 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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -40,7 +40,7 @@ Total canonical board items: **729**
|
||||
| Bucket | Count | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `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. |
|
||||
| `context` | 15 | Non-actionable roadmap context. |
|
||||
| `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 |
|
||||
| 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 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 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 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 |
|
||||
| `parity_repo_context` | 2 |
|
||||
| `roadmap_action` | 542 |
|
||||
| `roadmap_heading` | 124 |
|
||||
| `roadmap_heading` | 127 |
|
||||
|
||||
## 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-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-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
|
||||
|
||||
@@ -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-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-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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"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",
|
||||
"goalsPath": ".omx/ultragoal/goals.json",
|
||||
"ledgerPath": ".omx/ultragoal/ledger.jsonl",
|
||||
@@ -148,7 +148,19 @@
|
||||
"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.",
|
||||
"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
@@ -33,6 +33,41 @@ cargo build --workspace
|
||||
.\target\debug\claw.exe --help
|
||||
```
|
||||
|
||||
## Local pre-push build gate
|
||||
|
||||
Install the repository-local hook to catch stale compile errors before pushing:
|
||||
|
||||
```bash
|
||||
git config core.hooksPath .github/hooks
|
||||
```
|
||||
|
||||
This sets the repo's Git hook directory to `.github/hooks`; if you already use a
|
||||
custom `core.hooksPath`, copy or chain `.github/hooks/pre-push` instead. The hook
|
||||
runs the ROADMAP id guard, then runs
|
||||
`cargo build --manifest-path rust/Cargo.toml --workspace --locked` from the
|
||||
repository root. If you must bypass the cargo build for a docs-only push, set
|
||||
`SKIP_CLAW_PRE_PUSH_BUILD=1`; the hook still runs the ROADMAP guard and prints
|
||||
when the cargo-build escape hatch is used.
|
||||
|
||||
## ROADMAP id allocation
|
||||
|
||||
Before appending a new numeric ROADMAP entry, pull/rebase onto the latest
|
||||
`main`, allocate the id from the file you are about to edit, and run the duplicate
|
||||
id guard before pushing:
|
||||
|
||||
```bash
|
||||
git pull --rebase
|
||||
NEXT=$(scripts/roadmap-next-id.sh)
|
||||
# append "${NEXT}. **...**" to ROADMAP.md
|
||||
scripts/roadmap-check-ids.sh
|
||||
```
|
||||
|
||||
The duplicate guard currently checks helper-era ids (`>=723`) by default so it
|
||||
catches new optimistic-append collisions without failing on legacy numbered lists
|
||||
already present in the historical roadmap. Use `scripts/roadmap-check-ids.sh
|
||||
--min-id 1` for a strict whole-file audit after those legacy collisions are
|
||||
cleaned up.
|
||||
|
||||
## Checks before opening a pull request
|
||||
|
||||
Run the smallest relevant tests for your change, then the broader checks when
|
||||
|
||||
64
ROADMAP.md
64
ROADMAP.md
@@ -7555,3 +7555,67 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed)
|
||||
||||||| 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.
|
||||
|
||||
707. **`init.rs` test `temp_dir()` uses nanoseconds only — two parallel test runs in the same process can land in the same nanosecond window and collide on the same temp path, causing intermittent test failures** — dogfooded 2026-05-26 on `dedad14a`. `artifacts_with_status_partitions_fresh_and_idempotent_runs` was seen flaking in full-suite parallel runs but passing in isolation. Root cause: `temp_dir()` at `init.rs:383` used only `SystemTime::now().as_nanos()` as the uniqueness token. Two concurrent callers within the same nanosecond window produce the same path. Fix: added `AtomicU64` counter combined with nanoseconds → `rusty-claude-init-{nanos}-{id}` eliminates same-process collisions. Source: Jobdori dogfood on `dedad14a`, 2026-05-26.
|
||||
|
||||
708. **`claw skills show <name> --output-format json` returns `action:"list"` even on the show path — `render_skills_report_json` hardcoded `"action":"list"` for all skill responses including show/info/describe** — dogfooded 2026-05-26 on `26a50d91`. `claw skills show korea-weather --output-format json` returned `{action:"list"}` even when the show path was taken. Also had duplicate `"status":"ok"` key in the JSON object. Fix: renamed `render_skills_report_json` to `render_skills_report_json_with_action(skills, action)` and updated all call sites to pass `"list"` or `"show"` appropriately; removed duplicate status key. Source: Jobdori dogfood on `26a50d91`, 2026-05-26.
|
||||
|
||||
709. **`render_agents_report_json` and `render_skill_install_report_json` in `commands/src/lib.rs` contained duplicate `"status":"ok"` keys in the same JSON object literal — second key silently overwrites first in serde_json** — found during #708 dogfood sweep on `47c0226a`. Rust `serde_json::json!` macros accept duplicate keys but `serde_json` silently keeps the last occurrence; any consumer relying on the first occurrence gets the wrong value if they are ever different. Fixed by removing the duplicate `status` key from `render_agents_report_json` and `render_skill_install_report_json`. Source: Jobdori dogfood on `47c0226a`, 2026-05-26.
|
||||
|
||||
710. **`claw diff --output-format json` missing `action` and `working_directory` fields — both the ok and error paths in `render_diff_json_for` omitted `action` entirely (returning `null` at parse time) and omitted `working_directory`** — dogfooded 2026-05-26 on `8f8eb41e`. Both the clean/changes success path and the no-git-repo error path were missing the two envelope fields that automation uses for routing and provenance. Fix: added `action:"diff"` and `working_directory: cwd.display()` to both branches of `render_diff_json_for`; added contract-test assertions for both fields. Source: Jobdori dogfood on `8f8eb41e`, 2026-05-26.
|
||||
|
||||
711. **`version`, `system-prompt`, `export`, and `init` `--output-format json` responses all lacked an `action` field — returned `null` or omitted entirely** — dogfooded 2026-05-26 on `42c17bc4`. All four commands emitted `kind` + `status` but no `action`, making them inconsistent with all other JSON surfaces that include the verb. Fix: added `action:"show"` to `version` and `system-prompt`, `action:"export"` to all three `export` paths, `action:"init"` to `init`; added contract-test assertions for each. Source: Jobdori dogfood on `42c17bc4`, 2026-05-26.
|
||||
|
||||
712. **`doctor`, `status`, `bootstrap-plan`, and `dump-manifests` `--output-format json` responses missing `action` field — consistent with batch of missing-action fixes in #710/#711** — dogfooded 2026-05-26 on `bae0099c`. `doctor` returned no `action`; `status` and `bootstrap-plan` same. `dump-manifests` had empty string. Fix: added `action:"doctor"` to doctor JSON, `action:"show"` to status and bootstrap-plan, `action:"dump"` to dump-manifests happy path. Source: Jobdori dogfood on `bae0099c`, 2026-05-26.
|
||||
|
||||
713. **`acp` and `config` (bare and section-show) `--output-format json` responses missing `action` field — continues sweep from #710–#712** — dogfooded 2026-05-26 on `fdde5e45`. `acp` had no action; `config` bare had no action; `config <section>` and the unknown-section error path both had no action. Fix: added `action:"status"` to acp, `action:"list"` to config bare, `action:"show"` to config section-show and unknown-section error path. Source: Jobdori dogfood on `fdde5e45`, 2026-05-26.
|
||||
|
||||
714. **`help --output-format json` missing `action` field; resume `/help` JSON path also missing `action` and `status`** — dogfooded 2026-05-26 on `7d6b2044`. Top-level `claw help --output-format json` returned `{kind:"help", status:"ok"}` with no `action`. The `render_export_help_json` and `render_help_topic_json` resume-path helpers were also missing `action`. The resume REPL help JSON object had neither `action` nor `status`. Fix: added `action:"help"` (and `status:"ok"` where missing) to all 4 help JSON sites. Source: Jobdori dogfood on `7d6b2044`, 2026-05-26.
|
||||
|
||||
715. **Resume-path slash commands (`/compact`, `/clear`, `/cost`, `/stats`, `/history`, `/session exists`, `/session delete`, `memory`) JSON responses missing `action` and `status` fields** — dogfooded 2026-05-26 on `590b5b61`. The `assert_non_empty_action` guardrail added by #3109 only covers `assert_json_command` (top-level CLI surfaces); resume-path commands that emit JSON via `ResumeCommandOutcome.json` were not covered. 8 resume-path JSON sites all lacked `action` and `status`. Fix: added `action` + `status:"ok"` to `compact`, `clear`, `cost`, `stats`, `history`, `session_exists`, `session_delete`, `memory`, and `restored`. Source: Jobdori dogfood on `590b5b61`, 2026-05-26.
|
||||
|
||||
716. **Resume-path error JSON used legacy `{type:"error", error:...}` shape instead of standard `{kind, action, status:"error", error_kind, exit_code}` envelope — 5 error paths affected** — dogfooded 2026-05-26 on `76c8d480`. Session load failure, unsupported command, unsupported resumed command, SlashCommand parse error, and broad-cwd abort all emitted the old two-key shape. Fix: aligned all 5 to `{kind, action:"resume"|"abort", status:"error", error_kind, error, exit_code}`. Updated `resumed_stub_command_emits_not_implemented_json` test to assert `status:"error"` + `kind:"unsupported_command"`. Source: Jobdori dogfood on `76c8d480`, 2026-05-26.
|
||||
|
||||
717. **`claw agents show <name>` missing — `handle_agents_slash_command_json` only accepted `list`; `show/info/describe` was unimplemented unlike skills which had parity** — dogfooded 2026-05-26 on `6a007344`. `claw agents show claw-code --output-format json` returned `unknown_agents_subcommand` error. Fix: added `show/info/describe` and `list <filter>` arms to both `handle_agents_slash_command` and `handle_agents_slash_command_json`, mirroring the skills handler; renamed `render_agents_report_json` → `render_agents_report_json_with_action`; not-found path returns `{kind:"agents", action:"show", status:"error", error_kind:"agent_not_found", requested:"<name>"}` + Ok; added `classify_error_kind` branch for `agent_not_found`. Updated 2 tests. Source: Jobdori dogfood on `6a007344`, 2026-05-26.
|
||||
|
||||
718. **`claw plugins show <name>` unimplemented — unlike `agents show` and `skills show`, `/plugins show` returned `unknown_plugins_action` error** — dogfooded 2026-05-26 on `8d80f2ff`. Fix: added `show/info/describe` arm to `handle_plugins_slash_command` that filters installed plugins by name; `print_plugins` JSON path filters `payload.plugins` when action is `show/info/describe` and emits `{kind:"plugin", action:"show", status:"error", error_kind:"plugin_not_found", requested:"<name>"}` for missing names. Updated error message in catch-all to name `show` as supported. Source: Jobdori dogfood on `8d80f2ff`, 2026-05-26.
|
||||
|
||||
719. **`plugins list <filter>` silently returned all plugins instead of filtering — unlike `agents list <filter>` and `skills list <filter>` which do substring filter** — dogfooded 2026-05-26 on `556a598f`. `claw plugins list nonexistent-filter-xyz --output-format json` returned both installed plugins. Fix: `handle_plugins_slash_command` `list` arm now treats `target` as a substring filter; `print_plugins` JSON path applies `target` filter for `list` action. Source: Jobdori dogfood on `556a598f`, 2026-05-26.
|
||||
|
||||
720. **`claw help agents` (and `help skills|plugins|mcp|config|diff|sandbox|doctor|etc.`) errored with `cli_parse: unrecognized argument` instead of routing to the subsystem's help** — dogfooded 2026-05-26 on `fe2b13a4`. `claw help agents --output-format json` returned `{status:"error", error_kind:"cli_parse"}`. The `is_diagnostic` guard for `"help"` verb rejected any trailing non-flag argument. Fix: when `verb == "help"` and exactly one topic argument follows, match it against all known `LocalHelpTopic` variants (including new `Agents`, `Skills`, `Plugins`, `Mcp`, `Config`, `Diff`) and route to `HelpTopic`; `agents` and `skills` delegate to their subsystem usage JSON in `print_help_topic`. Unknown topics fall through to generic `Help`. Source: Jobdori dogfood on `fe2b13a4`, 2026-05-26.
|
||||
|
||||
721. **`claw config mcp|sandbox|permissions|skills|agents` returned `{status:"error", error_kind:"unsupported_config_section"}` with error message "Use env, hooks, model, or plugins" — 5 valid config sections were not mapped in the JSON path** — dogfooded 2026-05-26 on `02d1f6a0`. `claw config agents --output-format json` → `{status:"error", error_kind:"unsupported_config_section", error:"...Use env, hooks, model, or plugins."}`. The `match section` arms in both text and JSON paths only handled `env/hooks/model/plugins`; all others fell to the error arm. Fix: added `mcp|mcp_servers|mcpServers`, `sandbox`, `permissions`, `skills`, `agents` arms to both text and JSON config section handlers. `unsupported_config_section` error envelope now includes `supported_sections:[]` array. Source: Jobdori dogfood on `02d1f6a0`, 2026-05-26.
|
||||
|
||||
722. **ROADMAP #721 re-entry after rebase conflict: `claw config mcp|sandbox|permissions|skills|agents` returned `unsupported_config_section` — code fix is in `6e44da10`** (main.rs changes preserved through rebase, only ROADMAP.md was conflict-resolved to Gaebal's version). Both text and JSON config section handlers now support `mcp`, `sandbox`, `permissions`, `skills`, `agents`; error envelope includes `supported_sections:[]`. Source: Jobdori dogfood on `02d1f6a0`, 2026-05-26.
|
||||
|
||||
723. **Concurrent dogfood claws allocate ROADMAP ids manually and collide — same id reused by two contributors simultaneously, causing PR ROADMAP.md conflicts and lost entries** — observed live 2026-05-26 during Jobdori+Gaebal parallel dogfood session: Gaebal filed stale-local-probe as #719; Jobdori landed `plugins list <filter>` as #719 on main first; Gaebal shifted to #720; Jobdori landed `claw help <topic>` as #720; stale-local-probe eventually landed as #721 after two forced rebase cycles. The ROADMAP append workflow has no reservation or conflict-aware id allocation. **Required fix shape:** (a) add `scripts/roadmap-next-id.sh` that reads the highest id from ROADMAP.md and prints `highest+1` — claws should call this immediately before appending any new entry; (b) document in CONTRIBUTING.md that id allocation is optimistic-append: call `roadmap-next-id.sh` immediately before the append, git-pull first, resolve collisions at push time by re-numbering the appended entry; (c) long-term: a GitHub Action that validates no duplicate ROADMAP ids on PR would catch this before merge. Added `scripts/roadmap-next-id.sh` (this commit). Source: Gaebal Gajae live observation, 2026-05-26.
|
||||
|
||||
724. **DONE — ROADMAP duplicate-id validation guard for helper-era append collisions** — follow-up to #723 after dogfood showed `scripts/roadmap-next-id.sh` still printed 724 and exited 0 when a temp ROADMAP copy already contained a second `723. ...` line. This PR closes the gap for new optimistic-append collisions by adding `scripts/roadmap-check-ids.sh`, wiring it into docs CI and the local pre-push hook, documenting the pre-push command in CONTRIBUTING, and mentioning the guard from `roadmap-next-id.sh`. The guard defaults to ids >=723 so current historical roadmap content and old numbered lists do not block docs-only PRs; `--min-id 1` is available for a strict whole-file audit once legacy collisions are cleaned up. **Verification:** `scripts/roadmap-check-ids.sh` passes on current ROADMAP; a temp copy with an appended duplicate `723.` fails nonzero and lists duplicate id 723 with line numbers. Source: Jobdori dogfood follow-up on origin/main `922c2398`, 2026-05-25. [SCOPE: docs/scripts]
|
||||
|
||||
725. **DONE — roadmap-next-id helper now fails closed on helper-era duplicate ids before printing a next id** — follow-up to #724 after dogfood on origin/main 25ee5f3d showed `scripts/roadmap-next-id.sh` could print `1000` and exit 0 when a temp ROADMAP copy already contained two `999.` helper-era entries. This PR makes `roadmap-next-id.sh` resolve `roadmap-check-ids.sh` by its own script directory, run the checker with default helper-era min-id semantics before computing `highest+1`, keep stdout reserved for the single next id on success, and fail closed with a useful error if the checker is unavailable. Added focused pytest coverage for clean next-id output, duplicate fail-fast behavior, and missing-checker fail-closed behavior. **Verification:** `scripts/roadmap-next-id.sh ROADMAP.md` prints `725`; `scripts/roadmap-check-ids.sh ROADMAP.md` passes; a temp ROADMAP with duplicate `999.` exits nonzero and lists duplicate id 999 without printing a next id; `bash -n scripts/roadmap-next-id.sh scripts/roadmap-check-ids.sh` passes; `python -m pytest tests/test_roadmap_helpers.py -q` passes. Source: Jobdori dogfood follow-up on origin/main 25ee5f3d. [SCOPE: docs/scripts]
|
||||
|
||||
726. **DONE — roadmap-next-id helper missing explicit ROADMAP path behavior is regression-tested** — follow-up to #725 after dogfood on origin/main 49d5b3fc showed the helper already failed correctly for `scripts/roadmap-next-id.sh /tmp/nonexistent-roadmap` but tests only covered clean next-id output, duplicate fail-fast behavior, and missing-checker fail-closed behavior. This PR adds focused unittest coverage proving a missing explicit ROADMAP path exits nonzero, keeps stdout empty, and reports both `ROADMAP not found` and the requested path on stderr. **Verification:** `python -m unittest tests.test_roadmap_helpers -q`; `scripts/roadmap-check-ids.sh ROADMAP.md`; `scripts/roadmap-next-id.sh ROADMAP.md`. Source: Jobdori dogfood follow-up on origin/main 49d5b3fc. [SCOPE: docs/scripts]
|
||||
|
||||
51
docs/g013-roadmap-pinpoints-693-695-verification-map.md
Normal file
51
docs/g013-roadmap-pinpoints-693-695-verification-map.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# G013 ROADMAP pinpoints #693-#695 verification map
|
||||
|
||||
This map records the current-head follow-up that was discovered after resetting
|
||||
`main` to `origin/main`: ROADMAP.md contained three new Pinpoint headings not
|
||||
covered by the Claw Code 2.0 board.
|
||||
|
||||
## Pinpoint #693 — typed phase error instead of silent `unknown`
|
||||
|
||||
- Code: `rust/crates/claw-analog/src/lib.rs`
|
||||
- Behavior: `format_rag_query_json_for_model` now rejects missing, empty, or
|
||||
literal `"unknown"` phase values with a structured error envelope containing
|
||||
`kind:"unknown_bootstrap_phase"`, `field:"phase"`, and `received_value`.
|
||||
- Regression tests: `rag_response_missing_phase_returns_typed_error` and
|
||||
`rag_response_unknown_phase_returns_typed_error`.
|
||||
|
||||
## Pinpoint #694 — local pre-push build gate
|
||||
|
||||
- Hook: `.github/hooks/pre-push`
|
||||
- Install command: `git config core.hooksPath .github/hooks`
|
||||
- Gate: `cargo build --manifest-path rust/Cargo.toml --workspace --locked`
|
||||
- Escape hatch: `SKIP_CLAW_PRE_PUSH_BUILD=1` prints an explicit skip message.
|
||||
- Regression test: `tests/test_pre_push_hook_contract.py` locks the skip
|
||||
hatch and `--locked` build command contract.
|
||||
- Purpose: mirror the CI build job locally so stale field/variant references are
|
||||
caught before push.
|
||||
|
||||
## Pinpoint #695 — startup/worktree preflight diagnostics
|
||||
|
||||
- Code: `rust/crates/runtime/src/worker_boot.rs`
|
||||
- Behavior: `startup_preflight_warnings` and
|
||||
`WorkerRegistry::observe_startup_preflight` emit structured warnings before
|
||||
the first model turn when a task mentions a path not tracked on the current
|
||||
branch (`file_absent_on_branch`) or git metadata is not writable
|
||||
(`git_metadata_not_writable`).
|
||||
- Regression tests:
|
||||
- `startup_preflight_warns_when_task_file_is_absent_on_branch`
|
||||
- `startup_preflight_records_structured_warning_event`
|
||||
|
||||
## Verification commands
|
||||
|
||||
```bash
|
||||
python3 scripts/generate_cc2_board.py
|
||||
python3 scripts/validate_cc2_board.py --board .omx/cc2/board.json
|
||||
python3 .omx/cc2/validate_issue_parity_intake.py .omx/cc2/issue-parity-intake.json
|
||||
bash -n .github/hooks/pre-push
|
||||
python3 tests/test_pre_push_hook_contract.py -v
|
||||
cargo fmt --manifest-path rust/Cargo.toml --all -- --check
|
||||
cargo test --manifest-path rust/Cargo.toml -p claw-analog rag_response_ -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p runtime startup_preflight -- --nocapture
|
||||
cargo build --manifest-path rust/Cargo.toml --workspace --locked
|
||||
```
|
||||
1
rust/Cargo.lock
generated
1
rust/Cargo.lock
generated
@@ -2124,6 +2124,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"telemetry",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -234,7 +234,7 @@ pub fn resolve_model_alias(model: &str) -> String {
|
||||
#[must_use]
|
||||
pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
|
||||
let canonical = resolve_model_alias(model);
|
||||
if canonical.starts_with("claude") {
|
||||
if canonical.starts_with("claude") || canonical.starts_with("anthropic/") {
|
||||
return Some(ProviderMetadata {
|
||||
provider: ProviderKind::Anthropic,
|
||||
auth_env: "ANTHROPIC_API_KEY",
|
||||
|
||||
@@ -1109,9 +1109,34 @@ enum BlockKind {
|
||||
},
|
||||
}
|
||||
|
||||
const KNOWN_RAG_BOOTSTRAP_PHASES: &[&str] =
|
||||
&["1-sqlite-no-db", "1-sqlite-empty", "1-sqlite", "2-qdrant"];
|
||||
|
||||
fn unknown_bootstrap_phase_error(received_value: Value, message: &str) -> String {
|
||||
json!({
|
||||
"kind": "unknown_bootstrap_phase",
|
||||
"field": "phase",
|
||||
"received_value": received_value,
|
||||
"allowed_values": KNOWN_RAG_BOOTSTRAP_PHASES,
|
||||
"message": message,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn format_rag_query_json_for_model(body: &str) -> Result<String, String> {
|
||||
let v: Value = serde_json::from_str(body).map_err(|e| format!("invalid JSON: {e}"))?;
|
||||
let phase = v.get("phase").and_then(|x| x.as_str()).unwrap_or("unknown");
|
||||
let phase = v.get("phase").and_then(|x| x.as_str()).ok_or_else(|| {
|
||||
unknown_bootstrap_phase_error(
|
||||
v.get("phase").cloned().unwrap_or(Value::Null),
|
||||
"RAG response is missing a string phase; refusing to silently render phase as unknown",
|
||||
)
|
||||
})?;
|
||||
if !KNOWN_RAG_BOOTSTRAP_PHASES.contains(&phase) {
|
||||
return Err(unknown_bootstrap_phase_error(
|
||||
Value::String(phase.to_string()),
|
||||
"RAG response phase is not a recognized bootstrap phase",
|
||||
));
|
||||
}
|
||||
let hits = v
|
||||
.get("hits")
|
||||
.and_then(|h| h.as_array())
|
||||
@@ -2557,6 +2582,30 @@ mod tests {
|
||||
assert!(out.contains("score="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_response_missing_phase_returns_typed_error() {
|
||||
let err = format_rag_query_json_for_model(r#"{"hits":[]}"#).unwrap_err();
|
||||
assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#));
|
||||
assert!(err.contains(r#""field":"phase""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_response_unknown_phase_returns_typed_error() {
|
||||
let err = format_rag_query_json_for_model(r#"{"hits":[],"phase":"unknown"}"#).unwrap_err();
|
||||
assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#));
|
||||
assert!(err.contains(r#""received_value":"unknown""#));
|
||||
assert!(err.contains(r#""field":"phase""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rag_response_unrecognized_phase_returns_typed_error() {
|
||||
let err =
|
||||
format_rag_query_json_for_model(r#"{"hits":[],"phase":"3-drifted"}"#).unwrap_err();
|
||||
assert!(err.contains(r#""kind":"unknown_bootstrap_phase""#));
|
||||
assert!(err.contains(r#""received_value":"3-drifted""#));
|
||||
assert!(err.contains(r#""allowed_values""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rag_base_url_toml_beats_env() {
|
||||
let _g = mock_env_lock();
|
||||
|
||||
@@ -2202,7 +2202,16 @@ pub fn handle_plugins_slash_command(
|
||||
match action {
|
||||
None | Some("list") => {
|
||||
let report = manager.installed_plugin_registry_report()?;
|
||||
let plugins = report.summaries();
|
||||
let plugins: Vec<_> = if let Some(filter) = target {
|
||||
let needle = filter.to_lowercase();
|
||||
report
|
||||
.summaries()
|
||||
.into_iter()
|
||||
.filter(|p| p.metadata.id.to_lowercase().contains(&needle))
|
||||
.collect()
|
||||
} else {
|
||||
report.summaries().into_iter().collect()
|
||||
};
|
||||
let failures = report.failures();
|
||||
Ok(PluginsCommandResult {
|
||||
message: render_plugins_report_with_failures(&plugins, failures),
|
||||
@@ -2260,7 +2269,7 @@ pub fn handle_plugins_slash_command(
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some("uninstall") => {
|
||||
Some("remove") | Some("uninstall") => {
|
||||
let Some(target) = target else {
|
||||
return Ok(PluginsCommandResult {
|
||||
message: "Usage: /plugins uninstall <plugin-id>".to_string(),
|
||||
@@ -2301,12 +2310,29 @@ pub fn handle_plugins_slash_command(
|
||||
reload_runtime: true,
|
||||
})
|
||||
}
|
||||
Some(other) => Ok(PluginsCommandResult {
|
||||
message: format!(
|
||||
"Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
|
||||
),
|
||||
reload_runtime: false,
|
||||
}),
|
||||
Some("show" | "info" | "describe") => {
|
||||
// Show a named plugin by filtering the installed registry.
|
||||
// Without a target, shows all (same as list).
|
||||
let report = manager.installed_plugin_registry_report()?;
|
||||
let plugins: Vec<_> = if let Some(name) = target {
|
||||
let needle = name.to_lowercase();
|
||||
report
|
||||
.summaries()
|
||||
.into_iter()
|
||||
.filter(|p| p.metadata.id.to_lowercase() == needle)
|
||||
.collect()
|
||||
} else {
|
||||
report.summaries().into_iter().collect()
|
||||
};
|
||||
let failures = report.failures();
|
||||
Ok(PluginsCommandResult {
|
||||
message: render_plugins_report_with_failures(&plugins, failures),
|
||||
reload_runtime: false,
|
||||
})
|
||||
}
|
||||
Some(other) => Err(PluginError::CommandFailed(format!(
|
||||
"unknown_plugins_action: '{other}' is not a supported /plugins action. Use list, show, install, enable, disable, uninstall, or update."
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2326,8 +2352,51 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report(&agents))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_agents_report(&filtered))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report(&agents))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let matched: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase() == name)
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("agent not found: {name}"),
|
||||
));
|
||||
}
|
||||
Ok(render_agents_report(&matched))
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
|
||||
Some(args) => Ok(render_agents_usage(Some(args))),
|
||||
Some(args) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown agents subcommand: {args}. Supported: list, show, help"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2347,8 +2416,54 @@ pub fn handle_agents_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report_json(cwd, &agents))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let filtered: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_agents_report_json(cwd, &filtered))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
Ok(render_agents_report_json_with_action(cwd, &agents, "show"))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
|| args.starts_with("info ")
|
||||
|| args.starts_with("describe ") =>
|
||||
{
|
||||
let name = args
|
||||
.split_once(' ')
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let roots = discover_definition_roots(cwd, "agents");
|
||||
let agents = load_agents_from_roots(&roots)?;
|
||||
let matched: Vec<_> = agents
|
||||
.into_iter()
|
||||
.filter(|a| a.name.to_lowercase() == name)
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
return Ok(serde_json::json!({
|
||||
"kind": "agents",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"error_kind": "agent_not_found",
|
||||
"requested": name,
|
||||
}));
|
||||
}
|
||||
Ok(render_agents_report_json_with_action(cwd, &matched, "show"))
|
||||
}
|
||||
Some(args) if is_help_arg(args) => Ok(render_agents_usage_json(None)),
|
||||
Some(args) => 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, show, help"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2448,7 +2563,7 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
None | Some("list") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report_json(&skills))
|
||||
Ok(render_skills_report_json_with_action(&skills, "list"))
|
||||
}
|
||||
Some(args) if args.starts_with("list ") => {
|
||||
let filter = args["list ".len()..].trim().to_lowercase();
|
||||
@@ -2458,12 +2573,12 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase().contains(&filter))
|
||||
.collect();
|
||||
Ok(render_skills_report_json(&filtered))
|
||||
Ok(render_skills_report_json_with_action(&filtered, "list"))
|
||||
}
|
||||
Some("show" | "info" | "describe") => {
|
||||
let roots = discover_skill_roots(cwd);
|
||||
let skills = load_skills_from_roots(&roots)?;
|
||||
Ok(render_skills_report_json(&skills))
|
||||
Ok(render_skills_report_json_with_action(&skills, "show"))
|
||||
}
|
||||
Some(args)
|
||||
if args.starts_with("show ")
|
||||
@@ -2482,7 +2597,18 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std::
|
||||
.into_iter()
|
||||
.filter(|s| s.name.to_lowercase() == name)
|
||||
.collect();
|
||||
Ok(render_skills_report_json(&matched))
|
||||
// #706: return typed error when named skill is not found instead of silent empty list
|
||||
if matched.is_empty() {
|
||||
return Ok(json!({
|
||||
"kind": "skills",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"error_kind": "skill_not_found",
|
||||
"message": format!("skill '{}' not found", name),
|
||||
"requested": name,
|
||||
}));
|
||||
}
|
||||
Ok(render_skills_report_json_with_action(&matched, "show"))
|
||||
}
|
||||
Some("install") => Ok(render_skills_usage_json(Some("install"))),
|
||||
Some(args) if args.starts_with("install ") => {
|
||||
@@ -2800,7 +2926,11 @@ fn render_mcp_report_json_for(
|
||||
runtime_config.mcp().get(server_name),
|
||||
);
|
||||
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);
|
||||
}
|
||||
Ok(value)
|
||||
@@ -3230,7 +3360,12 @@ fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<Ski
|
||||
} else {
|
||||
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() {
|
||||
let prompt_path = source.join("SKILL.md");
|
||||
@@ -3613,13 +3748,22 @@ fn render_agents_report(agents: &[AgentSummary]) -> String {
|
||||
}
|
||||
|
||||
fn render_agents_report_json(cwd: &Path, agents: &[AgentSummary]) -> Value {
|
||||
render_agents_report_json_with_action(cwd, agents, "list")
|
||||
}
|
||||
|
||||
fn render_agents_report_json_with_action(
|
||||
cwd: &Path,
|
||||
agents: &[AgentSummary],
|
||||
action: &str,
|
||||
) -> Value {
|
||||
let active = agents
|
||||
.iter()
|
||||
.filter(|agent| agent.shadowed_by.is_none())
|
||||
.count();
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"count": agents.len(),
|
||||
"summary": {
|
||||
@@ -3694,14 +3838,15 @@ fn render_skills_report(skills: &[SkillSummary]) -> String {
|
||||
lines.join("\n").trim_end().to_string()
|
||||
}
|
||||
|
||||
fn render_skills_report_json(skills: &[SkillSummary]) -> Value {
|
||||
fn render_skills_report_json_with_action(skills: &[SkillSummary], action: &str) -> Value {
|
||||
let active = skills
|
||||
.iter()
|
||||
.filter(|skill| skill.shadowed_by.is_none())
|
||||
.count();
|
||||
json!({
|
||||
"kind": "skills",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"action": action,
|
||||
"summary": {
|
||||
"total": skills.len(),
|
||||
"active": active,
|
||||
@@ -3735,6 +3880,7 @@ fn render_skill_install_report(skill: &InstalledSkill) -> String {
|
||||
fn render_skill_install_report_json(skill: &InstalledSkill) -> Value {
|
||||
json!({
|
||||
"kind": "skills",
|
||||
"status": "ok",
|
||||
"action": "install",
|
||||
"result": "installed",
|
||||
"invocation_name": &skill.invocation_name,
|
||||
@@ -3878,6 +4024,7 @@ fn render_mcp_server_report_json(
|
||||
Some(server) => json!({
|
||||
"kind": "mcp",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"found": true,
|
||||
"server": mcp_server_json(server_name, server),
|
||||
@@ -3885,6 +4032,8 @@ fn render_mcp_server_report_json(
|
||||
None => json!({
|
||||
"kind": "mcp",
|
||||
"action": "show",
|
||||
"status": "error",
|
||||
"error_kind": "server_not_found",
|
||||
"working_directory": cwd.display().to_string(),
|
||||
"found": false,
|
||||
"server_name": server_name,
|
||||
@@ -3924,6 +4073,8 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value {
|
||||
json!({
|
||||
"kind": "agents",
|
||||
"action": "help",
|
||||
"ok": unexpected.is_none(),
|
||||
"status": if unexpected.is_some() { "error" } else { "ok" },
|
||||
"usage": {
|
||||
"slash_command": "/agents [list|help]",
|
||||
"direct_cli": "claw agents [list|help]",
|
||||
@@ -3953,6 +4104,8 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value {
|
||||
json!({
|
||||
"kind": "skills",
|
||||
"action": "help",
|
||||
"ok": unexpected.is_none(),
|
||||
"status": if unexpected.is_some() { "error" } else { "ok" },
|
||||
"usage": {
|
||||
"slash_command": "/skills [list|install <path>|help|<skill> [args]]",
|
||||
"aliases": ["/skill"],
|
||||
@@ -3995,6 +4148,8 @@ fn render_mcp_usage_json(unexpected: Option<&str>) -> Value {
|
||||
json!({
|
||||
"kind": "mcp",
|
||||
"action": "help",
|
||||
"ok": unexpected.is_none(),
|
||||
"status": if unexpected.is_some() { "error" } else { "ok" },
|
||||
"usage": {
|
||||
"slash_command": "/mcp [list|show <server>|help]",
|
||||
"direct_cli": "claw mcp [list|show <server>|help]",
|
||||
@@ -4095,9 +4250,17 @@ fn definition_source_id(source: DefinitionSource) -> &'static str {
|
||||
}
|
||||
|
||||
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!({
|
||||
"id": definition_source_id(source),
|
||||
"label": source.label(),
|
||||
"detail_label": detail_label,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4131,7 +4294,7 @@ fn skill_summary_json(skill: &SkillSummary) -> Value {
|
||||
json!({
|
||||
"name": &skill.name,
|
||||
"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),
|
||||
"active": skill.shadowed_by.is_none(),
|
||||
"shadowed_by": skill.shadowed_by.map(definition_source_json),
|
||||
@@ -5298,6 +5461,7 @@ mod tests {
|
||||
|
||||
assert_eq!(report["kind"], "agents");
|
||||
assert_eq!(report["action"], "list");
|
||||
assert_eq!(report["status"], "ok");
|
||||
assert_eq!(report["working_directory"], workspace.display().to_string());
|
||||
assert_eq!(report["count"], 3);
|
||||
assert_eq!(report["summary"]["active"], 2);
|
||||
@@ -5313,12 +5477,26 @@ mod tests {
|
||||
let help = handle_agents_slash_command_json(Some("help"), &workspace).expect("agents help");
|
||||
assert_eq!(help["kind"], "agents");
|
||||
assert_eq!(help["action"], "help");
|
||||
assert_eq!(help["status"], "ok");
|
||||
assert_eq!(help["usage"]["direct_cli"], "claw agents [list|help]");
|
||||
|
||||
let unexpected = handle_agents_slash_command_json(Some("show planner"), &workspace)
|
||||
.expect("agents usage");
|
||||
assert_eq!(unexpected["action"], "help");
|
||||
assert_eq!(unexpected["unexpected"], "show planner");
|
||||
// `show <name>` is now valid. Known agent returns ok with matching entry.
|
||||
let show_planner = handle_agents_slash_command_json(Some("show planner"), &workspace)
|
||||
.expect("show planner should return Ok");
|
||||
assert_eq!(show_planner["status"], "ok");
|
||||
let show_agents = show_planner["agents"].as_array().expect("agents array");
|
||||
assert_eq!(show_agents.len(), 1, "show by exact name returns one entry");
|
||||
assert_eq!(show_agents[0]["name"], "planner");
|
||||
// Missing agent returns Ok(json error) with error_kind:agent_not_found.
|
||||
let show_missing =
|
||||
handle_agents_slash_command_json(Some("show nonexistent-xyz"), &workspace)
|
||||
.expect("show missing agent should return Ok");
|
||||
assert_eq!(show_missing["status"], "error");
|
||||
assert_eq!(show_missing["error_kind"], "agent_not_found");
|
||||
assert_eq!(show_missing["requested"], "nonexistent-xyz");
|
||||
// Truly unknown subcommands still Err.
|
||||
let unexpected_err = handle_agents_slash_command_json(Some("frobnicate"), &workspace);
|
||||
assert!(unexpected_err.is_err());
|
||||
|
||||
let _ = fs::remove_dir_all(workspace);
|
||||
let _ = fs::remove_dir_all(user_home);
|
||||
@@ -5419,22 +5597,36 @@ mod tests {
|
||||
origin: SkillOrigin::SkillsDir,
|
||||
},
|
||||
];
|
||||
let report = super::render_skills_report_json(
|
||||
let report = super::render_skills_report_json_with_action(
|
||||
&load_skills_from_roots(&roots).expect("skills should load"),
|
||||
"list",
|
||||
);
|
||||
assert_eq!(report["kind"], "skills");
|
||||
assert_eq!(report["action"], "list");
|
||||
assert_eq!(report["status"], "ok");
|
||||
assert_eq!(report["summary"]["active"], 3);
|
||||
assert_eq!(report["summary"]["shadowed"], 1);
|
||||
assert_eq!(report["skills"][0]["name"], "plan");
|
||||
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]["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"][3]["shadowed_by"]["id"], "project_claw");
|
||||
|
||||
let help = handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help");
|
||||
assert_eq!(help["kind"], "skills");
|
||||
assert_eq!(help["action"], "help");
|
||||
assert_eq!(help["status"], "ok");
|
||||
assert_eq!(help["usage"]["aliases"][0], "/skill");
|
||||
assert_eq!(
|
||||
help["usage"]["direct_cli"],
|
||||
@@ -5456,9 +5648,23 @@ mod tests {
|
||||
assert!(agents_help
|
||||
.contains("Sources .claw/agents, ~/.claw/agents, $CLAW_CONFIG_HOME/agents"));
|
||||
|
||||
let agents_unexpected =
|
||||
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
|
||||
assert!(agents_unexpected.contains("Unexpected show planner"));
|
||||
// `show <name>` is now valid. For an agent that doesn't exist it returns Err(NotFound).
|
||||
let agents_show_missing = super::handle_agents_slash_command(Some("show planner"), &cwd);
|
||||
assert!(
|
||||
agents_show_missing.is_err(),
|
||||
"show of a missing agent should Err"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_show_missing.unwrap_err().kind(),
|
||||
std::io::ErrorKind::NotFound
|
||||
);
|
||||
// Truly unknown subcommands still Err with InvalidInput.
|
||||
let agents_unknown_err = super::handle_agents_slash_command(Some("frobnicate"), &cwd);
|
||||
assert!(agents_unknown_err.is_err());
|
||||
assert_eq!(
|
||||
agents_unknown_err.unwrap_err().kind(),
|
||||
std::io::ErrorKind::InvalidInput
|
||||
);
|
||||
|
||||
let skills_help =
|
||||
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
|
||||
@@ -5494,6 +5700,7 @@ mod tests {
|
||||
let sources = skills_help_json["usage"]["sources"]
|
||||
.as_array()
|
||||
.expect("skills help sources");
|
||||
assert_eq!(skills_help_json["status"], "ok");
|
||||
assert_eq!(skills_help_json["usage"]["aliases"][0], "/skill");
|
||||
assert!(sources.iter().any(|value| value == ".omc/skills"));
|
||||
assert!(sources.iter().any(|value| value == ".agents/skills"));
|
||||
@@ -5879,6 +6086,13 @@ mod tests {
|
||||
assert!(report.contains("Invoke as $help"));
|
||||
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 {
|
||||
source: DefinitionSource::UserCodexHome,
|
||||
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"] }
|
||||
walkdir = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs;
|
||||
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::sandbox::{FilesystemIsolationMode, SandboxConfig};
|
||||
@@ -301,7 +316,7 @@ impl ConfigLoader {
|
||||
}
|
||||
|
||||
for warning in &all_warnings {
|
||||
eprintln!("warning: {warning}");
|
||||
emit_config_warning_once(&warning.to_string());
|
||||
}
|
||||
|
||||
let merged_value = JsonValue::Object(merged.clone());
|
||||
|
||||
@@ -342,6 +342,7 @@ where
|
||||
let mut tool_results = Vec::new();
|
||||
let mut prompt_cache_events = Vec::new();
|
||||
let mut iterations = 0;
|
||||
let mut auto_compaction = None;
|
||||
|
||||
loop {
|
||||
iterations += 1;
|
||||
@@ -397,6 +398,12 @@ where
|
||||
.map_err(|error| RuntimeError::new(error.to_string()))?;
|
||||
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() {
|
||||
break;
|
||||
}
|
||||
@@ -503,8 +510,6 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
let auto_compaction = self.maybe_auto_compact();
|
||||
|
||||
let summary = TurnSummary {
|
||||
assistant_messages,
|
||||
tool_results,
|
||||
|
||||
@@ -413,6 +413,7 @@ impl Session {
|
||||
.get("created_at_ms")
|
||||
.map(|value| required_u64_from_value(value, "created_at_ms"))
|
||||
.transpose()?
|
||||
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
|
||||
.unwrap_or(now);
|
||||
let updated_at_ms = object
|
||||
.get("updated_at_ms")
|
||||
@@ -500,7 +501,10 @@ impl Session {
|
||||
"session_meta" => {
|
||||
version = required_u32(object, "version")?;
|
||||
session_id = Some(required_string(object, "session_id")?);
|
||||
created_at_ms = Some(required_u64(object, "created_at_ms")?);
|
||||
created_at_ms = object
|
||||
.get("created_at_ms")
|
||||
.map(|value| required_u64_from_value(value, "created_at_ms"))
|
||||
.transpose()?;
|
||||
updated_at_ms = Some(required_u64(object, "updated_at_ms")?);
|
||||
fork = object.get("fork").map(SessionFork::from_json).transpose()?;
|
||||
workspace_root = object
|
||||
@@ -543,11 +547,15 @@ impl Session {
|
||||
}
|
||||
|
||||
let now = current_time_millis();
|
||||
let session_id = session_id.unwrap_or_else(generate_session_id);
|
||||
let created_at_ms = created_at_ms
|
||||
.or_else(|| parse_created_at_ms_from_session_id(&session_id))
|
||||
.unwrap_or(now);
|
||||
Ok(Self {
|
||||
version,
|
||||
session_id: session_id.unwrap_or_else(generate_session_id),
|
||||
created_at_ms: created_at_ms.unwrap_or(now),
|
||||
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms.unwrap_or(now)),
|
||||
session_id,
|
||||
created_at_ms,
|
||||
updated_at_ms: updated_at_ms.unwrap_or(created_at_ms),
|
||||
messages,
|
||||
compaction,
|
||||
fork,
|
||||
@@ -1291,6 +1299,15 @@ fn current_time_millis() -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_created_at_ms_from_session_id(session_id: &str) -> Option<u64> {
|
||||
let timestamp_and_suffix = session_id.strip_prefix("session-")?;
|
||||
let (timestamp, suffix) = timestamp_and_suffix.split_once('-')?;
|
||||
if suffix.is_empty() {
|
||||
return None;
|
||||
}
|
||||
timestamp.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
fn generate_session_id() -> String {
|
||||
let millis = current_time_millis();
|
||||
let counter = SESSION_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -1380,8 +1397,9 @@ fn cleanup_rotated_logs(path: &Path) -> Result<(), SessionError> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
cleanup_rotated_logs, current_time_millis, rotate_session_file_if_needed, ContentBlock,
|
||||
ConversationMessage, MessageRole, Session, SessionFork,
|
||||
cleanup_rotated_logs, current_time_millis, parse_created_at_ms_from_session_id,
|
||||
rotate_session_file_if_needed, ContentBlock, ConversationMessage, MessageRole, Session,
|
||||
SessionFork,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use crate::usage::TokenUsage;
|
||||
@@ -1502,6 +1520,44 @@ mod tests {
|
||||
assert!(!restored.session_id.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_at_parser_requires_full_session_id_shape() {
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123-0"),
|
||||
Some(1_743_724_800_123)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("session-1743724800123-"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_created_at_ms_from_session_id("other-1743724800123-0"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_legacy_jsonl_created_at_from_session_id_when_meta_omits_it() {
|
||||
let path = temp_session_path("legacy-jsonl-created-at");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"type":"session_meta","version":3,"session_id":"session-1743724800123-0","updated_at_ms":1743724800456}
|
||||
"#,
|
||||
)
|
||||
.expect("legacy jsonl should write");
|
||||
|
||||
let restored = Session::load_from_path(&path).expect("legacy jsonl should load");
|
||||
fs::remove_file(&path).expect("temp file should be removable");
|
||||
|
||||
assert_eq!(restored.session_id, "session-1743724800123-0");
|
||||
assert_eq!(restored.created_at_ms, 1_743_724_800_123);
|
||||
assert_eq!(restored.updated_at_ms, 1_743_724_800_456);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_messages_to_persisted_jsonl_session() {
|
||||
let path = temp_session_path("append");
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::session::{Session, SessionError};
|
||||
use crate::session::{parse_created_at_ms_from_session_id, Session, SessionError};
|
||||
|
||||
/// Per-worktree session store that namespaces on-disk session files by
|
||||
/// workspace fingerprint so that parallel `opencode serve` instances never
|
||||
@@ -345,6 +345,9 @@ impl SessionStore {
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
|
||||
let fallback_created_at_ms =
|
||||
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
|
||||
let summary = match Session::load_from_path(&path) {
|
||||
Ok(session) => {
|
||||
if self.validate_loaded_session(&path, &session).is_err() {
|
||||
@@ -353,6 +356,7 @@ impl SessionStore {
|
||||
ManagedSessionSummary {
|
||||
id: session.session_id,
|
||||
path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis,
|
||||
message_count: session.messages.len(),
|
||||
@@ -367,12 +371,9 @@ impl SessionStore {
|
||||
}
|
||||
}
|
||||
Err(_) => ManagedSessionSummary {
|
||||
id: path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
id: fallback_id,
|
||||
path,
|
||||
created_at_ms: fallback_created_at_ms,
|
||||
updated_at_ms: 0,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
@@ -409,10 +410,14 @@ impl SessionStore {
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_millis())
|
||||
.unwrap_or_default();
|
||||
let fallback_id = session_id_from_path(&path).unwrap_or_else(|| "unknown".to_string());
|
||||
let fallback_created_at_ms =
|
||||
parse_created_at_ms_from_session_id(&fallback_id).unwrap_or(0);
|
||||
let summary = match Session::load_from_path(&path) {
|
||||
Ok(session) => ManagedSessionSummary {
|
||||
id: session.session_id,
|
||||
path,
|
||||
created_at_ms: session.created_at_ms,
|
||||
updated_at_ms: session.updated_at_ms,
|
||||
modified_epoch_millis,
|
||||
message_count: session.messages.len(),
|
||||
@@ -426,12 +431,9 @@ impl SessionStore {
|
||||
.and_then(|fork| fork.branch_name.clone()),
|
||||
},
|
||||
Err(_) => ManagedSessionSummary {
|
||||
id: path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string(),
|
||||
id: fallback_id,
|
||||
path,
|
||||
created_at_ms: fallback_created_at_ms,
|
||||
updated_at_ms: 0,
|
||||
modified_epoch_millis,
|
||||
message_count: 0,
|
||||
@@ -483,6 +485,7 @@ pub struct SessionHandle {
|
||||
pub struct ManagedSessionSummary {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
pub created_at_ms: u64,
|
||||
pub updated_at_ms: u64,
|
||||
pub modified_epoch_millis: u128,
|
||||
pub message_count: usize,
|
||||
@@ -810,6 +813,7 @@ mod tests {
|
||||
ManagedSessionSummary {
|
||||
id: "older-file-newer-session".to_string(),
|
||||
path: PathBuf::from("/tmp/older"),
|
||||
created_at_ms: 100,
|
||||
updated_at_ms: 200,
|
||||
modified_epoch_millis: 100,
|
||||
message_count: 2,
|
||||
@@ -819,6 +823,7 @@ mod tests {
|
||||
ManagedSessionSummary {
|
||||
id: "newer-file-older-session".to_string(),
|
||||
path: PathBuf::from("/tmp/newer"),
|
||||
created_at_ms: 50,
|
||||
updated_at_ms: 100,
|
||||
modified_epoch_millis: 200,
|
||||
message_count: 1,
|
||||
|
||||
@@ -438,13 +438,24 @@ fn normalize_path(path: &Path) -> PathBuf {
|
||||
/// Extract repository name from a path for event context.
|
||||
fn extract_repo_name(cwd: &str) -> Option<String> {
|
||||
let path = Path::new(cwd);
|
||||
// Try to find a .git directory to identify repo root
|
||||
let mut current = Some(path);
|
||||
while let Some(p) = current {
|
||||
if p.join(".git").is_dir() {
|
||||
return p.file_name().map(|n| n.to_string_lossy().to_string());
|
||||
// Ask git from the cwd itself. Walking ancestors manually can accidentally
|
||||
// classify synthetic/nonexistent paths as an unrelated parent repo (for
|
||||
// example `/tmp/.git`), which makes trust events point at the wrong repo.
|
||||
if path.is_dir() {
|
||||
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
|
||||
path.file_name().map(|n| n.to_string_lossy().to_string())
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -73,6 +74,7 @@ pub struct WorkerFailure {
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkerEventKind {
|
||||
Spawning,
|
||||
StartupPreflightWarning,
|
||||
TrustRequired,
|
||||
ToolPermissionRequired,
|
||||
TrustResolved,
|
||||
@@ -102,6 +104,21 @@ pub enum WorkerPromptTarget {
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -212,6 +229,12 @@ pub enum WorkerEventPayload {
|
||||
evidence: StartupEvidenceBundle,
|
||||
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)]
|
||||
@@ -329,6 +352,34 @@ impl WorkerRegistry {
|
||||
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> {
|
||||
let mut inner = self.inner.lock().expect("worker registry lock poisoned");
|
||||
let worker = inner
|
||||
@@ -1064,6 +1115,128 @@ fn extract_server_from_qualified_tool(tool: &str) -> Option<String> {
|
||||
(!server.is_empty()).then(|| server.to_string())
|
||||
}
|
||||
|
||||
pub fn startup_preflight_warnings(
|
||||
cwd: &Path,
|
||||
task_prompt: &str,
|
||||
) -> Vec<WorkerStartupPreflightWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
if let Some(git_path) = git_metadata_path(cwd) {
|
||||
if !path_is_writable(&git_path) {
|
||||
warnings.push(WorkerStartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
|
||||
message: format!(
|
||||
"git metadata is not writable; commits or pushes may fail: {}",
|
||||
git_path.display()
|
||||
),
|
||||
path: Some(git_path.display().to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for path in mentioned_repo_paths(task_prompt) {
|
||||
if !git_tracks_path(cwd, &path) {
|
||||
warnings.push(WorkerStartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::FileAbsentOnBranch,
|
||||
message: format!(
|
||||
"task mentions {path}, but git does not track it on the current branch"
|
||||
),
|
||||
path: Some(path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
|
||||
fn mentioned_repo_paths(task_prompt: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for raw in task_prompt.split_whitespace() {
|
||||
let token = raw.trim_matches(|ch: char| {
|
||||
matches!(
|
||||
ch,
|
||||
'`' | '"' | '\'' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | ':'
|
||||
)
|
||||
});
|
||||
if !token.contains('/') || token.contains("://") || token.starts_with('/') {
|
||||
continue;
|
||||
}
|
||||
let token = token.trim_start_matches("./");
|
||||
if token.contains("..") {
|
||||
continue;
|
||||
}
|
||||
if token
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '_' | '-' | '.'))
|
||||
&& token
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.is_some_and(|name| name.contains('.'))
|
||||
&& !out.iter().any(|seen| seen == token)
|
||||
{
|
||||
out.push(token.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn git_tracks_path(cwd: &Path, path: &str) -> bool {
|
||||
Command::new("git")
|
||||
.arg("ls-files")
|
||||
.arg("--error-unmatch")
|
||||
.arg("--")
|
||||
.arg(path)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.is_ok_and(|output| output.status.success())
|
||||
}
|
||||
|
||||
fn git_metadata_path(cwd: &Path) -> Option<PathBuf> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let path = PathBuf::from(text);
|
||||
Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
cwd.join(path)
|
||||
})
|
||||
}
|
||||
|
||||
fn path_is_writable(path: &Path) -> bool {
|
||||
let probe_dir = if path.is_dir() {
|
||||
path
|
||||
} else {
|
||||
path.parent().unwrap_or(path)
|
||||
};
|
||||
std::fs::metadata(probe_dir)
|
||||
.ok()
|
||||
.filter(std::fs::Metadata::is_dir)
|
||||
.is_some_and(|metadata| metadata_allows_directory_writes(&metadata))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = metadata.permissions().mode();
|
||||
mode & 0o222 != 0 && mode & 0o111 != 0
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn metadata_allows_directory_writes(metadata: &std::fs::Metadata) -> bool {
|
||||
!metadata.permissions().readonly()
|
||||
}
|
||||
|
||||
fn detect_trust_prompt(lowered: &str) -> bool {
|
||||
[
|
||||
"do you trust the files in this folder",
|
||||
@@ -1285,6 +1458,8 @@ fn cwd_matches_observed_target(expected_cwd: &str, observed_cwd: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn allowlisted_trust_prompt_auto_resolves_then_reaches_ready_state() {
|
||||
@@ -1431,6 +1606,116 @@ mod tests {
|
||||
assert!(!readiness.ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_preflight_warns_when_task_file_is_absent_on_branch() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("src dir");
|
||||
fs::write(tmp.path().join("src/lib.rs"), "pub fn present() {}\n").expect("write file");
|
||||
Command::new("git")
|
||||
.args(["add", "src/lib.rs"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git add should run");
|
||||
|
||||
let warnings = startup_preflight_warnings(
|
||||
tmp.path(),
|
||||
"Fix src/lib.rs and rust/crates/runtime/src/trident.rs before testing.",
|
||||
);
|
||||
|
||||
assert!(warnings.iter().any(|warning| {
|
||||
warning.kind == WorkerStartupPreflightWarningKind::FileAbsentOnBranch
|
||||
&& warning.path.as_deref() == Some("rust/crates/runtime/src/trident.rs")
|
||||
}));
|
||||
assert!(!warnings.iter().any(|warning| {
|
||||
warning.kind == WorkerStartupPreflightWarningKind::FileAbsentOnBranch
|
||||
&& warning.path.as_deref() == Some("src/lib.rs")
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn startup_preflight_warns_when_git_metadata_is_not_writable() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let worktree = tmp.path().join("worktree");
|
||||
let git_dir = tmp.path().join("external-gitdir");
|
||||
fs::create_dir_all(&worktree).expect("worktree dir");
|
||||
fs::create_dir_all(git_dir.join("objects")).expect("objects dir");
|
||||
fs::create_dir_all(git_dir.join("refs/heads")).expect("refs dir");
|
||||
fs::write(git_dir.join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
|
||||
fs::write(
|
||||
worktree.join(".git"),
|
||||
format!("gitdir: {}\n", git_dir.display()),
|
||||
)
|
||||
.expect(".git file");
|
||||
|
||||
let original_permissions = fs::metadata(&git_dir)
|
||||
.expect("gitdir metadata")
|
||||
.permissions();
|
||||
let mut read_only_permissions = original_permissions.clone();
|
||||
read_only_permissions.set_mode(0o555);
|
||||
fs::set_permissions(&git_dir, read_only_permissions).expect("make gitdir read-only");
|
||||
|
||||
let warnings = startup_preflight_warnings(&worktree, "Audit repository.");
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create(&worktree.display().to_string(), &[], true);
|
||||
let observed = registry
|
||||
.observe_startup_preflight(&worker.worker_id, "Audit repository.")
|
||||
.expect("preflight should run");
|
||||
|
||||
fs::set_permissions(&git_dir, original_permissions).expect("restore gitdir permissions");
|
||||
|
||||
assert!(warnings.iter().any(|warning| {
|
||||
warning.kind == WorkerStartupPreflightWarningKind::GitMetadataNotWritable
|
||||
&& warning.path.as_deref() == Some(git_dir.to_string_lossy().as_ref())
|
||||
}));
|
||||
assert!(observed.events.iter().any(|event| {
|
||||
matches!(
|
||||
&event.payload,
|
||||
Some(WorkerEventPayload::StartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::GitMetadataNotWritable,
|
||||
path: Some(path),
|
||||
..
|
||||
}) if path == git_dir.to_string_lossy().as_ref()
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_preflight_records_structured_warning_event() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
let registry = WorkerRegistry::new();
|
||||
let worker = registry.create(&tmp.path().display().to_string(), &[], true);
|
||||
|
||||
let observed = registry
|
||||
.observe_startup_preflight(&worker.worker_id, "Open missing/file.rs")
|
||||
.expect("preflight should run");
|
||||
|
||||
let event = observed
|
||||
.events
|
||||
.iter()
|
||||
.find(|event| event.kind == WorkerEventKind::StartupPreflightWarning)
|
||||
.expect("preflight warning event");
|
||||
assert!(matches!(
|
||||
event.payload,
|
||||
Some(WorkerEventPayload::StartupPreflightWarning {
|
||||
kind: WorkerStartupPreflightWarningKind::FileAbsentOnBranch,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_timeout_classifies_tool_permission_prompt() {
|
||||
let registry = WorkerRegistry::new();
|
||||
|
||||
@@ -381,11 +381,16 @@ mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}"))
|
||||
// Combine counter + nanoseconds so parallel tests in the same process
|
||||
// never collide even if two calls land in the same nanosecond (#707).
|
||||
std::env::temp_dir().join(format!("rusty-claude-init-{nanos}-{id}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
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 serde_json::Value;
|
||||
@@ -245,6 +245,84 @@ stderr:
|
||||
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(
|
||||
cwd: &std::path::Path,
|
||||
config_home: &std::path::Path,
|
||||
@@ -266,6 +344,48 @@ fn run_claw(
|
||||
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 {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -16,6 +16,10 @@ fn help_emits_json_when_requested() {
|
||||
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "help"]);
|
||||
assert_eq!(parsed["kind"], "help");
|
||||
assert_eq!(
|
||||
parsed["status"], "ok",
|
||||
"help JSON must have status:ok (#700)"
|
||||
);
|
||||
assert!(parsed["message"]
|
||||
.as_str()
|
||||
.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"]);
|
||||
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["command"], "export");
|
||||
assert_eq!(
|
||||
@@ -65,6 +73,10 @@ fn version_emits_json_when_requested() {
|
||||
|
||||
let parsed = assert_json_command(&root, &["--output-format", "json", "version"]);
|
||||
assert_eq!(parsed["kind"], "version");
|
||||
assert_eq!(
|
||||
parsed["action"], "show",
|
||||
"version JSON must have action:show (#711)"
|
||||
);
|
||||
assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION"));
|
||||
// Provenance fields must be present for binary identification (#507).
|
||||
assert!(
|
||||
@@ -179,6 +191,72 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
.expect("agents array")
|
||||
.is_empty());
|
||||
|
||||
// #717: agents show <name> and agents list <filter> should be valid subcommands
|
||||
let agents_show_env = [
|
||||
("HOME", isolated_home.to_str().expect("utf8 home")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
isolated_config.to_str().expect("utf8 config home"),
|
||||
),
|
||||
(
|
||||
"CODEX_HOME",
|
||||
isolated_codex.to_str().expect("utf8 codex home"),
|
||||
),
|
||||
];
|
||||
let agents_show_missing = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"agents",
|
||||
"show",
|
||||
"nonexistent-xyz",
|
||||
],
|
||||
&agents_show_env,
|
||||
);
|
||||
assert_eq!(agents_show_missing["kind"], "agents", "agents show kind");
|
||||
assert_eq!(agents_show_missing["action"], "show", "agents show action");
|
||||
assert_eq!(
|
||||
agents_show_missing["status"], "error",
|
||||
"agents show not-found status"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_show_missing["error_kind"], "agent_not_found",
|
||||
"agents show error_kind"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_show_missing["requested"], "nonexistent-xyz",
|
||||
"agents show requested"
|
||||
);
|
||||
|
||||
let agents_list_filtered = assert_json_command_with_env(
|
||||
&root,
|
||||
&[
|
||||
"--output-format",
|
||||
"json",
|
||||
"agents",
|
||||
"list",
|
||||
"nonexistent-filter-xyz",
|
||||
],
|
||||
&agents_show_env,
|
||||
);
|
||||
assert_eq!(
|
||||
agents_list_filtered["kind"], "agents",
|
||||
"agents list filter kind"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_list_filtered["action"], "list",
|
||||
"agents list filter action"
|
||||
);
|
||||
assert_eq!(
|
||||
agents_list_filtered["status"], "ok",
|
||||
"agents list filter status"
|
||||
);
|
||||
assert!(agents_list_filtered["agents"]
|
||||
.as_array()
|
||||
.expect("agents array")
|
||||
.is_empty());
|
||||
|
||||
let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]);
|
||||
assert_eq!(mcp["kind"], "mcp");
|
||||
assert_eq!(mcp["action"], "list");
|
||||
@@ -194,13 +272,31 @@ fn inventory_commands_emit_structured_json_when_requested() {
|
||||
assert_eq!(plugins["action"], "list");
|
||||
assert_eq!(plugins["status"], "ok");
|
||||
assert!(plugins["config_load_error"].is_null());
|
||||
// reload_runtime and target are operation-result fields; list response omits them (#703)
|
||||
assert!(
|
||||
plugins["reload_runtime"].is_boolean(),
|
||||
"plugins reload_runtime should be a boolean"
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
plugins["target"].is_null(),
|
||||
"plugins target should be null when no plugin is targeted"
|
||||
!plugins
|
||||
.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");
|
||||
let plugin_entries = plugins["plugins"].as_array().expect("plugins array");
|
||||
@@ -351,6 +447,8 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
|
||||
assert_eq!(parsed["summary"]["shadowed"], 1);
|
||||
assert_eq!(parsed["agents"][0]["name"], "planner");
|
||||
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"][1]["name"], "verifier");
|
||||
assert_eq!(parsed["agents"][2]["name"], "planner");
|
||||
@@ -358,6 +456,83 @@ fn agents_command_emits_structured_agent_entries_when_requested() {
|
||||
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]
|
||||
fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
let root = unique_temp_dir("bootstrap-system-prompt-json");
|
||||
@@ -365,10 +540,18 @@ fn bootstrap_and_system_prompt_emit_json_when_requested() {
|
||||
|
||||
let plan = assert_json_command(&root, &["--output-format", "json", "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);
|
||||
|
||||
let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]);
|
||||
assert_eq!(prompt["kind"], "system-prompt");
|
||||
assert_eq!(
|
||||
prompt["action"], "show",
|
||||
"system-prompt JSON must have action:show (#711)"
|
||||
);
|
||||
assert!(prompt["message"]
|
||||
.as_str()
|
||||
.expect("prompt text")
|
||||
@@ -399,6 +582,10 @@ fn dump_manifests_and_init_emit_json_when_requested() {
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
let init = assert_json_command(&workspace, &["--output-format", "json", "init"]);
|
||||
assert_eq!(init["kind"], "init");
|
||||
assert_eq!(
|
||||
init["action"], "init",
|
||||
"init JSON must have action:init (#711)"
|
||||
);
|
||||
assert!(workspace.join("CLAUDE.md").exists());
|
||||
}
|
||||
|
||||
@@ -427,6 +614,12 @@ fn doctor_and_resume_status_emit_json_when_requested() {
|
||||
assert!(check["status"].as_str().is_some());
|
||||
assert!(check["summary"].as_str().is_some());
|
||||
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")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -600,13 +793,22 @@ fn resumed_inventory_commands_emit_structured_json_when_requested() {
|
||||
assert_eq!(plugins["action"], "list");
|
||||
assert_eq!(plugins["status"], "ok");
|
||||
assert!(plugins["config_load_error"].is_null());
|
||||
// reload_runtime and target are operation-result fields; list response omits them (#703)
|
||||
assert!(
|
||||
plugins["reload_runtime"].is_boolean(),
|
||||
"plugins reload_runtime should be a boolean"
|
||||
!plugins
|
||||
.as_object()
|
||||
.map_or(false, |o| o.contains_key("reload_runtime")),
|
||||
"plugins list should not include reload_runtime"
|
||||
);
|
||||
assert!(
|
||||
plugins["target"].is_null(),
|
||||
"plugins target should be null when no plugin is targeted"
|
||||
!plugins
|
||||
.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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -817,6 +1019,164 @@ fn mcp_degraded_config_and_failed_usage_are_distinct_json_contracts() {
|
||||
assert!(failed.get("config_load_error").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_json_surfaces_have_non_empty_action_contract_714() {
|
||||
let root = unique_temp_dir("json-action-sweep-714");
|
||||
let workspace = root.join("workspace");
|
||||
let init_workspace = root.join("init-workspace");
|
||||
let git_workspace = root.join("git-workspace");
|
||||
let home = root.join("home");
|
||||
let config_home = root.join("config-home");
|
||||
let codex_home = root.join("codex-home");
|
||||
fs::create_dir_all(&workspace).expect("workspace should exist");
|
||||
fs::create_dir_all(&init_workspace).expect("init workspace should exist");
|
||||
fs::create_dir_all(&git_workspace).expect("git workspace should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&codex_home).expect("codex home should exist");
|
||||
|
||||
let session_path = write_session_fixture(&workspace, "action-sweep-export", Some("export me"));
|
||||
let export_output = root.join("export.md");
|
||||
let upstream = write_upstream_fixture(&root);
|
||||
let git_init = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(&git_workspace)
|
||||
.output()
|
||||
.expect("git init should launch");
|
||||
assert!(
|
||||
git_init.status.success(),
|
||||
"git init stdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&git_init.stdout),
|
||||
String::from_utf8_lossy(&git_init.stderr)
|
||||
);
|
||||
|
||||
let envs = [
|
||||
("HOME", home.to_str().expect("home utf8")),
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("config utf8"),
|
||||
),
|
||||
("CODEX_HOME", codex_home.to_str().expect("codex utf8")),
|
||||
];
|
||||
|
||||
let surfaces: Vec<(&Path, Vec<String>)> = vec![
|
||||
(&workspace, strings(&["--output-format", "json", "help"])),
|
||||
(&workspace, strings(&["--output-format", "json", "version"])),
|
||||
(&workspace, strings(&["--output-format", "json", "doctor"])),
|
||||
(&workspace, strings(&["--output-format", "json", "status"])),
|
||||
(&workspace, strings(&["--output-format", "json", "sandbox"])),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "bootstrap-plan"]),
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "system-prompt"]),
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
vec![
|
||||
"--output-format".into(),
|
||||
"json".into(),
|
||||
"dump-manifests".into(),
|
||||
"--manifests-dir".into(),
|
||||
upstream.to_str().expect("upstream utf8").into(),
|
||||
],
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
vec![
|
||||
"--output-format".into(),
|
||||
"json".into(),
|
||||
"export".into(),
|
||||
"--session".into(),
|
||||
session_path.to_str().expect("session utf8").into(),
|
||||
],
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
vec![
|
||||
"--output-format".into(),
|
||||
"json".into(),
|
||||
"export".into(),
|
||||
"--session".into(),
|
||||
session_path.to_str().expect("session utf8").into(),
|
||||
"--output".into(),
|
||||
export_output.to_str().expect("export output utf8").into(),
|
||||
],
|
||||
),
|
||||
(
|
||||
&init_workspace,
|
||||
strings(&["--output-format", "json", "init"]),
|
||||
),
|
||||
(&workspace, strings(&["--output-format", "json", "diff"])),
|
||||
(
|
||||
&git_workspace,
|
||||
strings(&["--output-format", "json", "diff"]),
|
||||
),
|
||||
(&workspace, strings(&["--output-format", "json", "acp"])),
|
||||
(&workspace, strings(&["--output-format", "json", "config"])),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "config", "model"]),
|
||||
),
|
||||
(
|
||||
&workspace,
|
||||
strings(&["--output-format", "json", "config", "unknown"]),
|
||||
),
|
||||
(&workspace, strings(&["--output-format", "json", "skills"])),
|
||||
(&workspace, strings(&["--output-format", "json", "agents"])),
|
||||
(&workspace, strings(&["--output-format", "json", "plugins"])),
|
||||
(&workspace, strings(&["--output-format", "json", "mcp"])),
|
||||
];
|
||||
|
||||
for (current_dir, args) in surfaces {
|
||||
let arg_refs = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let parsed = assert_json_command_with_env(current_dir, &arg_refs, &envs);
|
||||
assert_non_empty_action(&parsed, &arg_refs);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inventory_commands_deduplicate_config_deprecation_warnings_per_process() {
|
||||
let root = unique_temp_dir("config-warning-dedup");
|
||||
let config_home = root.join("config-home");
|
||||
let home = root.join("home");
|
||||
fs::create_dir_all(&config_home).expect("config home should exist");
|
||||
fs::create_dir_all(&home).expect("home should exist");
|
||||
fs::write(
|
||||
config_home.join("settings.json"),
|
||||
r#"{"enabledPlugins": {}}"#,
|
||||
)
|
||||
.expect("deprecated config fixture should write");
|
||||
|
||||
let envs = [
|
||||
(
|
||||
"CLAW_CONFIG_HOME",
|
||||
config_home.to_str().expect("utf8 config home"),
|
||||
),
|
||||
("HOME", home.to_str().expect("utf8 home")),
|
||||
];
|
||||
|
||||
for args in [&["plugins", "list"][..], &["mcp", "list"][..]] {
|
||||
let output = run_claw(&root, args, &envs);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"args={args:?}\nstdout:\n{}\n\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
|
||||
let warning_count = stderr
|
||||
.matches("field \"enabledPlugins\" is deprecated")
|
||||
.count();
|
||||
assert_eq!(
|
||||
warning_count, 1,
|
||||
"args={args:?} should emit the deprecated enabledPlugins warning once per process:\n{stderr}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value {
|
||||
assert_json_command_with_env(current_dir, args, &[])
|
||||
}
|
||||
@@ -829,7 +1189,21 @@ fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be valid json")
|
||||
let parsed: Value =
|
||||
serde_json::from_slice(&output.stdout).expect("stdout should be valid json");
|
||||
assert_non_empty_action(&parsed, args);
|
||||
parsed
|
||||
}
|
||||
|
||||
fn assert_non_empty_action(parsed: &Value, args: &[&str]) {
|
||||
let action = parsed
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!action.trim().is_empty(),
|
||||
"JSON output for args={args:?} must include a non-empty stable action field: {parsed}"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output {
|
||||
@@ -841,6 +1215,10 @@ fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output
|
||||
command.output().expect("claw should launch")
|
||||
}
|
||||
|
||||
fn strings(items: &[&str]) -> Vec<String> {
|
||||
items.iter().map(|item| (*item).to_string()).collect()
|
||||
}
|
||||
|
||||
fn write_upstream_fixture(root: &Path) -> PathBuf {
|
||||
let upstream = root.join("claw-code");
|
||||
let src = upstream.join("src");
|
||||
@@ -893,6 +1271,25 @@ fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasonin
|
||||
.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 {
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -904,3 +1301,99 @@ fn unique_temp_dir(label: &str) -> PathBuf {
|
||||
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"
|
||||
);
|
||||
// #710: diff JSON must have action:diff and working_directory
|
||||
assert_eq!(
|
||||
parsed["action"], "diff",
|
||||
"diff JSON must have action:diff (#710)"
|
||||
);
|
||||
assert!(
|
||||
parsed
|
||||
.get("working_directory")
|
||||
.and_then(|v| v.as_str())
|
||||
.is_some(),
|
||||
"diff JSON must have working_directory field (#710)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,7 +523,14 @@ fn resumed_stub_command_emits_not_implemented_json() {
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8(output.stderr).expect("utf8");
|
||||
let parsed: Value = serde_json::from_str(stderr.trim()).expect("should be json");
|
||||
assert_eq!(parsed["type"], "error");
|
||||
assert_eq!(
|
||||
parsed["status"], "error",
|
||||
"stub command should emit status:error"
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["kind"], "unsupported_command",
|
||||
"stub command should emit kind:unsupported_command"
|
||||
);
|
||||
assert!(
|
||||
parsed["error"]
|
||||
.as_str()
|
||||
|
||||
72
scripts/roadmap-check-ids.sh
Executable file
72
scripts/roadmap-check-ids.sh
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# roadmap-check-ids.sh — fail when helper-era ROADMAP item ids are duplicated.
|
||||
# Usage: scripts/roadmap-check-ids.sh [--min-id N] [path/to/ROADMAP.md]
|
||||
#
|
||||
# By default this validates ids >= 723, the point where ROADMAP appends started
|
||||
# using scripts/roadmap-next-id.sh. Earlier ROADMAP content contains historical
|
||||
# numbered lists and already-landed duplicate low ids, so the default guard is
|
||||
# intentionally scoped to new helper-era append collisions. Use --min-id 1 for a
|
||||
# strict whole-file audit after legacy numbering is cleaned up.
|
||||
set -euo pipefail
|
||||
|
||||
MIN_ID=723
|
||||
ROADMAP="ROADMAP.md"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--min-id)
|
||||
if [[ $# -lt 2 || ! "$2" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: --min-id requires a non-negative integer" >&2
|
||||
exit 2
|
||||
fi
|
||||
MIN_ID="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
sed -n '2,9p' "$0" | sed 's/^# //; s/^#//'
|
||||
exit 0
|
||||
;;
|
||||
--*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
ROADMAP="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$ROADMAP" ]]; then
|
||||
echo "error: ROADMAP not found at $ROADMAP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
awk -v min_id="$MIN_ID" -v path="$ROADMAP" '
|
||||
/^[0-9]+\./ {
|
||||
id = $0
|
||||
sub(/\..*/, "", id)
|
||||
id += 0
|
||||
if (id >= min_id) {
|
||||
count[id]++
|
||||
lines[id] = lines[id] (lines[id] ? ", " : "") FNR
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (id in count) {
|
||||
if (count[id] > 1) {
|
||||
duplicate_count++
|
||||
duplicate_ids[duplicate_count] = id
|
||||
}
|
||||
}
|
||||
if (duplicate_count) {
|
||||
print "error: duplicate ROADMAP numeric id(s) in " path " (min id " min_id "):" > "/dev/stderr"
|
||||
for (i = 1; i <= duplicate_count; i++) {
|
||||
id = duplicate_ids[i]
|
||||
print " - " id " at line(s) " lines[id] > "/dev/stderr"
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
print "roadmap id check passed: no duplicate ids >= " min_id " in " path
|
||||
}
|
||||
' "$ROADMAP"
|
||||
57
scripts/roadmap-next-id.sh
Executable file
57
scripts/roadmap-next-id.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# roadmap-next-id.sh — print the next available ROADMAP item id.
|
||||
# Usage: scripts/roadmap-next-id.sh [path/to/ROADMAP.md]
|
||||
#
|
||||
# Designed to be used before appending a new entry so that concurrent
|
||||
# dogfood claws do not accidentally reuse the same id:
|
||||
#
|
||||
# NEXT=$(scripts/roadmap-next-id.sh)
|
||||
# cat >> ROADMAP.md << EOF
|
||||
# ${NEXT}. **...description...**
|
||||
# EOF
|
||||
#
|
||||
# The script first validates helper-era ids with roadmap-check-ids.sh, then
|
||||
# reads the highest numeric id prefix from ROADMAP.md and prints highest+1. It
|
||||
# does not lock the file; callers working in parallel should git-pull
|
||||
# immediately before appending, run scripts/roadmap-check-ids.sh before push,
|
||||
# and resolve any append collision at git-push time.
|
||||
set -euo pipefail
|
||||
|
||||
ROADMAP="${1:-ROADMAP.md}"
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
CHECKER="$SCRIPT_DIR/roadmap-check-ids.sh"
|
||||
|
||||
if [[ ! -f "$ROADMAP" ]]; then
|
||||
echo "error: ROADMAP not found at $ROADMAP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$CHECKER" || ! -r "$CHECKER" ]]; then
|
||||
echo "error: required ROADMAP id checker not found or not readable at $CHECKER" >&2
|
||||
echo "error: refusing to print a next id without duplicate-id validation" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! checker_output="$(bash "$CHECKER" "$ROADMAP" 2>&1)"; then
|
||||
printf '%s\n' "$checker_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find the highest leading integer from lines that start with a number + '.'.
|
||||
highest=$(awk '
|
||||
/^[0-9]+\./ {
|
||||
id = $0
|
||||
sub(/\..*/, "", id)
|
||||
id += 0
|
||||
if (id > highest) {
|
||||
highest = id
|
||||
}
|
||||
}
|
||||
END { print highest + 0 }
|
||||
' "$ROADMAP")
|
||||
|
||||
if [[ "$highest" -eq 0 ]]; then
|
||||
echo 1
|
||||
else
|
||||
echo $(( highest + 1 ))
|
||||
fi
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
45
tests/test_pre_push_hook_contract.py
Normal file
45
tests/test_pre_push_hook_contract.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PRE_PUSH_HOOK = REPO_ROOT / '.github' / 'hooks' / 'pre-push'
|
||||
|
||||
|
||||
class PrePushHookContractTests(unittest.TestCase):
|
||||
def test_skip_escape_hatch_exits_successfully_with_stderr_notice(self) -> None:
|
||||
env = os.environ.copy()
|
||||
env['SKIP_CLAW_PRE_PUSH_BUILD'] = '1'
|
||||
|
||||
result = subprocess.run(
|
||||
['bash', str(PRE_PUSH_HOOK)],
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('SKIP_CLAW_PRE_PUSH_BUILD=1', result.stderr)
|
||||
self.assertIn('skipping cargo workspace build', result.stderr)
|
||||
|
||||
def test_default_build_gate_uses_workspace_locked_cargo_build(self) -> None:
|
||||
hook = PRE_PUSH_HOOK.read_text()
|
||||
|
||||
self.assertIn(
|
||||
'cargo build --manifest-path rust/Cargo.toml --workspace --locked',
|
||||
hook,
|
||||
)
|
||||
self.assertIn(
|
||||
'build_cmd=(cargo build --manifest-path rust/Cargo.toml --workspace --locked)',
|
||||
hook,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
78
tests/test_roadmap_helpers.py
Normal file
78
tests/test_roadmap_helpers.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
NEXT_ID = REPO_ROOT / 'scripts' / 'roadmap-next-id.sh'
|
||||
|
||||
|
||||
def run_next_id(roadmap: Path, script: Path = NEXT_ID) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
['bash', str(script), str(roadmap)],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class RoadmapHelperTests(unittest.TestCase):
|
||||
def test_roadmap_next_id_prints_only_next_id_after_duplicate_check(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('721. old\n723. helper era\n724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertEqual(0, result.returncode)
|
||||
self.assertEqual('725\n', result.stdout)
|
||||
self.assertEqual('', result.stderr)
|
||||
|
||||
def test_roadmap_next_id_fails_fast_on_helper_era_duplicate(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('722. legacy\n999. first\n999. duplicate\n')
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('duplicate ROADMAP numeric id(s)', result.stderr)
|
||||
self.assertIn('999', result.stderr)
|
||||
self.assertNotIn('1000', result.stdout)
|
||||
|
||||
def test_roadmap_next_id_fails_when_explicit_roadmap_path_is_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
roadmap = Path(temp_dir) / 'missing-ROADMAP.md'
|
||||
|
||||
result = run_next_id(roadmap)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('ROADMAP not found', result.stderr)
|
||||
self.assertIn(str(roadmap), result.stderr)
|
||||
|
||||
def test_roadmap_next_id_fails_closed_when_checker_is_unavailable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
script_dir = Path(temp_dir) / 'scripts'
|
||||
script_dir.mkdir()
|
||||
copied_next_id = script_dir / 'roadmap-next-id.sh'
|
||||
shutil.copy2(NEXT_ID, copied_next_id)
|
||||
roadmap = Path(temp_dir) / 'ROADMAP.md'
|
||||
roadmap.write_text('724. guard\n')
|
||||
|
||||
result = run_next_id(roadmap, copied_next_id)
|
||||
|
||||
self.assertNotEqual(0, result.returncode)
|
||||
self.assertEqual('', result.stdout)
|
||||
self.assertIn('required ROADMAP id checker not found or not readable', result.stderr)
|
||||
self.assertIn('refusing to print a next id', result.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user