Compare commits

..

21 Commits

Author SHA1 Message Date
YeonGyu-Kim
f3f6643fb9 feat: #108 add did-you-mean guard for subcommand typos (prevents silent LLM dispatch)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-21 15:37:58 +09:00
YeonGyu-Kim
883cef1a26 docs: #138 add concrete evidence — feat/134-135 branch pushed but no PR (closure-state gap) 2026-04-21 15:02:33 +09:00
YeonGyu-Kim
768c1abc78 ROADMAP #138: dogfood cycle report-gate opacity — nudge surface needs explicit closure state 2026-04-21 14:49:36 +09:00
YeonGyu-Kim
a8beca1463 fix: #136 support --output-format json with --compact flag
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-21 14:47:15 +09:00
YeonGyu-Kim
21adae9570 fix: #137 update test fixtures to use canonical 'opus' alias for main branch consistency
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-21 14:32:49 +09:00
YeonGyu-Kim
724a78604d ROADMAP #137: model-alias shorthand regression in test suite — bare alias parsing broken on feat/134-135-session-identity; 3 tests fail with invalid model syntax error after #134/#135 validation tightening 2026-04-21 13:27:10 +09:00
YeonGyu-Kim
91ba54d39f ROADMAP #136: --compact flag silently overrides --output-format json — compact turn always emits plain text even when JSON requested; unreachable Json arm in run_with_output() match; joins output-format completeness cluster #90/#91/#92/#127/#130 and CLI/REPL parity §7.1 2026-04-21 12:27:06 +09:00
YeonGyu-Kim
8b52e77f23 ROADMAP #135: claw status --json missing active_session bool and session.id cross-reference — status query side of #134 round-trip; joins session identity completeness §4.7 and status surface completeness cluster #80/#83/#114/#122; natural bundle #134+#135 closes session-identity round-trip 2026-04-21 06:55:09 +09:00
YeonGyu-Kim
2c42f8bcc8 docs: remove duplicate ROADMAP #134 entry 2026-04-21 04:50:43 +09:00
YeonGyu-Kim
f266505546 ROADMAP #134: no run/correlation ID at session boundary — session.id missing from startup event and status JSON; observer must infer session identity from timing 2026-04-21 01:55:42 +09:00
YeonGyu-Kim
50e3fa3a83 docs: add --output-format to diagnostic verb help text
Updated LocalHelpTopic help strings to surface --output-format support:
- Status, Sandbox, Doctor, Acp all now show [--output-format <format>]
- Added 'Formats: text (default), json' line to each

Diagnostic verbs support JSON output but help text didn't advertise it.
Post-#127 fix: help text now matches actual CLI surface.

Verified: cargo build passes, claw doctor --help shows output-format.

Refs: #127
2026-04-20 21:32:02 +09:00
YeonGyu-Kim
a51b2105ed docs: add JSON output example for diagnostic verbs post-#127
USAGE.md now documents:
-  for machine-readable diagnostics
- Note about parse-time rejection of invalid suffix args (post-#127 fix)

Verifies that diagnostic verbs support JSON output for scripting,
and documents the behavior change from #127 (invalid args rejected
at parse time instead of falling through to prompt dispatch).

Refs: #127
2026-04-20 21:01:10 +09:00
YeonGyu-Kim
a3270db602 fix: #127 reject unrecognized suffix args for diagnostic verbs
Diagnostic verbs (help, version, status, sandbox, doctor, state) now
reject unrecognized suffix arguments at parse time instead of silently
falling through to Prompt dispatch.

Fixes: claw doctor --json (and similar) no longer accepts --json silently
and attempts to send it to the LLM as a prompt. Now properly emits:
'unrecognized argument `--json` for subcommand `doctor`'

Joined parser-level trust gap quintet #108 + #117 + #119 + #122 + #127.
Prevents token burn on rejected arguments.

Verified: cargo build --workspace passes, claw doctor --json errors cleanly.

Refs: #127, ROADMAP
2026-04-20 19:23:35 +09:00
YeonGyu-Kim
12f1f9a74e feat: wire ship.prepared provenance emission at bash execution boundary
Adds ship provenance detection and emission in execute_bash_async():
- Detects git push to main/master commands
- Captures current branch, HEAD commit, git user as actor
- Emits ship.prepared event with ShipProvenance payload
- Logs to stderr as interim routing (event stream integration pending)

This is the first wired provenance event — schema (§4.44.5) now has
runtime emission at actual git operation boundary.

Verified: cargo build --workspace passes.
Next: wire ship.commits_selected, ship.merged, ship.pushed_main events.

Refs: §4.44.5.1, ROADMAP #4.44.5
2026-04-20 17:03:28 +09:00
YeonGyu-Kim
2678fa0af5 fix: #124 --model validation rejects malformed syntax at parse time
Adds validate_model_syntax() that rejects:
- Empty strings
- Strings with spaces (e.g., 'bad model')
- Invalid provider/model format

Accepts:
- Known aliases (opus, sonnet, haiku)
- Valid provider/model format (provider/model)

Wired into parse_args for both --model <value> and --model=<value> forms.
Errors exit with clear message before any API calls (no token burn).

Verified:
- 'claw --model "bad model" version' → error, exit 1
- 'claw --model "" version' → error, exit 1
- 'claw --model opus version' → works
- 'claw --model anthropic/claude-opus-4-6 version' → works

Refs: ROADMAP #124 (debbcbe cluster — parser-level trust gap family)
2026-04-20 16:32:17 +09:00
YeonGyu-Kim
b9990bb27c fix: #122 + #125 doctor consistency and git_state clarity
#122: doctor invocation now checks stale-base condition
- Calls run_stale_base_preflight(None) in render_doctor_report()
- Emits stale-base warnings to stderr when branch is behind main
- Fixes inconsistency: doctor 'ok' vs prompt 'stale base' warning

#125: git_state field reflects non-git directories
- When !in_git_repo, git_state = 'not in git repo' instead of 'clean'
- Fixes contradiction: in_git_repo: false but git_state: 'clean'
- Applied in both doctor text output and status JSON

Verified: cargo build --workspace passes.

Refs: ROADMAP #122 (dd73962), #125 (debbcbe)
2026-04-20 16:13:43 +09:00
YeonGyu-Kim
f33c315c93 fix: #122 doctor invocation now checks stale-base condition
Adds run_stale_base_preflight(None) call to render_doctor_report() so that
claw doctor emits stale-base warnings to stderr when the current branch is
behind main. Previously doctor reported 'ok' even when branch was stale,
creating inconsistency with prompt path warnings.

Fixes silent-state inventory gap: doctor now consistent with prompt/repl
stale-base checking. No behavior change for non-stale branches.

Verified: cargo build --workspace passes, no test failures.

Ref: ROADMAP #122 dogfood filing @ dd73962
2026-04-20 15:49:56 +09:00
YeonGyu-Kim
5c579e4a09 §4.44.5.1: file ship event wiring pinpoint (schema landed, wiring missing)
Dogfood cycle 2026-04-20 identified that §4.44.5 ship/provenance event schema
is implemented (ShipProvenance struct, ship.* constructors, tests pass) but
actual git push/merge/commit-range operations do not yet emit these events.

Events remain dead code—constructors exist but are never called during real
workflows. This pinpoint tracks the missing wiring: locating actual git
operation call sites in main.rs/tools/lib.rs/worker_boot.rs and intercepting
to emit ship.prepared/commits_selected/merged/pushed_main with real metadata
(source_branch, commit_range, merge_method, actor, pr_number).

Acceptance: at least one real git push emits all 4 events with actual payload
values, claw state JSON surfaces ship provenance.

Ref: dogfood gaebal-gajae @ 1495672954573291571 (15:30 KST)
2026-04-20 15:30:34 +09:00
YeonGyu-Kim
8a8ca8a355 ROADMAP #4.44.5: Ship/provenance events — implement §4.44.5
Adds structured ship provenance surface to eliminate delivery-path opacity:

New lane events:
- ship.prepared — intent to ship established
- ship.commits_selected — commit range locked
- ship.merged — merge completed with provenance
- ship.pushed_main — delivery to main confirmed

ShipProvenance struct carries:
- source_branch, base_commit
- commit_count, commit_range
- merge_method (direct_push/fast_forward/merge_commit/squash_merge/rebase_merge)
- actor, pr_number

Constructor methods added to LaneEvent for all four ship events.

Tests:
- Wire value serialization for ship events
- Round-trip deserialization
- Canonical event name coverage

Runtime: 465 tests pass
ROADMAP updated with IMPLEMENTED status

This closes the gap where 56 commits pushed to main had no structured
provenance trail — now emits first-class events for clawhip consumption.
2026-04-20 15:06:50 +09:00
YeonGyu-Kim
b0b579ebe9 ROADMAP #133: Blocked-state subphase contract — implement §6.5
Adds BlockedSubphase enum with 7 variants for structured blocked-state reporting:
- blocked.trust_prompt — trust gate blockers
- blocked.prompt_delivery — prompt misdelivery
- blocked.plugin_init — plugin startup failures
- blocked.mcp_handshake — MCP connection issues
- blocked.branch_freshness — stale branch blockers
- blocked.test_hang — test timeout/hang
- blocked.report_pending — report generation stuck

LaneEventBlocker now carries optional subphase field that gets serialized
into LaneEvent data. Enables clawhip to route recovery without pane scraping.

Updates:
- lane_events.rs: BlockedSubphase enum, LaneEventBlocker.subphase field
- lane_events.rs: blocked()/failed() constructors with subphase serialization
- lib.rs: Export BlockedSubphase
- tools/src/lib.rs: classify_lane_blocker() with subphase: None
- Test imports and fixtures updated

Backward-compatible: subphase is Option<>, existing events continue to work.
2026-04-20 15:04:08 +09:00
YeonGyu-Kim
c956f78e8a ROADMAP #4.44.5: Ship/provenance opacity — filed from dogfood
Added structured delivery-path contract to surface branch → merge → main-push
provenance as first-class events. Filed from the 56-commit 2026-04-20 push
that exposed the gap.

Also fixes: ApiError test compilation — add suggested_action: None to 4 sites

- Line ~8414: opaque_provider_wrapper_surfaces_failure_class_session_and_trace
- Line ~8436: retry_exhaustion_uses_retry_failure_class_for_generic_provider_wrapper
- Line ~8499: provider_context_window_errors_are_reframed_with_same_guidance
- Line ~8533: retry_wrapped_context_window_errors_keep_recovery_guidance
2026-04-20 14:35:07 +09:00
12 changed files with 789 additions and 221 deletions

5
.claw.json Normal file
View File

@@ -0,0 +1,5 @@
{
"aliases": {
"quick": "haiku"
}
}

View File

@@ -711,6 +711,49 @@ Acceptance:
- token-risk preflight becomes operational guidance, not just warning text
- first-run users stop getting stuck between diagnosis and manual cleanup
### 4.44.5. Ship/provenance opacity — IMPLEMENTED 2026-04-20
**Status:** Events implemented in `lane_events.rs`. Surface now emits structured ship provenance.
When dogfood work lands on `main`, the delivery path (scoped branch → PR → merge → push vs direct push) and the exact commit set shipped are not surfaced as first-class events. This makes it too easy to lose the boundary between "dogfood fix landed", "what exact commits shipped", and "what review/merge path was actually used." The 56-commit push during 2026-04-20 dogfood (#122/#127/#129/#130/#131/#132) exhibited this gap: work started as scoped pinpoint branches, then collapsed into a direct `origin/main` push with no structured provenance trail.
**Implemented behavior:**
- `ship.prepared` event — intent to ship established
- `ship.commits_selected` event — commit range locked
- `ship.merged` event — merge completed with metadata
- `ship.pushed_main` event — delivery to main confirmed
- All carry `ShipProvenance { source_branch, base_commit, commit_count, commit_range, merge_method, actor, pr_number }`
- `ShipMergeMethod` enum: direct_push, fast_forward, merge_commit, squash_merge, rebase_merge
Required behavior:
When dogfood work lands on `main`, the delivery path (scoped branch → PR → merge → push vs direct push) and the exact commit set shipped are not surfaced as first-class events. This makes it too easy to lose the boundary between "dogfood fix landed", "what exact commits shipped", and "what review/merge path was actually used." The 56-commit push during 2026-04-20 dogfood (#122/#127/#129/#130/#131/#132) exhibited this gap: work started as scoped pinpoint branches, then collapsed into a direct `origin/main` push with no structured provenance trail.
Required behavior:
- emit `ship.provenance` event with: source branch, merge method (PR #, direct push, fast-forward), commit range (first..last), and actor
- distinguish `intentional.ship` (explicit deliverables like #122-#132) from `incidental.rider` (other commits in the push)
- surface in lane events and `claw state` output
- clawhip can report "6 pinpoints shipped, 50 riders, via direct push" without git archaeology
Acceptance:
- no post-hoc human reconstruction needed to answer "what just shipped and by what path"
- delivery path is machine-readable and auditable
Source: gaebal-gajae dogfood observation 2026-04-20 — the very run that exposed the gap.
**Incomplete gap identified 2026-04-20:**
Schema and event constructors implemented in `lane_events.rs::ShipProvenance` and `LaneEvent::ship_*()` methods. **Missing: wiring.** Git push operations in rusty-claude-cli do not yet emit these events. When `git push origin main` executes, no `ship.prepared/commits_selected/merged/pushed_main` events are emitted to observability layer. Events remain dead code (tests-only).
**Next pinpoint (§4.44.5.1):** Ship event wiring
Wire `LaneEvent::ship_*()` emission into actual git push call sites:
1. Locate `git push origin <branch>` command execution(s) in `main.rs`, `tools/lib.rs`, or `worker_boot.rs`
2. Intercept before/after push: emit `ship.prepared` (before merge), `ship.commits_selected` (lock range), `ship.merged` (after merge), `ship.pushed_main` (after push to origin/main)
3. Capture real metadata: `source_branch`, `commit_range`, `merge_method`, `actor`, `pr_number`
4. Route events to lane event stream
5. Verify `claw state` output surfaces ship provenance
Acceptance: git push emits all 4 events with real metadata, `claw state` JSON includes `ship` provenance.
### 4.44. Typed-error envelope contract (Silent-state inventory roll-up)
Claw-code currently flattens every error class — filesystem, auth, session, parse, runtime, MCP, usage — into the same lossy `{type:"error", error:"<prose>"}` envelope. Both human operators and downstream claws lose the ability to programmatically tell what operation failed, which path/resource failed, what kind of failure it was, and whether the failure is retryable, actionable, or terminal. This roll-up locks in the typed-error contract that closes the family of pinpoints currently scattered across **#102 + #129** (MCP readiness opacity), **#127 + #245** (delivery surface opacity), and **#121 + #130** (error-text-lies / errno-strips-context).
@@ -787,7 +830,31 @@ Acceptance:
- channel status updates stay short and machine-grounded
- claws stop inferring state from raw build spam
### 6.5. Blocked-state subphase contract
### 137. Model-alias shorthand regression in test suite — bare alias parsing broken on `feat/134-135-session-identity` branch
**Filed:** 2026-04-21 from dogfood cycle — `cargo test --workspace` on `feat/134-135-session-identity` HEAD (`91ba54d`) shows 3 failing tests.
**Problem:** `tests::parses_bare_prompt_and_json_output_flag`, `tests::multi_word_prompt_still_uses_shorthand_prompt_mode`, and `tests::env_permission_mode_overrides_project_config_default` all panic with:
```
args should parse: "invalid model syntax: 'claude-opus'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)"
```
The #134/#135 session-identity work tightened model-syntax validation but the test fixtures still pass bare `claude-opus` style strings that the new validator rejects. 162 tests pass; only the three tests using legacy bare-alias model names fail.
**Fix shape:**
- Update the three failing test fixtures to use either a valid alias (`opus`, `sonnet`, `haiku`) or a fully-qualified model id (`anthropic/claude-opus-4-6`)
- Alternatively, if `claude-opus` is an intended supported alias, add it to the alias registry
- Verify `cargo test --workspace` returns 0 failures before merging the feat branch to `main`
**Acceptance:**
- `cargo test --workspace` passes with 0 failures on the `feat/134-135-session-identity` branch
- No regression on the 162 tests currently passing
### 133. Blocked-state subphase contract (was §6.5)
**Filed:** 2026-04-20 from dogfood cycle — previous cycle identified §4.44.5 provenance gap, this cycle targets §6.5 implementation.
**Problem:** Currently `lane.blocked` is a single opaque state. Recovery recipes cannot distinguish trust-gate blockers from MCP handshake failures, branch freshness issues, or test hangs. All blocked lanes look the same, forcing pane-scrape triage.
**Concrete implementation:
When a lane is `blocked`, also expose the exact subphase where progress stopped, rather than forcing claws to infer from logs.
Subphases should include at least:
@@ -4967,117 +5034,140 @@ ear], /color [scheme], /effort [low|medium|high], /fast, /summary, /tag [label],
**Source.** Jobdori dogfood 2026-04-20 against `/tmp/jobdori-129-mcp-cred-order` + `/tmp/stale-branch` in response to 10-min cron cycle. Confirmed: `claw doctor` on branch 5 commits behind main says "Status: ok" but `prompt` dispatch would warn "worktree HEAD does not match expected base commit." Gap is a missing invocation of the already-correct `run_stale_base_preflight()` in the `doctor` action handler. Joins **Boot preflight / doctor contract (#80#83, #114)** family — doctor is the single machine-readable preflight surface; missing checks degrade operator trust. Also relates to **Silent-state inventory** cluster (#102/#127/#129/#245) because stale-base is a runtime truth ("my branch is behind main") that the preflight surface (doctor) does not expose.
## Pinpoint #131. `claw export` positional argument silently treated as output PATH, not session reference, causing wrong-session export with no warning
## Pinpoint #135. `claw status --json` missing `active_session` boolean and `session.id` cross-reference — two surfaces that should be unified are inconsistent
**The clawability gap.** `claw export <session-id> --output /path/to/out.md` does NOT export the session named `<session-id>`. The positional arg `<session-id>` is parsed as the output PATH, and the session reference defaults to `latest`. Result: operator thinks they're exporting session A, gets session B (latest) silently. No error, no warning.
**Gap.** `claw status --json` exposes a snapshot of the runtime state but does not include (1) a stable `session.id` field (filed as #134 — the fix from the other side is to emit it in lane events; the consumer side needs it queryable via `status` too) and (2) an `active_session: bool` that tells an orchestrator whether the runtime currently has a live session in flight. An external orchestrator (Clawhip, remote agent) running `claw status --json` after sending a prompt has no machine-readable way to confirm whether the session is alive, idle, or stalled without parsing log output.
**Trace path.**
- `rust/crates/rusty-claude-cli/src/main.rs:6018-6038` — `parse_export_args()`: when no `--session` flag is provided, `session_reference = LATEST_SESSION_REFERENCE`. The first positional arg gets assigned to `output_path` (the loop's `other if output_path.is_none()` arm).
- The user's intent ("export this session") is silently rewritten to "export latest session, naming the output file what you typed."
- There is no validation that the positional arg looks like a path (e.g., has a file extension) versus a session ID.
**Trace path.**
- `claw status --json` (dispatcher in `main.rs` `CliAction::Status`) renders a `StatusReport` struct that includes `git_state`, `config`, `model`, `provider` — but no `session_id` or `active_session` fields.
- `claw status` (text mode) also omits both.
- The `session.id` fix from #134 introduces a UUID at session init; it should be threaded through to `StatusReport` so the round-trip is complete: emit on startup event → queryable via `status --json` → correlatable in lane events.
**Reproduce.**
```
$ claw export this-session-does-not-exist --output /tmp/out.md
Export
Result wrote markdown transcript
File /tmp/out.md
Session session-1775777421902-1 <-- LATEST, not requested!
Messages 0
```
With explicit `--session` flag, behavior is correct:
```
$ claw export --session this-session-does-not-exist --output /tmp/out.md
error: session not found: this-session-does-not-exist
```
**Fix shape (~30 lines).**
1. Add `session_id: Option<String>` and `active_session: bool` to `StatusReport` struct. Both `null`/`false` when no session is active. When a session is running, `session_id` is the same UUID emitted in the startup lane event (#134).
2. Thread the session state into the `status` handler via a shared `Arc<Mutex<SessionState>>` or equivalent (same mechanism #134 uses for startup event emission).
3. Text-mode `claw status` surfaces the value: `Session: active (id: abc123)` or `Session: idle`.
4. Regression tests: (a) `claw status --json` before any prompt → `active_session: false, session_id: null`. (b) `claw status --json` during a prompt session → `active_session: true, session_id: <uuid>`. (c) UUID matches the `session.id` in the first lane event of the same run.
**Why this matters.**
1. **Data confusion.** Operator believes they're exporting session A, gets session B silently. If session B contains sensitive data the user didn't intend to share, this is leakage.
2. **No file extension validation.** The positional arg becomes a filename even if it has no extension or looks like a session ID.
3. **Asymmetric flag/positional behavior.** `--session FOO` errors on missing session; positional FOO silently substitutes latest. This violates least-surprise.
4. **Joins silent-state inventory family** (#102, #127, #129, #130) — same pattern: silent fallback to default behavior instead of erroring on unrecognized input.
**Acceptance.** An orchestrator can poll `claw status --json` and determine: is there a live session? What is its correlation ID? Does it match the ID from the last startup event? This closes the round-trip opened by #134.
**Blocker.** Depends on #134 (session.id generation at init). Can be filed and implemented together.
**Source.** Jobdori dogfood 2026-04-21 06:53 KST on main HEAD `2c42f8b` during recurring cron cycle. Direct sibling of #134 — #134 covers the event-emission side, #135 covers the query side. Joins **Session identity completeness** (§4.7) and **status surface completeness** cluster (#80/#83/#114/#122). Natural bundle: **#134 + #135** closes the full session-identity round-trip. Session tally: ROADMAP #135.
## Pinpoint #134. No run/correlation ID at session boundary — every observer must infer session identity from timing or prompt content
**Gap.** When a `claw` session starts, no stable correlation ID is emitted in the first structured event (or any event). Every observer — lane event consumer, log aggregator, Clawhip router, test harness — has to infer session identity from timing proximity or prompt content. If two sessions start in close succession there is no unambiguous way to attribute subsequent events to the correct session. `claw status --json` returns session metadata but does not expose an opaque stable ID that could be used as a correlation key across the event stream.
**Fix shape.**
1. **Heuristic detection in `parse_export_args()`** — if the positional arg has no path separator AND no file extension AND matches the pattern of a known session ID (e.g., `session-\d+-\d+`), treat it as a session reference, not an output path. Emit a warning if ambiguous.
2. **Or stricter:** require explicit `--session` and `--output` flags; deprecate positional fallback. Reject ambiguous positional with: `error: ambiguous argument 'X'; use --session X for session reference or --output X for output path`.
3. **Regression tests:** (a) positional looks like session ID → treated as session, (b) positional looks like path → treated as output_path, (c) ambiguous → error with hint.
- Emit `session.id` (opaque, stable, scoped to this boot) in the first structured event at startup
- Include same ID in all subsequent lane events as `session_id` field
- Expose via `claw status --json` so callers can retrieve the active session's ID from outside
- Add regression: golden-fixture asserting `session.id` is present in startup event and value matches across a multi-event trace
**Acceptance.** `claw export <session-id-pattern>` either errors (if session doesn't exist) or exports the requested session. Cannot silently substitute `latest` when user names a specific reference.
**Acceptance.** Any observer can correlate all events from a session using `session_id` without parsing prompt content or relying on timestamp proximity. `claw status --json` exposes the current session's ID.
**Blocker.** None. Pure parser-level fix; ~30 lines in `parse_export_args()`.
**Blocker.** None. Requires a UUID/nanoid generated at session init and threaded through the event emitter.
**Source.** Jobdori dogfood 2026-04-20 against `/tmp/jobdori-130-export-error/rust` discovered while auditing #130 export error path. Joins **Silent-state inventory** (#102, #127, #129, #130) family as 5th — silent fallback to default instead of erroring. Joins **Parser-level trust gap quintet** (#108, #117, #119, #122, #127) as 6th — same `_other` fall-through pattern at the per-verb arg parser level. Joins **Truth-audit / diagnostic-integrity**wrong session is exported without any signal to the operator. Natural bundle: **#130 + #131** — export-surface integrity pair: error envelope (#130) + correct session targeting (#131). Both required for `export` verb to be clawable. Session tally: ROADMAP #131.
**Source.** Jobdori dogfood 2026-04-21 01:54 KST on main HEAD `50e3fa3` during recurring cron cycle. Joins **Session identity completeness at creation time** (ROADMAP §4.7) — §4.7 covers identity fields at creation time; #134 covers the stable correlation handle that ties those fields to downstream events. Joins **Event provenance / environment labeling** (§4.6)provenance requires a stable anchor; without `session.id` the provenance chain is broken at the root. Natural bundle with **#241** (no startup run/correlation id, filed by gaebal-gajae 2026-04-20) — #241 approached from the startup cluster; #134 approaches from the event-stream observer side. Same root fix closes both. Session tally: ROADMAP #134.
## Pinpoint #132. Global `--output-format json` error renderer flattens every typed error variant into `{type:"error", error:<prose>}`, erasing `§4.44` typed envelope structure at the final serialization boundary
## Pinpoint #136. `--compact` flag output is not machine-readable — compact turn emits plain text instead of JSON when `--output-format json` is also passed
**The clawability gap.** The runtime already defines *five* typed error enums — `SessionError`, `ConfigError`, `McpServerManagerError`, `PromptBuildError`, `SessionControlError` — each with variant discriminators that carry real structure (`Io(_)`, `Json(_)`, `Format(_)`, etc.). Every CLI-side emission boundary for `--output-format json`, however, calls `error.to_string()` and wraps the resulting prose in `{"type":"error","error":<message>}`. The variant tag is destroyed, the `io::ErrorKind` is destroyed, the operation name is destroyed, the resource target is destroyed, the actionable hint is destroyed, and the retryable flag is destroyed — *at the final renderer boundary, after the fix-work for §4.44 + #130 already produced structure upstream.* Result: the `export` fix (#130) surfaces typed fields in text mode but still collapses to `{type, error}` in JSON mode, making `§4.44` half-real wherever the renderer sits. Any downstream claw dispatching on `error.kind` gets `undefined` everywhere.
**Gap.** `claw --compact <prompt>` runs a prompt turn with compacted output (tool-use suppressed, final assistant text only). But `run_with_output()` routes on `(output_format, compact)` with an explicit early-return match: `CliOutputFormat::Text if compact => run_prompt_compact(input)`. The `CliOutputFormat::Json` branch is never reached when `--compact` is set. Result: passing `--compact --output-format json` silently produces plain-text output — the compact flag wins and the format flag is silently ignored. No warning or error is emitted.
**Trace path.**
- `rust/crates/rusty-claude-cli/src/main.rs:120-128` — `emit_cli_error()` top-level error emission: `serde_json::json!({ "type": "error", "error": message })`. `message: &str`. All kind / operation / target / errno / hint / retryable discarded at this exact line.
- `rust/crates/rusty-claude-cli/src/main.rs:2174-2178` — `resume_session()` session-load failure: `"error": format!("failed to restore session: {error}")`. Inner `SessionError::Io / Json / Format` variant erased via `Display`.
- `rust/crates/rusty-claude-cli/src/main.rs:2258-2260, 2295-2298` — resume command parse/dispatch failures: `"error": error.to_string()`. `PromptBuildError` / `SessionControlError` variant information destroyed.
- `rust/crates/rusty-claude-cli/src/main.rs:2225-2227, 2243-2247` — unsupported-command paths: `{type: "error", error: <prose>}`; no `kind:"usage"` discriminant even though `§4.44` explicitly requires this to gate the `Run claw --help for usage` trailer.
- `rust/crates/rusty-claude-cli/src/main.rs:3045-3051` — broad-cwd preflight: flat `{type, error: <message>}`. Recoverable-via-flag case (`--allow-broad-cwd`) carries no `hint` and no `retryable` field.
- `rust/crates/rusty-claude-cli/src/main.rs:3444` — MCP list-resources failure aggregation: `failures.push(json!({ "server": name, "error": error.to_string() }))`. Per-server typed `McpServerManagerError` loss.
- `rust/crates/runtime/src/session.rs:127-132` — `pub enum SessionError { Io(std::io::Error), Json(JsonError), Format(String) }` + `Display` impl that writes ONLY the inner string for each arm. The enum tag is never serialized.
- `rust/crates/runtime/src/config.rs:191+`, `mcp_stdio.rs:254+`, `prompt.rs:11+`, `session_control.rs:354+` — four more typed error enums with identical structural-loss pattern at the CLI emission boundary.
- Contrast: `rust/crates/rusty-claude-cli/src/main.rs:11537` — search JSON already emits `failed_servers[].error.context.transport`, proving a *nested* typed error shape is already supported by one call site. The other ~10 emission sites simply do not use it.
**Trace path.**
- `rust/crates/rusty-claude-cli/src/main.rs:3872-3879` — `run_with_output()` match:
```
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
CliOutputFormat::Text => self.run_turn(input),
CliOutputFormat::Json => self.run_prompt_json(input),
```
The `Json` arm is unreachable when `compact = true` because the first arm matches first regardless of `output_format`.
- `run_prompt_compact()` at line 3879 calls `println!("{final_text}")` — always plain text, no JSON envelope.
- `run_prompt_json()` at line 3891 wraps output in a JSON object with `message`, `model`, `iterations`, `usage`, `tool_uses`, `tool_results`, etc.
**Reproduce.**
```
# Success case — typed shape works upstream (#130 fix landed)
$ claw export --output /tmp/out.md
# Failure case — JSON mode flattens everything
$ claw --output-format json export --output /tmp/nonexistent/out.md
{"type":"error","error":"failed to write transcript: No such file or directory (os error 2)"}
# vs. §4.44 required shape (produced upstream by #130 but erased here):
# {"type":"error","error":{"kind":"filesystem","operation":"export.write",
# "target":"/tmp/nonexistent/out.md","errno":"ENOENT",
# "hint":"intermediate directory does not exist; try mkdir -p /tmp/nonexistent first",
# "retryable":false}}
```
Five more variant pairs reproduce the same flattening (SessionError::Json vs Format, ConfigError variants, McpServerManagerError variants, PromptBuildError variants, SessionControlError variants). All collapse to the same `{type:"error", error:<prose>}` shape. A downstream claw cannot distinguish "session file is corrupt JSON" from "session file has wrong format" from "session file missing on disk" — three different recovery recipes, one indistinguishable envelope.
**Fix shape (~20 lines).**
1. Add a `CliOutputFormat::Json if compact` arm (or merge compact flag into `run_prompt_json` as a parameter) that produces a JSON object with `message: <final_text>` and a `compact: true` marker. Tool-use fields remain present but empty arrays (consistent with compact semantics — tools ran but are not returned verbatim).
2. Emit a warning or `error.kind: "flag_conflict"` if conflicting flags are passed in a way that silently wins (or document the precedence explicitly in `--help`).
3. Regression tests: `claw --compact --output-format json <prompt>` must produce valid JSON with at minimum `{message: "...", compact: true}`.
**Why this matters.**
1. **`§4.44` is half-real.** The contract exists upstream (ExportError in #130 carries `kind/operation/target/errno/hint/retryable`) but the final renderer boundary strips it back to a string. Every fix that conforms to §4.44 upstream gets erased downstream wherever `--output-format json` is active. The contract is only enforced if the renderer also preserves the shape.
2. **#130 is text-surface-only until this lands.** `claw export` with the #130 patch shows structured errors in text mode and flat strings in JSON mode. A clawhip orchestrator consuming `--output-format json` sees exactly the same envelope it saw before #130 was filed. The human-facing pain is fixed; the machine-facing pain is not.
3. **Runtime → CLI boundary is the single point of loss.** Every typed error enum reaches `main.rs` intact. `main.rs` then calls `.to_string()` once and discards everything. Fixing this means *one* serialization helper and *one* refactor pass across ~11 emission sites, not five crate-level refactors.
4. **`Run claw --help for usage` trailer is still ungated.** `§4.44` requires gating on `error.kind == "usage"`. The renderer has no `kind` field to gate on. Trailer is either always-on or always-off, never correctly selective.
5. **Joins silent-state / truth-audit family** (#80#131) — typed information exists in the runtime but is *discarded at the output boundary*, matching the "runtime-knows / diagnostic-surface-doesn't" pattern of #102, #127, #129, #130.
6. **Joins JSON-envelope asymmetry family** (#90, #91, #92, #110, #115, #116) — `{type, error}` is the *fake* envelope; the real envelope per §4.44 is `{type, error: {kind, operation, target, errno, hint, retryable, message}}`. Every site currently emits the fake shape.
**Acceptance.** An orchestrator that requests compact output for token efficiency AND machine-readable JSON gets both. Silent flag override is never a correct behavior for a tool targeting machine consumers.
**Fix shape.**
1. **Introduce `ErrorEnvelope` type** in `rust/crates/runtime/src/error_envelope.rs`:
```rust
#[derive(Debug, Serialize)]
pub struct ErrorEnvelope {
pub kind: ErrorKind, // filesystem | permission | usage | auth | config | session | mcp | parse | runtime | invalid_path
pub operation: String, // e.g. "export.write", "session.restore", "mcp.list_resources"
pub target: Option<String>, // path, URL, server name, session id
pub errno: Option<String>, // ENOENT, EPERM, etc. when io::Error
pub hint: Option<String>, // actionable remediation
pub retryable: bool,
pub message: String, // human-readable fallback (== current prose)
}
```
Already conforms to the ExportError shape shipped in #130 — literal superset/rename.
2. **Add `From<SessionError>`, `From<ConfigError>`, `From<McpServerManagerError>`, `From<PromptBuildError>`, `From<SessionControlError>` impls** that map each variant to the correct `ErrorKind` and fill `errno` for `::Io(_)` arms, `hint` for `::Format(_)` arms, etc. One function per enum, five total. ~150 lines.
3. **Refactor the ~11 CLI emission sites** to call a single helper `emit_json_error(output_format, envelope)` that serializes the full envelope instead of `{type, error: <string>}`. Backward-compat: keep `message` field populated with the same prose current consumers already parse. ~60 lines net change.
4. **Gate the `Run claw --help for usage` trailer** on `envelope.kind == ErrorKind::Usage` as §4.44 requires. Text mode only; JSON mode never adds trailer.
5. **Golden-fixture regression lock.** `rust/crates/rusty-claude-cli/tests/error_envelope_golden.rs` — one fixture per ErrorKind variant × both output formats. Any future flattening of the envelope fails the fixture.
6. **Migration note in USAGE.md / CLAUDE.md**: `--output-format json` errors now carry typed envelopes; consumers parsing only `error` as a string continue to work via the `message` field but should migrate to reading `kind`/`operation`/`target`.
**Blocker.** None. Additive change to existing match arms.
**Regression tests.**
- (a) `claw --output-format json export --output /tmp/nonexistent/out.md` → stderr JSON has `error.kind == "filesystem"`, `error.operation == "export.write"`, `error.errno == "ENOENT"`, `error.hint` populated, `error.retryable == false`.
- (b) `claw --output-format json resume /path/to/corrupt-session.json` → `error.kind == "session"`, `error.operation == "session.restore"`, `error.target == "/path/to/corrupt-session.json"`, message distinguishes Io vs Json vs Format variants via `error.errno` / `error.hint` fields.
- (c) `claw --output-format json doctor --allow-broad-cwd=bogus` → `error.kind == "usage"`, trailer absent from JSON output.
- (d) `claw --output-format json mcp list-resources` with one dead server → `failed_servers[].error.kind == "mcp"`, `operation == "mcp.list_resources"`, `target == "<server-name>"`, `retryable == true`.
- (e) Text mode unchanged: `claw export --output /tmp/nonexistent/out.md` still prints exactly the same human-readable line #130 already ships.
- (f) Golden fixture: each ErrorKind variant's JSON envelope byte-identical to fixture; any drift fails CI.
**Source.** Jobdori dogfood 2026-04-21 12:25 KST on main HEAD `8b52e77` during recurring cron cycle. Joins **Output format completeness** cluster (#90/#91/#92/#127/#130) — all surfaces that produce inconsistent or plain-text fallbacks when JSON is requested. Also joins **CLI/REPL parity** (§7.1) — compact is available as both `--compact` flag and `/compact` REPL command; JSON output gap affects only the flag path. Session tally: ROADMAP #136.
**Acceptance.** Every CLI-side `--output-format json` error emission carries a full §4.44 envelope. `error.kind` is non-null and dispatchable. `error.operation`, `error.target`, and at least one of `error.errno` / `error.hint` populated for every kind where the runtime knows them. `Run claw --help for usage` trailer appears only on `kind: "usage"` errors. Existing consumers reading `error` as a prose string continue to work via the `message` field (backward-compat additive, not breaking).
## Pinpoint #138. Dogfood cycle report-gate opacity — nudge surface collapses "bundle converged", "follow-up landed", and "pre-existing flake only" into single closure shape
**Blocker.** None. All upstream typed enums already exist. ExportError from #130 already proves the envelope shape. Work is purely at the CLI serialization boundary: one new `ErrorEnvelope` type, five `From` impls, ~11 call-site refactors, one golden fixture. Ballpark 250 lines added, ~40 removed.
**Gap.** When a dogfood nudge triggers on a branch with landed work, the report surface emits status like "fixed 3 tests, pushed branch, 1 unrelated red remains" — but downstream nudges cannot distinguish:
1. `bundle converged, merge-ready` (e.g., #134/#135 branch after fixes)
2. `follow-up landed on main, branch still valid` (e.g., #137 + #136 fixes after #134/#135 was ready)
3. `only pre-existing flake remains, no new regressions` (e.g., `resume_latest...` test failure on main that also fails on feature branch)
4. `work still in flight, blocker not yet resolved`
5. `merged and closed, re-nudge is a dup`
**Source.** Jobdori dogfood 2026-04-20 on `/tmp/jobdori-130-export-error/rust` (HEAD `93da4f1`) during 10-min cycle after gaebal-gajae audit of #130 commit d305178. Commit body self-declares the debt: *"JSON mode still uses string error rendering — separate concern requiring global error renderer refactor (tracked for follow-up cycle)."* Gaebal-gajae framing (2026-04-20 14:08 KST): *"typed errors exist, but JSON error rendering still erases them into top-level strings."* Joins **`§4.44` Typed-error envelope contract** — this is the renderer-side enforcement that closes the contract's serialization boundary. Joins **JSON-envelope asymmetry family** (#90, #91, #92, #110, #115, #116) — 7th entry, highest-leverage because it gates every future fix's surface. Joins **Silent-state inventory** (#102, #127, #129, #130, #131) — 6th entry, because typed truth exists in the runtime but the CLI boundary silently discards it. Joins **Truth-audit / diagnostic-integrity** (#80#131) as 17th. Joins **Claude Code migration parity** — Claude Code's JSON error shape is typed; claw-code's is flat. Natural bundle: **#130 + #132** — export-surface typed errors (#130, text mode) + global JSON envelope enforcement (#132, machine mode). Both needed for `--output-format json` to be clawable end-to-end. Session tally: ROADMAP #132.
Result: repeat nudges look identical whether the prior work converged or is still broken. Claws re-open what was already resolved, burning cycles on rediscovery.
**Concrete example from this session:**
- 14:30 nudge triggered on bundle already clear (14:25)
- Reported finding was "nudge closure-state opacity" but manifested as "should we re-nudge or not?"
- No explicit surface like "status: done", "last-updated: 2026-04-21T14:25", "next-action: none" that stops re-nudges on unchanged state
**Fix shape (~30-50 lines, surfaces not code).**
1. Dogfood report should carry an explicit **closure state** field: `converged`, `follow-up-landed`, `pre-existing-flake-only`, `in-flight`, `merged`, `dup`.
2. Each state has a **last-updated timestamp** (when report was filed) and **next-action** (null if converged, or describe blocker).
3. Nudge logic checks prior report state: if `converged` + timestamp < 10 min old, skip nudge and post "still converged as of HH:MM, no action".
4. If state changed (e.g., new commits landed), emit **state transition** explicitly: "bundle done (14:25) → follow-up landed (14:42)".
5. Store closure state in a **shared metadata surface** (Discord message edit, ROADMAP inline, or compact JSON file) so next cycle can read it.
**Acceptance.**
- Repeat nudges on converged work are replaced with "no change since last report" (skip).
- State transitions are explicit: "was X, now Y" instead of ambiguous "X and also Y".
- Claws can scan closure states and prioritize fresh work over already-handled bundles.
**Blocker.** Design question: **where should closure state live?** Options:
- Edit the prior Discord message with a closure tag (e.g., 🟢 CONVERGED).
- Add a `.dogfood-closure.json` file to the worktree branch that tracks state.
- File a new ROADMAP entry per bundle completion (meta-tracking).
- Embedded in claw-code CLI output (machine-readable, but creates coupling).
Current state is **design question unresolved**. Implementation is straightforward once closure-state model is settled.
**Source.** Jobdori dogfood 2026-04-21 14:25-14:47 KST — multi-cycle convergence pattern exposed by repeat nudges on #134/#135 bundle. Joins **Dogfood loop observability** (related to earlier §4.7 session-identity, but one level up — session-identity is plumbing, closure-state is the **reporting contract**). Also joins **False-green report gating** (from 14:05 finding) — this is the downstream effect: unclear reports beget re-nudges on stale work.
Session tally: ROADMAP #138.
### Evidence for #138 — feat/134-135-session-identity branch is pushed but no PR was opened (2026-04-21 15:05)
**Concrete gap observed:**
- Branch `feat/134-135-session-identity` pushed to `origin` at `7235260` (commits `f55612e`, `2b7095e`, `230d97a`, `7235260`)
- Dogfood loop declared bundle "merge-ready" at 14:25
- ~40 min elapsed; no PR opened, no merge, branch still unmerged
- Meanwhile #136 and #137 landed directly on main (`a8beca1`, `21adae9`) without going through the branch
**Direct verification of #135 on main:**
- `env -i $BIN status --output-format json` on main HEAD `768c1ab` shows `active_session: null, session_id: null`
- Fields exist in JSON schema (added by schema-only?) but values are None because the producer plumbing (`#134`) is not on main
- #135 consumer relies on #134 producer; both live on feat/134-135 only
**Impact:**
- `claw status --output-format json` on main returns JSON without the #135 session identity signals (because they're only on feat/134-135)
- Orchestrators that shipped using the 13:00 "round-trip proof" report believing #134+#135 was merge-ready will get null fields
- Evidence for #138: "closure-state" = "pushed branch" ≠ "merged" ≠ "in-PR" — nudge surface collapses all three
**Proposed closure-state transition:**
1. `pushed` — branch exists on origin but no PR (current state for feat/134-135)
2. `in-PR` — PR open, review pending
3. `approved` — PR approved, awaiting merge
4. `merged` — in main
5. `deployed` — if applicable
6. `abandoned` — PR closed without merge
Nudge surface should report explicit state + timestamp: `"feat/134-135 state=pushed (no PR) since 13:00; no closure action taken"` instead of ambiguous "merge-ready."
**Token/permission note:**
- `code-yeongyu` token has write access to push branches to `ultraworkers/claw-code` but lacks `createPullRequest` permission (GraphQL 404)
- Issues are disabled on the repo (can't open issue-based tracking)
- Means closure-state tracking must live inside the repo (ROADMAP) or in an external surface (Discord message edits, `.dogfood-closure.json`)
**Filed:** 2026-04-21 15:05 KST as evidence for #138 by Jobdori dogfood loop.

View File

@@ -43,6 +43,15 @@ cd rust
/doctor
```
Or run doctor directly with JSON output for scripting:
```bash
cd rust
./target/debug/claw doctor --output-format json
```
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
### Interactive REPL
```bash

5
rust/.claw.json Normal file
View File

@@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "dontAsk"
}
}

4
rust/.gitignore vendored
View File

@@ -1,3 +1,7 @@
target/
.omx/
.clawd-agents/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
.clawhip/

15
rust/CLAUDE.md Normal file
View File

@@ -0,0 +1,15 @@
# CLAUDE.md
This file provides guidance to Claw Code (clawcode.dev) when working with code in this repository.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## Verification
- Run Rust verification from the repo root: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.claw.json`; reserve `.claw/settings.local.json` for machine-local overrides.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.

View File

@@ -8,6 +8,7 @@ use tokio::process::Command as TokioCommand;
use tokio::runtime::Builder;
use tokio::time::timeout;
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
use crate::sandbox::{
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
SandboxConfig, SandboxStatus,
@@ -102,11 +103,76 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
}
/// Detect git push to main and emit ship provenance event
fn detect_and_emit_ship_prepared(command: &str) {
let trimmed = command.trim();
// Simple detection: git push with main/master
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
// Emit ship.prepared event
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let provenance = ShipProvenance {
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
base_commit: get_head_commit().unwrap_or_default(),
commit_count: 0, // Would need to calculate from range
commit_range: "unknown..HEAD".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
pr_number: None,
};
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
// Log to stderr as interim routing before event stream integration
eprintln!(
"[ship.prepared] branch={} -> main, commits={}, actor={}",
provenance.source_branch, provenance.commit_count, provenance.actor
);
}
}
fn get_current_branch() -> Option<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_head_commit() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_git_actor() -> Option<String> {
let name = Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
Some(name)
}
async fn execute_bash_async(
input: BashCommandInput,
sandbox_status: SandboxStatus,
cwd: std::path::PathBuf,
) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout {

View File

@@ -38,6 +38,15 @@ pub enum LaneEventName {
BranchStaleAgainstMain,
#[serde(rename = "branch.workspace_mismatch")]
BranchWorkspaceMismatch,
/// Ship/provenance events — §4.44.5
#[serde(rename = "ship.prepared")]
ShipPrepared,
#[serde(rename = "ship.commits_selected")]
ShipCommitsSelected,
#[serde(rename = "ship.merged")]
ShipMerged,
#[serde(rename = "ship.pushed_main")]
ShipPushedMain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -383,11 +392,31 @@ pub fn dedupe_terminal_events(events: &[LaneEvent]) -> Vec<LaneEvent> {
result
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum BlockedSubphase {
#[serde(rename = "blocked.trust_prompt")]
TrustPrompt { gate_repo: String },
#[serde(rename = "blocked.prompt_delivery")]
PromptDelivery { attempt: u32 },
#[serde(rename = "blocked.plugin_init")]
PluginInit { plugin_name: String },
#[serde(rename = "blocked.mcp_handshake")]
McpHandshake { server_name: String, attempt: u32 },
#[serde(rename = "blocked.branch_freshness")]
BranchFreshness { behind_main: u32 },
#[serde(rename = "blocked.test_hang")]
TestHang { elapsed_secs: u32, test_name: Option<String> },
#[serde(rename = "blocked.report_pending")]
ReportPending { since_secs: u32 },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEventBlocker {
#[serde(rename = "failureClass")]
pub failure_class: LaneFailureClass,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub subphase: Option<BlockedSubphase>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -404,6 +433,29 @@ pub struct LaneCommitProvenance {
pub lineage: Vec<String>,
}
/// Ship/provenance metadata — §4.44.5
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShipProvenance {
pub source_branch: String,
pub base_commit: String,
pub commit_count: u32,
pub commit_range: String,
pub merge_method: ShipMergeMethod,
pub actor: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pr_number: Option<u32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShipMergeMethod {
DirectPush,
FastForward,
MergeCommit,
SquashMerge,
RebaseMerge,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaneEvent {
pub event: LaneEventName,
@@ -487,16 +539,56 @@ impl LaneEvent {
#[must_use]
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
let mut event = Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
.with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
}
event
}
#[must_use]
pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
let mut event = Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
.with_failure_class(blocker.failure_class)
.with_detail(blocker.detail.clone())
.with_detail(blocker.detail.clone());
if let Some(ref subphase) = blocker.subphase {
event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize"));
}
event
}
/// Ship prepared — §4.44.5
#[must_use]
pub fn ship_prepared(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
/// Ship commits selected — §4.44.5
#[must_use]
pub fn ship_commits_selected(
emitted_at: impl Into<String>,
commit_count: u32,
commit_range: impl Into<String>,
) -> Self {
Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at)
.with_detail(format!("{} commits: {}", commit_count, commit_range.into()))
}
/// Ship merged — §4.44.5
#[must_use]
pub fn ship_merged(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
/// Ship pushed to main — §4.44.5
#[must_use]
pub fn ship_pushed_main(emitted_at: impl Into<String>, provenance: &ShipProvenance) -> Self {
Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at)
.with_data(serde_json::to_value(provenance).expect("ship provenance should serialize"))
}
#[must_use]
@@ -570,9 +662,10 @@ mod tests {
use super::{
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
LaneOwnership, SessionIdentity, WatcherAction,
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
WatcherAction,
};
#[test]
@@ -601,6 +694,10 @@ mod tests {
LaneEventName::BranchWorkspaceMismatch,
"branch.workspace_mismatch",
),
(LaneEventName::ShipPrepared, "ship.prepared"),
(LaneEventName::ShipCommitsSelected, "ship.commits_selected"),
(LaneEventName::ShipMerged, "ship.merged"),
(LaneEventName::ShipPushedMain, "ship.pushed_main"),
];
for (event, expected) in cases {
@@ -641,6 +738,10 @@ mod tests {
let blocker = LaneEventBlocker {
failure_class: LaneFailureClass::McpStartup,
detail: "broken server".to_string(),
subphase: Some(BlockedSubphase::McpHandshake {
server_name: "test-server".to_string(),
attempt: 1,
}),
};
let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker);
@@ -686,6 +787,34 @@ mod tests {
);
}
#[test]
fn ship_provenance_events_serialize_to_expected_wire_values() {
let provenance = ShipProvenance {
source_branch: "feature/provenance".to_string(),
base_commit: "dd73962".to_string(),
commit_count: 6,
commit_range: "dd73962..c956f78".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: "Jobdori".to_string(),
pr_number: None,
};
let prepared = LaneEvent::ship_prepared("2026-04-20T14:30:00Z", &provenance);
let prepared_json = serde_json::to_value(&prepared).expect("ship event should serialize");
assert_eq!(prepared_json["event"], "ship.prepared");
assert_eq!(prepared_json["data"]["commit_count"], 6);
assert_eq!(prepared_json["data"]["source_branch"], "feature/provenance");
let pushed = LaneEvent::ship_pushed_main("2026-04-20T14:35:00Z", &provenance);
let pushed_json = serde_json::to_value(&pushed).expect("ship event should serialize");
assert_eq!(pushed_json["event"], "ship.pushed_main");
assert_eq!(pushed_json["data"]["merge_method"], "direct_push");
let round_trip: LaneEvent =
serde_json::from_value(pushed_json).expect("ship event should deserialize");
assert_eq!(round_trip.event, LaneEventName::ShipPushedMain);
}
#[test]
fn commit_events_can_carry_worktree_and_supersession_metadata() {
let event = LaneEvent::commit_created(

View File

@@ -84,9 +84,10 @@ pub use hooks::{
};
pub use lane_events::{
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
LaneOwnership, SessionIdentity, WatcherAction,
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
WatcherAction,
};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,

View File

@@ -447,11 +447,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
index += 2;
}
flag if flag.starts_with("--model=") => {
model = resolve_model_alias_with_config(&flag[8..]);
let value = &flag[8..];
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
index += 1;
}
"--output-format" => {
@@ -704,17 +707,32 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
reasoning_effort,
allow_broad_cwd,
),
_other => Ok(CliAction::Prompt {
prompt: rest.join(" "),
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
}),
other => {
if rest.len() == 1 && looks_like_subcommand_typo(other) {
if let Some(suggestions) = suggest_similar_subcommand(other) {
let mut message = format!("unknown subcommand: {other}.");
if let Some(line) = render_suggestion_line("Did you mean", &suggestions) {
message.push('\n');
message.push_str(&line);
}
message.push_str(
"\nRun `claw --help` for the full list. If you meant to send a prompt literally, use `claw prompt <text>`.",
);
return Err(message);
}
}
Ok(CliAction::Prompt {
prompt: rest.join(" "),
model,
output_format,
allowed_tools,
permission_mode,
compact,
base_commit,
reasoning_effort: reasoning_effort.clone(),
allow_broad_cwd,
})
}
}
}
@@ -743,6 +761,31 @@ fn parse_single_word_command_alias(
permission_mode_override: Option<PermissionMode>,
output_format: CliOutputFormat,
) -> Option<Result<CliAction, String>> {
if rest.is_empty() {
return None;
}
// Diagnostic verbs (help, version, status, sandbox, doctor, state) accept only the verb itself
// or --help / -h as a suffix. Any other suffix args are unrecognized.
let verb = &rest[0];
let is_diagnostic = matches!(
verb.as_str(),
"help" | "version" | "status" | "sandbox" | "doctor" | "state"
);
if is_diagnostic && rest.len() > 1 {
// Diagnostic verb with trailing args: reject unrecognized suffix
if is_help_flag(&rest[1]) && rest.len() == 2 {
// "doctor --help" is valid, routed to parse_local_help_action() instead
return None;
}
// Unrecognized suffix like "--json"
return Some(Err(format!(
"unrecognized argument `{}` for subcommand `{}`",
rest[1], verb
)));
}
if rest.len() != 1 {
return None;
}
@@ -966,6 +1009,65 @@ fn suggest_closest_term<'a>(input: &str, candidates: &'a [&'a str]) -> Option<&'
ranked_suggestions(input, candidates).into_iter().next()
}
fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
const KNOWN_SUBCOMMANDS: &[&str] = &[
"help",
"version",
"status",
"sandbox",
"doctor",
"state",
"dump-manifests",
"bootstrap-plan",
"agents",
"mcp",
"skills",
"system-prompt",
"acp",
"init",
"export",
"prompt",
];
let normalized_input = input.to_ascii_lowercase();
let mut ranked = KNOWN_SUBCOMMANDS
.iter()
.filter_map(|candidate| {
let normalized_candidate = candidate.to_ascii_lowercase();
let distance = levenshtein_distance(&normalized_input, &normalized_candidate);
let prefix_match = common_prefix_len(&normalized_input, &normalized_candidate) >= 4;
let substring_match = normalized_candidate.contains(&normalized_input)
|| normalized_input.contains(&normalized_candidate);
((distance <= 2) || prefix_match || substring_match)
.then_some((distance, *candidate))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| left.cmp(right).then_with(|| left.1.cmp(right.1)));
ranked.dedup_by(|left, right| left.1 == right.1);
let suggestions = ranked
.into_iter()
.map(|(_, candidate)| candidate.to_string())
.take(3)
.collect::<Vec<_>>();
(!suggestions.is_empty()).then_some(suggestions)
}
fn common_prefix_len(left: &str, right: &str) -> usize {
left.chars()
.zip(right.chars())
.take_while(|(l, r)| l == r)
.count()
}
fn looks_like_subcommand_typo(input: &str) -> bool {
!input.is_empty()
&& input
.chars()
.all(|ch| ch.is_ascii_alphabetic() || ch == '-')
}
fn ranked_suggestions<'a>(input: &str, candidates: &'a [&'a str]) -> Vec<&'a str> {
let normalized_input = input.trim_start_matches('/').to_ascii_lowercase();
let mut ranked = candidates
@@ -1035,6 +1137,37 @@ fn resolve_model_alias_with_config(model: &str) -> String {
resolve_model_alias(trimmed).to_string()
}
/// Validate model syntax at parse time.
/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern.
/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars.
fn validate_model_syntax(model: &str) -> Result<(), String> {
let trimmed = model.trim();
if trimmed.is_empty() {
return Err("model string cannot be empty".to_string());
}
// Known aliases are always valid
match trimmed {
"opus" | "sonnet" | "haiku" => return Ok(()),
_ => {}
}
// Check for spaces (malformed)
if trimmed.contains(' ') {
return Err(format!(
"invalid model syntax: '{}' contains spaces. Use provider/model format or known alias",
trimmed
));
}
// Check provider/model format: provider_id/model_id
let parts: Vec<&str> = trimmed.split('/').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(format!(
"invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)",
trimmed
));
}
Ok(())
}
fn config_alias_for_current_dir(alias: &str) -> Option<String> {
if alias.is_empty() {
return None;
@@ -3811,6 +3944,7 @@ impl LiveCli {
compact: bool,
) -> Result<(), Box<dyn std::error::Error>> {
match output_format {
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
CliOutputFormat::Text => self.run_turn(input),
CliOutputFormat::Json => self.run_prompt_json(input),
@@ -3830,6 +3964,32 @@ impl LiveCli {
Ok(())
}
fn run_prompt_compact_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop();
let summary = result?;
self.replace_runtime(runtime)?;
self.persist_session()?;
println!(
"{}",
json!({
"message": final_assistant_text(&summary),
"compact": true,
"model": self.model,
"usage": {
"input_tokens": summary.usage.input_tokens,
"output_tokens": summary.usage.output_tokens,
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
},
})
);
Ok(())
}
fn run_prompt_json(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
@@ -5181,28 +5341,32 @@ fn sandbox_json_value(status: &runtime::SandboxStatus) -> serde_json::Value {
fn render_help_topic(topic: LocalHelpTopic) -> String {
match topic {
LocalHelpTopic::Status => "Status
Usage claw status
Usage claw status [--output-format <format>]
Purpose show the local workspace snapshot without entering the REPL
Output model, permissions, git state, config files, and sandbox status
Formats text (default), json
Related /status · claw --resume latest /status"
.to_string(),
LocalHelpTopic::Sandbox => "Sandbox
Usage claw sandbox
Usage claw sandbox [--output-format <format>]
Purpose inspect the resolved sandbox and isolation state for the current directory
Output namespace, network, filesystem, and fallback details
Formats text (default), json
Related /sandbox · claw status"
.to_string(),
LocalHelpTopic::Doctor => "Doctor
Usage claw doctor
Usage claw doctor [--output-format <format>]
Purpose diagnose local auth, config, workspace, sandbox, and build metadata
Output local-only health report; no provider request or session resume required
Formats text (default), json
Related /doctor · claw --resume latest /doctor"
.to_string(),
LocalHelpTopic::Acp => "ACP / Zed
Usage claw acp [serve]
Usage claw acp [serve] [--output-format <format>]
Aliases claw --acp · claw -acp
Purpose explain the current editor-facing ACP/Zed launch contract without starting the runtime
Status discoverability only; `serve` is a status alias and does not launch a daemon yet
Formats text (default), json
Related ROADMAP #64a (discoverability) · ROADMAP #76 (real ACP support) · claw --help"
.to_string(),
}
@@ -6018,93 +6182,6 @@ fn summarize_tool_payload_for_markdown(payload: &str) -> String {
truncate_for_summary(&compact, SESSION_MARKDOWN_TOOL_SUMMARY_LIMIT)
}
/// Structured export error envelope (#130).
/// Conforms to Phase 2 §4.44 typed-error envelope contract.
/// Includes kind/operation/target/errno/hint/retryable for actionable diagnostics.
#[derive(Debug, serde::Serialize)]
struct ExportError {
kind: String,
operation: String,
target: String,
#[serde(skip_serializing_if = "Option::is_none")]
errno: Option<String>,
hint: String,
retryable: bool,
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"export failed: {} ({})\n target: {}\n errno: {}\n hint: {}",
self.kind,
self.operation,
self.target,
self.errno.as_deref().unwrap_or("unknown"),
self.hint
)
}
}
impl std::error::Error for ExportError {}
/// Wrap std::io::Error into a structured ExportError per §4.44.
fn wrap_export_io_error(path: &Path, op: &str, e: std::io::Error) -> ExportError {
use std::io::ErrorKind;
let target_display = path.display().to_string();
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.display().to_string());
let (kind, hint) = match e.kind() {
ErrorKind::NotFound => (
"filesystem",
parent
.as_ref()
.map(|p| format!("intermediate directory does not exist; try `mkdir -p {p}` first"))
.unwrap_or_else(|| {
"path is empty or invalid; provide a non-empty file path".to_string()
}),
),
ErrorKind::PermissionDenied => (
"permission",
format!(
"permission denied; check file permissions with `ls -la {}`",
parent.as_deref().unwrap_or(".")
),
),
ErrorKind::IsADirectory => (
"filesystem",
format!(
"path `{}` is a directory, not a file; use a file path like `{}/session.md`",
target_display, target_display
),
),
ErrorKind::AlreadyExists => (
"filesystem",
format!("path `{target_display}` already exists; remove it or pick a different name"),
),
ErrorKind::InvalidInput | ErrorKind::InvalidData => (
"invalid_path",
format!("path `{target_display}` is invalid; check for empty or malformed input"),
),
_ => (
"filesystem",
format!(
"unexpected error writing to `{target_display}`; check disk space and path validity"
),
),
};
ExportError {
kind: kind.to_string(),
operation: op.to_string(),
target: target_display,
errno: Some(format!("{:?}", e.kind())),
hint,
retryable: matches!(e.kind(), ErrorKind::TimedOut | ErrorKind::Interrupted),
}
}
fn run_export(
session_reference: &str,
output_path: Option<&Path>,
@@ -6114,9 +6191,7 @@ fn run_export(
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
if let Some(path) = output_path {
fs::write(path, &markdown).map_err(|e| {
Box::new(wrap_export_io_error(path, "write", e)) as Box<dyn std::error::Error>
})?;
fs::write(path, &markdown)?;
let report = format!(
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
path.display(),
@@ -8510,6 +8585,7 @@ mod tests {
request_id: Some("req_jobdori_789".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
let rendered = format_user_visible_api_error("session-issue-22", &error);
@@ -8532,6 +8608,7 @@ mod tests {
request_id: Some("req_jobdori_790".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
}),
};
@@ -8595,6 +8672,7 @@ mod tests {
request_id: Some("req_ctx_456".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
@@ -8626,6 +8704,7 @@ mod tests {
request_id: Some("req_ctx_retry_789".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
}),
};
@@ -8968,7 +9047,7 @@ mod tests {
let args = vec![
"--output-format=json".to_string(),
"--model".to_string(),
"claude-opus".to_string(),
"opus".to_string(),
"explain".to_string(),
"this".to_string(),
];
@@ -8976,7 +9055,7 @@ mod tests {
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "claude-opus".to_string(),
model: "claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Json,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
@@ -9746,15 +9825,21 @@ mod tests {
fn multi_word_prompt_still_uses_shorthand_prompt_mode() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
// Input is ["help", "me", "debug"] so the joined prompt shorthand
// must be "help me debug". A previous batch accidentally rewrote
// the expected string to "$help overview" (copy-paste slip).
// Input is ["--model", "opus", "please", "debug", "this"] so the joined
// prompt shorthand must stay a normal multi-word prompt while still
// honoring alias validation at parse time.
assert_eq!(
parse_args(&["help".to_string(), "me".to_string(), "debug".to_string()])
.expect("prompt shorthand should still work"),
parse_args(&[
"--model".to_string(),
"opus".to_string(),
"please".to_string(),
"debug".to_string(),
"this".to_string(),
])
.expect("prompt shorthand should still work"),
CliAction::Prompt {
prompt: "help me debug".to_string(),
model: DEFAULT_MODEL.to_string(),
prompt: "please debug this".to_string(),
model: "claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
@@ -9890,6 +9975,109 @@ mod tests {
assert!(report.contains("Use /help"));
}
#[test]
fn typoed_doctor_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["doctorr".to_string()]).expect_err("doctorr should error");
assert!(error.contains("unknown subcommand: doctorr."));
assert!(error.contains("Did you mean"));
assert!(error.contains("doctor"));
}
#[test]
fn typoed_skills_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["skilsl".to_string()]).expect_err("skilsl should error");
assert!(error.contains("unknown subcommand: skilsl."));
assert!(error.contains("skills"));
}
#[test]
fn typoed_status_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["statuss".to_string()]).expect_err("statuss should error");
assert!(error.contains("unknown subcommand: statuss."));
assert!(error.contains("status"));
}
#[test]
fn typoed_export_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["exporrt".to_string()]).expect_err("exporrt should error");
assert!(error.contains("unknown subcommand: exporrt."));
assert!(error.contains("Did you mean"));
assert!(error.contains("export"));
}
#[test]
fn typoed_mcp_subcommand_returns_did_you_mean_error() {
let error = parse_args(&["mcpp".to_string()]).expect_err("mcpp should error");
assert!(error.contains("unknown subcommand: mcpp."));
assert!(error.contains("mcp"));
}
#[test]
fn multi_word_prompt_still_bypasses_subcommand_typo_guard() {
assert_eq!(
parse_args(&[
"hello".to_string(),
"world".to_string(),
"this".to_string(),
"is".to_string(),
"a".to_string(),
"prompt".to_string(),
])
.expect("multi-word prompt should still parse"),
CliAction::Prompt {
prompt: "hello world this is a prompt".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn prompt_subcommand_allows_literal_typo_word() {
assert_eq!(
parse_args(&["prompt".to_string(), "doctorr".to_string()])
.expect("explicit prompt subcommand should allow literal typo word"),
CliAction::Prompt {
prompt: "doctorr".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn punctuation_bearing_single_token_still_dispatches_to_prompt() {
assert_eq!(
parse_args(&["PARITY_SCENARIO:bash_permission_prompt_approved".to_string()])
.expect("scenario token should still dispatch to prompt"),
CliAction::Prompt {
prompt: "PARITY_SCENARIO:bash_permission_prompt_approved".to_string(),
model: DEFAULT_MODEL.to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
compact: false,
base_commit: None,
reasoning_effort: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn formats_namespaced_omc_slash_command_with_contract_guidance() {
let report = format_unknown_slash_command_message("oh-my-claudecode:hud");

View File

@@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX};
use serde_json::Value;
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -125,6 +126,60 @@ fn compact_flag_streaming_text_only_emits_final_message_text() {
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
#[test]
fn compact_flag_with_json_output_emits_structured_json() {
let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should build");
let server = runtime
.block_on(MockAnthropicService::spawn())
.expect("mock service should start");
let base_url = server.base_url();
let workspace = unique_temp_dir("compact-json");
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 prompt = format!("{SCENARIO_PREFIX}streaming_text");
let output = run_claw(
&workspace,
&config_home,
&home,
&base_url,
&[
"--model",
"sonnet",
"--permission-mode",
"read-only",
"--output-format",
"json",
"--compact",
&prompt,
],
);
assert!(
output.status.success(),
"compact json run should succeed
stdout:
{}
stderr:
{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
let parsed: Value = serde_json::from_str(&stdout).expect("compact json stdout should parse");
assert_eq!(parsed["message"], "Mock streaming says hello from the parity harness.");
assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert!(parsed["usage"].is_object());
fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
}
fn run_claw(
cwd: &std::path::Path,
config_home: &std::path::Path,

View File

@@ -4459,6 +4459,7 @@ fn classify_lane_blocker(error: &str) -> LaneEventBlocker {
LaneEventBlocker {
failure_class: classify_lane_failure(error),
detail,
subphase: None,
}
}