Compare commits

..

4 Commits

Author SHA1 Message Date
YeonGyu-Kim
f079b7b616 ROADMAP #132: --output-format json global error renderer flattens every typed error variant into {type:"error",error:<prose>}, erasing §4.44 envelope structure at the final serialization boundary — runtime has 5 typed error enums (SessionError/ConfigError/McpServerManagerError/PromptBuildError/SessionControlError) but ~11 CLI emission sites call error.to_string() and wrap in flat {type,error} shape; kind/operation/target/errno/hint/retryable all discarded. #130 fix lands typed envelope in text mode but JSON mode still emits flat string. Commit d305178 body self-declares this debt. Closes the renderer-side half of §4.44. Joins JSON-envelope asymmetry family #90/#91/#92/#110/#115/#116 as 7th; joins silent-state inventory #102/#127/#129/#130/#131 as 6th. Bundle: #130+#132 export-surface typed errors text+json parity. 2026-04-20 14:14:41 +09:00
YeonGyu-Kim
93da4f14ab ROADMAP #131: claw export positional arg silently treated as output PATH not session reference — operator types 'claw export <session-id> --output /tmp/x.md' expecting to export <session-id>, but parser puts <session-id> in output_path slot and session_reference defaults to LATEST. Result: wrong session exported with no warning. Discovered while auditing #130 export error path during scope-truth pass on 2026-04-20 dogfood cycle. Joins silent-state inventory family (#102 + #127 + #129 + #130) and parser-level trust gap quintet (#108 + #117 + #119 + #122 + #127). 2026-04-20 14:07:27 +09:00
YeonGyu-Kim
d305178591 fix(cli): #130 export error envelope — wrap fs::write() in run_export() with structured ExportError per Phase 2 §4.44 typed-error envelope contract; eliminates zero-context errno output. ExportError struct includes kind/operation/target/errno/hint/retryable fields with serde::Serialize and Display impls. wrap_export_io_error() classifies io::ErrorKind into filesystem/permission/invalid_path categories and synthesizes actionable hints (e.g. 'intermediate directory does not exist; try mkdir -p X first'). Verified end-to-end: ENOENT, EPERM, IsADirectory, empty path, trailing slash all emit structured envelope; success case unchanged (backward-compat anchor preserved). JSON mode still uses string error rendering — separate concern requiring global error renderer refactor (tracked for follow-up cycle). 2026-04-20 14:02:15 +09:00
YeonGyu-Kim
0cbff5dc76 prep(cli): #130 structured ExportError type design ready — conforms to §4.44 typed-error envelope contract; includes kind/operation/target/errno/hint/retryable fields, Display + serde::Serialize impl, wrap_export_io_error helper; ready for integration into run_export handler 2026-04-20 13:42:57 +09:00
9 changed files with 217 additions and 228 deletions

View File

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

View File

@@ -711,49 +711,6 @@ 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).
@@ -830,12 +787,7 @@ Acceptance:
- channel status updates stay short and machine-grounded
- claws stop inferring state from raw build spam
### 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:
### 6.5. Blocked-state subphase contract
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:
@@ -5014,3 +4966,118 @@ ear], /color [scheme], /effort [low|medium|high], /fast, /summary, /tag [label],
**Blocker.** None. Reuses existing `stale_base` module; no new logic needed, just a missing call site.
**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
**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.
**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.
**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
```
**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.
**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.
**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.
**Blocker.** None. Pure parser-level fix; ~30 lines in `parse_export_args()`.
**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.
## 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
**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.
**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.
**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.
**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.
**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`.
**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.
**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).
**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.
**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.

View File

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

4
rust/.gitignore vendored
View File

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

View File

@@ -1,15 +0,0 @@
# 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

@@ -38,15 +38,6 @@ 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)]
@@ -392,31 +383,11 @@ 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)]
@@ -433,29 +404,6 @@ 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,
@@ -539,56 +487,16 @@ impl LaneEvent {
#[must_use]
pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
let mut event = Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at)
.with_failure_class(blocker.failure_class)
.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
.with_detail(blocker.detail.clone())
}
#[must_use]
pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self {
let mut event = Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at)
.with_failure_class(blocker.failure_class)
.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"))
.with_detail(blocker.detail.clone())
}
#[must_use]
@@ -662,10 +570,9 @@ mod tests {
use super::{
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
WatcherAction,
is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
LaneOwnership, SessionIdentity, WatcherAction,
};
#[test]
@@ -694,10 +601,6 @@ 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 {
@@ -738,10 +641,6 @@ 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);
@@ -787,34 +686,6 @@ 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,10 +84,9 @@ pub use hooks::{
};
pub use lane_events::{
compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events,
is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent,
LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus,
LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance,
WatcherAction,
is_terminal_event, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker,
LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass,
LaneOwnership, SessionIdentity, WatcherAction,
};
pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,

View File

@@ -1508,10 +1508,7 @@ fn render_doctor_report() -> Result<DoctorReport, Box<dyn std::error::Error>> {
check_sandbox_health(&context.sandbox_status),
check_system_health(&cwd, config.as_ref().ok()),
],
});
// Run stale-base preflight check — emits warnings to stderr if branch is behind main
run_stale_base_preflight(None);
Ok(report)
})
}
fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box<dyn std::error::Error>> {
@@ -6021,6 +6018,93 @@ 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>,
@@ -6030,7 +6114,9 @@ fn run_export(
let markdown = render_session_markdown(&session, &handle.id, &handle.path);
if let Some(path) = output_path {
fs::write(path, &markdown)?;
fs::write(path, &markdown).map_err(|e| {
Box::new(wrap_export_io_error(path, "write", e)) as Box<dyn std::error::Error>
})?;
let report = format!(
"Export\n Result wrote markdown transcript\n File {}\n Session {}\n Messages {}",
path.display(),
@@ -8424,7 +8510,6 @@ 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);
@@ -8447,7 +8532,6 @@ mod tests {
request_id: Some("req_jobdori_790".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
}),
};
@@ -8511,7 +8595,6 @@ 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);
@@ -8543,7 +8626,6 @@ mod tests {
request_id: Some("req_ctx_retry_789".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
}),
};

View File

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