mirror of
https://github.com/instructkr/claw-code.git
synced 2026-07-02 06:36:44 -04:00
Compare commits
4 Commits
f33c315c93
...
feat/jobdo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f079b7b616 | ||
|
|
93da4f14ab | ||
|
|
d305178591 | ||
|
|
0cbff5dc76 |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"aliases": {
|
||||
"quick": "haiku"
|
||||
}
|
||||
}
|
||||
165
ROADMAP.md
165
ROADMAP.md
@@ -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.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"defaultMode": "dontAsk"
|
||||
}
|
||||
}
|
||||
4
rust/.gitignore
vendored
4
rust/.gitignore
vendored
@@ -1,7 +1,3 @@
|
||||
target/
|
||||
.omx/
|
||||
.clawd-agents/
|
||||
# Claw Code local artifacts
|
||||
.claw/settings.local.json
|
||||
.claw/sessions/
|
||||
.clawhip/
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -4459,7 +4459,6 @@ fn classify_lane_blocker(error: &str) -> LaneEventBlocker {
|
||||
LaneEventBlocker {
|
||||
failure_class: classify_lane_failure(error),
|
||||
detail,
|
||||
subphase: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user