From 163cf006508bc393a33db3b3e1a11fd58450f1e1 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Sun, 5 Apr 2026 18:40:33 +0000 Subject: [PATCH] Close the clawability backlog with deterministic CLI output and lane lineage Finish the remaining roadmap work by making direct CLI JSON output deterministic across the non-interactive surface, restoring the degraded-startup MCP test as a real workspace test, and adding branch-lock plus commit-lineage primitives so downstream lane consumers can distinguish superseded worktree commits from canonical lineage. Constraint: Keep the user-facing config namespace centered on .claw while preserving legacy fallback discovery for compatibility Constraint: Verification needed to stay clean-room and reproducible from the checked-in workspace alone Rejected: Leave the output-format contract implied by ad-hoc smoke runs only | too easy for direct CLI regressions to slip back into prose-only output Rejected: Keep commit provenance as free-form detail text | downstream consumers need structured branch/worktree/supersession metadata Confidence: medium Scope-risk: moderate Directive: Extend the JSON contract through the same direct CLI entrypoints instead of adding one-off serializers on parallel code paths Tested: python .github/scripts/check_doc_source_of_truth.py Tested: cd rust && cargo fmt --all --check Tested: cd rust && cargo test --workspace Tested: cd rust && cargo clippy -p commands -p tools -p rusty-claude-cli --all-targets --no-deps -- -D warnings Not-tested: full cargo clippy --workspace --all-targets -- -D warnings still reports unrelated pre-existing runtime lint debt outside this change set --- .github/scripts/check_doc_source_of_truth.py | 45 ++ .github/workflows/rust-ci.yml | 30 ++ README.md | 2 +- ROADMAP.md | 40 +- rust/crates/commands/src/lib.rs | 1 + rust/crates/runtime/src/branch_lock.rs | 144 ++++++ rust/crates/runtime/src/lane_events.rs | 141 +++++- rust/crates/runtime/src/lib.rs | 5 +- rust/crates/runtime/src/mcp_stdio.rs | 37 +- rust/crates/rusty-claude-cli/Cargo.toml | 1 + rust/crates/rusty-claude-cli/src/main.rs | 436 +++++++++++++----- .../tests/mock_parity_harness.rs | 3 +- .../tests/output_format_contract.rs | 193 ++++++++ rust/crates/tools/src/lib.rs | 14 +- 14 files changed, 954 insertions(+), 138 deletions(-) create mode 100755 .github/scripts/check_doc_source_of_truth.py create mode 100644 rust/crates/runtime/src/branch_lock.rs create mode 100644 rust/crates/rusty-claude-cli/tests/output_format_contract.rs diff --git a/.github/scripts/check_doc_source_of_truth.py b/.github/scripts/check_doc_source_of_truth.py new file mode 100755 index 0000000..c831eb7 --- /dev/null +++ b/.github/scripts/check_doc_source_of_truth.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[2] +FILES = [ + ROOT / 'README.md', + ROOT / 'USAGE.md', + ROOT / 'PARITY.md', + ROOT / 'PHILOSOPHY.md', + ROOT / 'ROADMAP.md', + ROOT / '.github' / 'FUNDING.yml', +] +FILES.extend(sorted((ROOT / 'docs').rglob('*.md')) if (ROOT / 'docs').exists() else []) + +FORBIDDEN = { + r'github\.com/Yeachan-Heo/claw-code(?!-parity)': 'replace old claw-code GitHub links with ultraworkers/claw-code', + r'github\.com/code-yeongyu/claw-code': 'replace stale alternate claw-code GitHub links with ultraworkers/claw-code', + r'discord\.gg/6ztZB9jvWq': 'replace the stale UltraWorkers Discord invite with the current invite', + r'api\.star-history\.com/svg\?repos=Yeachan-Heo/claw-code': 'update star-history embeds to ultraworkers/claw-code', + r'star-history\.com/#Yeachan-Heo/claw-code': 'update star-history links to ultraworkers/claw-code', + r'assets/clawd-hero\.jpeg': 'rename stale hero asset references to assets/claw-hero.jpeg', + r'assets/instructkr\.png': 'remove stale instructkr image references', +} + +errors: list[str] = [] +for path in FILES: + if not path.exists(): + continue + text = path.read_text(encoding='utf-8') + for pattern, message in FORBIDDEN.items(): + for match in re.finditer(pattern, text): + line = text.count('\n', 0, match.start()) + 1 + errors.append(f'{path.relative_to(ROOT)}:{line}: {message}') + +if errors: + print('doc source-of-truth check failed:', file=sys.stderr) + for error in errors: + print(f' - {error}', file=sys.stderr) + sys.exit(1) + +print('doc source-of-truth check passed') diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 5235382..9776047 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -8,12 +8,28 @@ on: - 'omx-issue-*' paths: - .github/workflows/rust-ci.yml + - .github/scripts/check_doc_source_of_truth.py + - .github/FUNDING.yml + - README.md + - USAGE.md + - PARITY.md + - PHILOSOPHY.md + - ROADMAP.md + - docs/** - rust/** pull_request: branches: - main paths: - .github/workflows/rust-ci.yml + - .github/scripts/check_doc_source_of_truth.py + - .github/FUNDING.yml + - README.md + - USAGE.md + - PARITY.md + - PHILOSOPHY.md + - ROADMAP.md + - docs/** - rust/** workflow_dispatch: @@ -29,6 +45,20 @@ env: CARGO_TERM_COLOR: always jobs: + doc-source-of-truth: + name: docs source-of-truth + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Check docs and metadata for stale branding + run: python .github/scripts/check_doc_source_of_truth.py + fmt: name: cargo fmt runs-on: ubuntu-latest diff --git a/README.md b/README.md index b08c840..adbbe11 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Claw Code is the public Rust implementation of the `claw` CLI agent harness. The canonical implementation lives in [`rust/`](./rust), and the current source of truth for this repository is **ultraworkers/claw-code**. > [!IMPORTANT] -> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Use [`rust/README.md`](./rust/README.md) for crate-level details and [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint. +> Start with [`USAGE.md`](./USAGE.md) for build, auth, CLI, session, and parity-harness workflows. Make `claw doctor` your first health check after building, use [`rust/README.md`](./rust/README.md) for crate-level details, read [`PARITY.md`](./PARITY.md) for the current Rust-port checkpoint, and see [`docs/container.md`](./docs/container.md) for the container-first workflow. ## Current repository shape diff --git a/ROADMAP.md b/ROADMAP.md index 839ccfd..b33b05b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -271,18 +271,18 @@ Acceptance: Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = clawability hardening, P3 = swarm-efficiency improvements. **P0 — Fix first (CI reliability)** -1. Isolate `render_diff_report` tests into tmpdir — flaky under `cargo test --workspace`; reads real working-tree state; breaks CI during active worktree ops -2. Expand GitHub CI from single-crate coverage to workspace-grade verification — current `rust-ci.yml` runs `cargo fmt` and `cargo test -p rusty-claude-cli`, but misses broader `cargo test --workspace` coverage that already passes locally -3. Add release-grade binary workflow — repo has a Rust CLI and release intent, but no GitHub Actions path that builds tagged artifacts / checks release packaging before a publish step -4. Add container-first test/run docs — runtime detects Docker/Podman/container state, but docs do not show a canonical container workflow for `cargo test --workspace`, binary execution, or bind-mounted repo usage -5. Surface `doctor` / preflight diagnostics in onboarding docs and help — the CLI already has setup-diagnosis commands and branch preflight machinery, but they are not prominent enough in README/USAGE, so new users still ask manual setup questions instead of running a built-in health check first -6. Automate branding/source-of-truth residue checks in CI — the manual doc pass is done, but badges, Discord invites, copied repo URLs, and org-name drift can regress unless a cheap lint/check keeps README/docs aligned with `ultraworkers/claw-code` -7. Eliminate warning spam from first-run help/build path — `cargo run -p rusty-claude-cli -- --help` currently prints a wall of compile warnings before the actual help text, which pollutes the first-touch UX and hides the product surface behind unrelated noise -8. Promote `doctor` from slash-only to top-level CLI entrypoint — users naturally try `claw doctor`, but today it errors and tells them to enter a REPL or resume path first; healthcheck flows should be callable directly from the shell -9. Make machine-readable status commands actually machine-readable — `status` and `sandbox` accept the global `--output-format json` flag path, but currently still render prose tables, which breaks shell automation and agent-friendly health polling -10. Unify legacy config/skill namespaces in user-facing output — `skills` currently surfaces mixed project roots like `.codex` and `.claude`, which leaks historical layers into the current product and makes it unclear which config namespace is canonical -11. Honor JSON output on inventory commands like `skills` and `mcp` — these are exactly the commands agents and shell scripts want to inspect programmatically, but `--output-format json` still yields prose, forcing text scraping where structured inventory should exist -12. Audit `--output-format` contract across the whole CLI surface — current behavior is inconsistent by subcommand, so agents cannot trust the global flag without command-by-command probing; the format contract itself needs to become deterministic +1. Isolate `render_diff_report` tests into tmpdir — **done**: `render_diff_report_for()` tests run in temp git repos instead of the live working tree, and targeted `cargo test -p rusty-claude-cli render_diff_report -- --nocapture` now stays green during branch/worktree activity +2. Expand GitHub CI from single-crate coverage to workspace-grade verification — **done**: `.github/workflows/rust-ci.yml` now runs `cargo test --workspace` plus fmt/clippy at the workspace level +3. Add release-grade binary workflow — **done**: `.github/workflows/release.yml` now builds tagged Rust release artifacts for the CLI +4. Add container-first test/run docs — **done**: `Containerfile` + `docs/container.md` document the canonical Docker/Podman workflow for build, bind-mount, and `cargo test --workspace` usage +5. Surface `doctor` / preflight diagnostics in onboarding docs and help — **done**: README + USAGE now put `claw doctor` / `/doctor` in the first-run path and point at the built-in preflight report +6. Automate branding/source-of-truth residue checks in CI — **done**: `.github/scripts/check_doc_source_of_truth.py` and the `doc-source-of-truth` CI job now block stale repo/org/invite residue in tracked docs and metadata +7. Eliminate warning spam from first-run help/build path — **done**: current `cargo run -q -p rusty-claude-cli -- --help` renders clean help output without a warning wall before the product surface +8. Promote `doctor` from slash-only to top-level CLI entrypoint — **done**: `claw doctor` is now a local shell entrypoint with regression coverage for direct help and health-report output +9. Make machine-readable status commands actually machine-readable — **done**: `claw --output-format json status` and `claw --output-format json sandbox` now emit structured JSON snapshots instead of prose tables +10. Unify legacy config/skill namespaces in user-facing output — **done**: skills/help JSON/text output now present `.claw` as the canonical namespace and collapse legacy roots behind `.claw`-shaped source ids/labels +11. Honor JSON output on inventory commands like `skills` and `mcp` — **done**: direct CLI inventory commands now honor `--output-format json` with structured payloads for both skills and MCP inventory +12. Audit `--output-format` contract across the whole CLI surface — **done**: direct CLI commands now honor deterministic JSON/text handling across help/version/status/sandbox/agents/mcp/skills/bootstrap-plan/system-prompt/init/doctor, with regression coverage in `output_format_contract.rs` and resumed `/status` JSON coverage **P1 — Next (integration wiring, unblocks verification)** 2. Add cross-module integration tests — **done**: 12 integration tests covering worker→recovery→policy, stale_branch→policy, green_contract→policy, reconciliation flows @@ -300,17 +300,15 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 = 12. Lane board / machine-readable status API — **done**: Lane completion hardening + `LaneContext::completed` auto-detection + MCP degraded reporting surface machine-readable state 13. **Session completion failure classification** — **done**: `WorkerFailureKind::Provider` + `observe_completion()` + recovery recipe bridge landed 14. **Config merge validation gap** — **done**: `config.rs` hook validation before deep-merge (+56 lines), malformed entries fail with source-path context instead of merged parse errors -15. **MCP manager discovery flaky test** — `manager_discovery_report_keeps_healthy_servers_when_one_server_fails` has intermittent timing issues in CI; temporarily ignored, needs root cause fix - -16. **Commit provenance / worktree-aware push events** — clawhip build stream shows duplicate-looking commit messages and worktree-originated pushes without clear supersession indicators; add worktree/branch metadata to push events and de-dup superseded commits in build stream display -17. **Orphaned module integration audit** — `session_control` is `pub mod` exported from `runtime` but has zero consumers across the entire workspace (no import, no call site outside its own file). `trust_resolver` types are re-exported from `lib.rs` but never instantiated outside unit tests. These modules implement core clawability contracts (session management, trust resolution) that are structurally dead — built but not wired into the CLI or tools crate. **Action:** audit all `pub mod` / `pub use` exports from `runtime` for actual call sites; either wire orphaned modules into the real execution path or demote to `pub(crate)` / `cfg(test)` to prevent false clawability surface. -18. **Context-window preflight gap** — claw-code auto-compacts only after cumulative input crosses a static `100_000`-token threshold, while provider requests derive `max_tokens` from a naive model-name heuristic (`opus` => 32k, else 64k) and do not appear to preflight `estimated_prompt_tokens + requested_output_tokens` against the selected model’s actual context window. Result: giant sessions can be sent upstream and fail hard with provider-side `input_exceeds_context_by_*` errors instead of local preflight compaction/rejection. **Action:** add a model-context registry + request-size preflight before provider call; if projected request exceeds context, emit a structured `context_window_blocked` event and auto-compact or force `/compact` before retry. -19. **Subcommand help falls through into runtime/API path** — direct dogfood shows `./target/debug/claw doctor --help` and `./target/debug/claw status --help` do not render local subcommand help. Instead they enter the request path, show `🦀 Thinking...`, then fail with `api returned 500 ... auth_unavailable: no auth available`. Help/usage surfaces must be pure local parsing and never require auth or provider reachability. **Action:** fix argv dispatch so ` --help` is intercepted before runtime startup/API client initialization; add regression tests for `doctor --help`, `status --help`, and similar local-info commands. -20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — dogfooding with 14 parallel tmux/OMX lanes exposed that text-idle stale detection is far too coarse. Sessions were repeatedly flagged as stale even when they were already **finished/reportable** (P0.9, P0.10, P2.18, P2.19), **working but quiet** (doc/branding/audit passes), or **blocked on a specific recoverable state** (background terminal still running, cherry-pick conflict, MCP startup noise, transport interruption after partial progress). **Action:** add explicit machine states above prose scraping such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, `finished_cleanable`, and `truly_idle`; update clawhip/session monitoring so quiet work is not paged as stale and completed sessions can auto-report + auto-clean. +15. **MCP manager discovery flaky test** — **done**: `manager_discovery_report_keeps_healthy_servers_when_one_server_fails` now runs as a normal workspace test again after repeated stable passes, so degraded-startup coverage is no longer hidden behind `#[ignore]` +16. **Commit provenance / worktree-aware push events** — **done**: `LaneCommitProvenance` now carries branch/worktree/canonical-commit/supersession metadata in lane events, and `dedupe_superseded_commit_events()` is applied before agent manifests are written so superseded commit events collapse to the latest canonical lineage +17. **Orphaned module integration audit** — **done**: `runtime` now keeps `session_control` and `trust_resolver` behind `#[cfg(test)]` until they are wired into a real non-test execution path, so normal builds no longer advertise dead clawability surface area. +18. **Context-window preflight gap** — **done**: provider request sizing now emits `context_window_blocked` before oversized requests leave the process, using a model-context registry instead of the old naive max-token heuristic. +19. **Subcommand help falls through into runtime/API path** — **done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths. **P3 — Swarm efficiency** -13. Swarm branch-lock protocol — detect same-module/same-branch collision before parallel workers drift into duplicate implementation -14. Commit provenance / worktree-aware push events — emit branch, worktree, superseded-by, and canonical commit lineage so parallel sessions stop producing duplicate-looking push summaries +13. Swarm branch-lock protocol — **done**: `branch_lock::detect_branch_lock_collisions()` now detects same-branch/same-scope and nested-module collisions before parallel lanes drift into duplicate implementation +14. Commit provenance / worktree-aware push events — **done**: lane event provenance now includes branch/worktree/superseded/canonical lineage metadata, and manifest persistence de-dupes superseded commit events before downstream consumers render them ## Suggested Session Split diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index e34fb85..6e81ba5 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2466,6 +2466,7 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P roots } +#[allow(clippy::too_many_lines)] fn discover_skill_roots(cwd: &Path) -> Vec { let mut roots = Vec::new(); diff --git a/rust/crates/runtime/src/branch_lock.rs b/rust/crates/runtime/src/branch_lock.rs new file mode 100644 index 0000000..6fbf0d0 --- /dev/null +++ b/rust/crates/runtime/src/branch_lock.rs @@ -0,0 +1,144 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchLockIntent { + #[serde(rename = "laneId")] + pub lane_id: String, + pub branch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BranchLockCollision { + pub branch: String, + pub module: String, + #[serde(rename = "laneIds")] + pub lane_ids: Vec, +} + +#[must_use] +pub fn detect_branch_lock_collisions(intents: &[BranchLockIntent]) -> Vec { + let mut collisions = Vec::new(); + + for (index, left) in intents.iter().enumerate() { + for right in &intents[index + 1..] { + if left.branch != right.branch { + continue; + } + for module in overlapping_modules(&left.modules, &right.modules) { + collisions.push(BranchLockCollision { + branch: left.branch.clone(), + module, + lane_ids: vec![left.lane_id.clone(), right.lane_id.clone()], + }); + } + } + } + + collisions.sort_by(|a, b| { + a.branch + .cmp(&b.branch) + .then(a.module.cmp(&b.module)) + .then(a.lane_ids.cmp(&b.lane_ids)) + }); + collisions.dedup(); + collisions +} + +fn overlapping_modules(left: &[String], right: &[String]) -> Vec { + let mut overlaps = Vec::new(); + for left_module in left { + for right_module in right { + if modules_overlap(left_module, right_module) { + overlaps.push(shared_scope(left_module, right_module)); + } + } + } + overlaps.sort(); + overlaps.dedup(); + overlaps +} + +fn modules_overlap(left: &str, right: &str) -> bool { + left == right + || left.starts_with(&format!("{right}/")) + || right.starts_with(&format!("{left}/")) +} + +fn shared_scope(left: &str, right: &str) -> String { + if left.starts_with(&format!("{right}/")) || left == right { + right.to_string() + } else { + left.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::{detect_branch_lock_collisions, BranchLockIntent}; + + #[test] + fn detects_same_branch_same_module_collisions() { + let collisions = detect_branch_lock_collisions(&[ + BranchLockIntent { + lane_id: "lane-a".to_string(), + branch: "feature/lock".to_string(), + worktree: Some("wt-a".to_string()), + modules: vec!["runtime/mcp".to_string()], + }, + BranchLockIntent { + lane_id: "lane-b".to_string(), + branch: "feature/lock".to_string(), + worktree: Some("wt-b".to_string()), + modules: vec!["runtime/mcp".to_string()], + }, + ]); + + assert_eq!(collisions.len(), 1); + assert_eq!(collisions[0].branch, "feature/lock"); + assert_eq!(collisions[0].module, "runtime/mcp"); + } + + #[test] + fn detects_nested_module_scope_collisions() { + let collisions = detect_branch_lock_collisions(&[ + BranchLockIntent { + lane_id: "lane-a".to_string(), + branch: "feature/lock".to_string(), + worktree: None, + modules: vec!["runtime".to_string()], + }, + BranchLockIntent { + lane_id: "lane-b".to_string(), + branch: "feature/lock".to_string(), + worktree: None, + modules: vec!["runtime/mcp".to_string()], + }, + ]); + + assert_eq!(collisions[0].module, "runtime"); + } + + #[test] + fn ignores_different_branches() { + let collisions = detect_branch_lock_collisions(&[ + BranchLockIntent { + lane_id: "lane-a".to_string(), + branch: "feature/a".to_string(), + worktree: None, + modules: vec!["runtime/mcp".to_string()], + }, + BranchLockIntent { + lane_id: "lane-b".to_string(), + branch: "feature/b".to_string(), + worktree: None, + modules: vec!["runtime/mcp".to_string()], + }, + ]); + + assert!(collisions.is_empty()); + } +} diff --git a/rust/crates/runtime/src/lane_events.rs b/rust/crates/runtime/src/lane_events.rs index 9ee88a4..ca037df 100644 --- a/rust/crates/runtime/src/lane_events.rs +++ b/rust/crates/runtime/src/lane_events.rs @@ -76,6 +76,20 @@ pub struct LaneEventBlocker { pub detail: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LaneCommitProvenance { + pub commit: String, + pub branch: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worktree: Option, + #[serde(rename = "canonicalCommit", skip_serializing_if = "Option::is_none")] + pub canonical_commit: Option, + #[serde(rename = "supersededBy", skip_serializing_if = "Option::is_none")] + pub superseded_by: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lineage: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneEvent { pub event: LaneEventName, @@ -122,6 +136,36 @@ impl LaneEvent { .with_optional_detail(detail) } + #[must_use] + pub fn commit_created( + emitted_at: impl Into, + detail: Option, + provenance: LaneCommitProvenance, + ) -> Self { + Self::new( + LaneEventName::CommitCreated, + LaneEventStatus::Completed, + emitted_at, + ) + .with_optional_detail(detail) + .with_data(serde_json::to_value(provenance).expect("commit provenance should serialize")) + } + + #[must_use] + pub fn superseded( + emitted_at: impl Into, + detail: Option, + provenance: LaneCommitProvenance, + ) -> Self { + Self::new( + LaneEventName::Superseded, + LaneEventStatus::Superseded, + emitted_at, + ) + .with_optional_detail(detail) + .with_data(serde_json::to_value(provenance).expect("commit provenance should serialize")) + } + #[must_use] pub fn blocked(emitted_at: impl Into, blocker: &LaneEventBlocker) -> Self { Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at) @@ -161,11 +205,54 @@ impl LaneEvent { } } +#[must_use] +pub fn dedupe_superseded_commit_events(events: &[LaneEvent]) -> Vec { + let mut keep = vec![true; events.len()]; + let mut latest_by_key = std::collections::BTreeMap::::new(); + + for (index, event) in events.iter().enumerate() { + if event.event != LaneEventName::CommitCreated { + continue; + } + let Some(data) = event.data.as_ref() else { + continue; + }; + let key = data + .get("canonicalCommit") + .or_else(|| data.get("commit")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let superseded = data + .get("supersededBy") + .and_then(serde_json::Value::as_str) + .is_some(); + if superseded { + keep[index] = false; + continue; + } + if let Some(key) = key { + if let Some(previous) = latest_by_key.insert(key, index) { + keep[previous] = false; + } + } + } + + events + .iter() + .cloned() + .zip(keep) + .filter_map(|(event, retain)| retain.then_some(event)) + .collect() +} + #[cfg(test)] mod tests { use serde_json::json; - use super::{LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass}; + use super::{ + dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker, + LaneEventName, LaneEventStatus, LaneFailureClass, + }; #[test] fn canonical_lane_event_names_serialize_to_expected_wire_values() { @@ -240,4 +327,56 @@ mod tests { assert_eq!(failed.status, LaneEventStatus::Failed); assert_eq!(failed.detail.as_deref(), Some("broken server")); } + + #[test] + fn commit_events_can_carry_worktree_and_supersession_metadata() { + let event = LaneEvent::commit_created( + "2026-04-04T00:00:00Z", + Some("commit created".to_string()), + LaneCommitProvenance { + commit: "abc123".to_string(), + branch: "feature/provenance".to_string(), + worktree: Some("wt-a".to_string()), + canonical_commit: Some("abc123".to_string()), + superseded_by: None, + lineage: vec!["abc123".to_string()], + }, + ); + let event_json = serde_json::to_value(&event).expect("lane event should serialize"); + assert_eq!(event_json["event"], "lane.commit.created"); + assert_eq!(event_json["data"]["branch"], "feature/provenance"); + assert_eq!(event_json["data"]["worktree"], "wt-a"); + } + + #[test] + fn dedupes_superseded_commit_events_by_canonical_commit() { + let retained = dedupe_superseded_commit_events(&[ + LaneEvent::commit_created( + "2026-04-04T00:00:00Z", + Some("old".to_string()), + LaneCommitProvenance { + commit: "old123".to_string(), + branch: "feature/provenance".to_string(), + worktree: Some("wt-a".to_string()), + canonical_commit: Some("canon123".to_string()), + superseded_by: Some("new123".to_string()), + lineage: vec!["old123".to_string(), "new123".to_string()], + }, + ), + LaneEvent::commit_created( + "2026-04-04T00:00:01Z", + Some("new".to_string()), + LaneCommitProvenance { + commit: "new123".to_string(), + branch: "feature/provenance".to_string(), + worktree: Some("wt-b".to_string()), + canonical_commit: Some("canon123".to_string()), + superseded_by: None, + lineage: vec!["old123".to_string(), "new123".to_string()], + }, + ), + ]); + assert_eq!(retained.len(), 1); + assert_eq!(retained[0].detail.as_deref(), Some("new")); + } } diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 3c4472a..52864e2 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -7,6 +7,7 @@ mod bash; pub mod bash_validation; mod bootstrap; +pub mod branch_lock; mod compact; mod config; mod conversation; @@ -46,6 +47,7 @@ pub mod worker_boot; pub use bash::{execute_bash, BashCommandInput, BashCommandOutput}; pub use bootstrap::{BootstrapPhase, BootstrapPlan}; +pub use branch_lock::{detect_branch_lock_collisions, BranchLockCollision, BranchLockIntent}; pub use compact::{ compact_session, estimate_session_tokens, format_compact_summary, get_compact_continuation_message, should_compact, CompactionConfig, CompactionResult, @@ -72,7 +74,8 @@ pub use hooks::{ HookAbortSignal, HookEvent, HookProgressEvent, HookProgressReporter, HookRunResult, HookRunner, }; pub use lane_events::{ - LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, + dedupe_superseded_commit_events, LaneCommitProvenance, LaneEvent, LaneEventBlocker, + LaneEventName, LaneEventStatus, LaneFailureClass, }; pub use mcp::{ mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp, diff --git a/rust/crates/runtime/src/mcp_stdio.rs b/rust/crates/runtime/src/mcp_stdio.rs index be732e1..5fbc31b 100644 --- a/rust/crates/runtime/src/mcp_stdio.rs +++ b/rust/crates/runtime/src/mcp_stdio.rs @@ -2652,8 +2652,37 @@ mod tests { }); } + fn write_initialize_disconnect_script() -> PathBuf { + let root = temp_dir(); + fs::create_dir_all(&root).expect("temp dir"); + let script_path = root.join("initialize-disconnect.py"); + let script = [ + "#!/usr/bin/env python3", + "import sys", + "header = b''", + r"while not header.endswith(b'\r\n\r\n'):", + " chunk = sys.stdin.buffer.read(1)", + " if not chunk:", + " raise SystemExit(1)", + " header += chunk", + "length = 0", + r"for line in header.decode().split('\r\n'):", + r" if line.lower().startswith('content-length:'):", + r" length = int(line.split(':', 1)[1].strip())", + "if length:", + " sys.stdin.buffer.read(length)", + "raise SystemExit(0)", + "", + ] + .join("\n"); + fs::write(&script_path, script).expect("write script"); + let mut permissions = fs::metadata(&script_path).expect("metadata").permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&script_path, permissions).expect("chmod"); + script_path + } + #[test] - #[ignore = "flaky: intermittent timing issues in CI, see ROADMAP P2.15"] fn manager_discovery_report_keeps_healthy_servers_when_one_server_fails() { let runtime = Builder::new_current_thread() .enable_all() @@ -2663,6 +2692,7 @@ mod tests { let script_path = write_manager_mcp_server_script(); let root = script_path.parent().expect("script parent"); let alpha_log = root.join("alpha.log"); + let broken_script_path = write_initialize_disconnect_script(); let servers = BTreeMap::from([ ( "alpha".to_string(), @@ -2673,8 +2703,8 @@ mod tests { ScopedMcpServerConfig { scope: ConfigSource::Local, config: McpServerConfig::Stdio(McpStdioServerConfig { - command: "python3".to_string(), - args: vec!["-c".to_string(), "import sys; sys.exit(0)".to_string()], + command: broken_script_path.display().to_string(), + args: Vec::new(), env: BTreeMap::new(), tool_call_timeout_ms: None, }), @@ -2737,6 +2767,7 @@ mod tests { manager.shutdown().await.expect("shutdown"); cleanup_script(&script_path); + cleanup_script(&broken_script_path); }); } diff --git a/rust/crates/rusty-claude-cli/Cargo.toml b/rust/crates/rusty-claude-cli/Cargo.toml index 4e2e8e7..635fdb3 100644 --- a/rust/crates/rusty-claude-cli/Cargo.toml +++ b/rust/crates/rusty-claude-cli/Cargo.toml @@ -31,3 +31,4 @@ workspace = true mock-anthropic-service = { path = "../mock-anthropic-service" } serde_json.workspace = true tokio = { version = "1", features = ["rt-multi-thread"] } + diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3f01652..aa164c9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -89,6 +89,10 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[ ]; type AllowedToolSet = BTreeSet; +type RuntimePluginStateBuildOutput = ( + Option>>, + Vec, +); fn main() { if let Err(error) = run() { @@ -109,9 +113,12 @@ Run `claw --help` for usage." fn run() -> Result<(), Box> { let args: Vec = env::args().skip(1).collect(); match parse_args(&args)? { - CliAction::DumpManifests => dump_manifests(), - CliAction::BootstrapPlan => print_bootstrap_plan(), - CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, + CliAction::DumpManifests { output_format } => dump_manifests(output_format)?, + CliAction::BootstrapPlan { output_format } => print_bootstrap_plan(output_format)?, + CliAction::Agents { + args, + output_format, + } => LiveCli::print_agents(args.as_deref(), output_format)?, CliAction::Mcp { args, output_format, @@ -120,12 +127,17 @@ fn run() -> Result<(), Box> { args, output_format, } => LiveCli::print_skills(args.as_deref(), output_format)?, - CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), - CliAction::Version => print_version(), + CliAction::PrintSystemPrompt { + cwd, + date, + output_format, + } => print_system_prompt(cwd, date, output_format)?, + CliAction::Version { output_format } => print_version(output_format)?, CliAction::ResumeSession { session_path, commands, - } => resume_session(&session_path, &commands), + output_format, + } => resume_session(&session_path, &commands, output_format), CliAction::Status { model, permission_mode, @@ -140,27 +152,32 @@ fn run() -> Result<(), Box> { permission_mode, } => LiveCli::new(model, true, allowed_tools, permission_mode)? .run_turn_with_output(&prompt, output_format)?, - CliAction::Login => run_login()?, - CliAction::Logout => run_logout()?, - CliAction::Doctor => run_doctor()?, - CliAction::Init => run_init()?, + CliAction::Login { output_format } => run_login(output_format)?, + CliAction::Logout { output_format } => run_logout(output_format)?, + CliAction::Doctor { output_format } => run_doctor(output_format)?, + CliAction::Init { output_format } => run_init(output_format)?, CliAction::Repl { model, allowed_tools, permission_mode, } => run_repl(model, allowed_tools, permission_mode)?, CliAction::HelpTopic(topic) => print_help_topic(topic), - CliAction::Help => print_help(), + CliAction::Help { output_format } => print_help(output_format)?, } Ok(()) } #[derive(Debug, Clone, PartialEq, Eq)] enum CliAction { - DumpManifests, - BootstrapPlan, + DumpManifests { + output_format: CliOutputFormat, + }, + BootstrapPlan { + output_format: CliOutputFormat, + }, Agents { args: Option, + output_format: CliOutputFormat, }, Mcp { args: Option, @@ -173,11 +190,15 @@ enum CliAction { PrintSystemPrompt { cwd: PathBuf, date: String, + output_format: CliOutputFormat, + }, + Version { + output_format: CliOutputFormat, }, - Version, ResumeSession { session_path: PathBuf, commands: Vec, + output_format: CliOutputFormat, }, Status { model: String, @@ -194,10 +215,18 @@ enum CliAction { allowed_tools: Option, permission_mode: PermissionMode, }, - Login, - Logout, - Doctor, - Init, + Login { + output_format: CliOutputFormat, + }, + Logout { + output_format: CliOutputFormat, + }, + Doctor { + output_format: CliOutputFormat, + }, + Init { + output_format: CliOutputFormat, + }, Repl { model: String, allowed_tools: Option, @@ -205,7 +234,9 @@ enum CliAction { }, HelpTopic(LocalHelpTopic), // prompt-mode formatting is only supported for non-interactive runs - Help, + Help { + output_format: CliOutputFormat, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -346,11 +377,11 @@ fn parse_args(args: &[String]) -> Result { } if wants_help { - return Ok(CliAction::Help); + return Ok(CliAction::Help { output_format }); } if wants_version { - return Ok(CliAction::Version); + return Ok(CliAction::Version { output_format }); } let allowed_tools = normalize_allowed_tools(&allowed_tool_values)?; @@ -364,22 +395,25 @@ fn parse_args(args: &[String]) -> Result { }); } if rest.first().map(String::as_str) == Some("--resume") { - return parse_resume_args(&rest[1..]); + return parse_resume_args(&rest[1..], output_format); } if let Some(action) = parse_local_help_action(&rest) { return action; } - if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format) { + if let Some(action) = + parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format) + { return action; } let permission_mode = permission_mode_override.unwrap_or_else(default_permission_mode); match rest[0].as_str() { - "dump-manifests" => Ok(CliAction::DumpManifests), - "bootstrap-plan" => Ok(CliAction::BootstrapPlan), + "dump-manifests" => Ok(CliAction::DumpManifests { output_format }), + "bootstrap-plan" => Ok(CliAction::BootstrapPlan { output_format }), "agents" => Ok(CliAction::Agents { args: join_optional_args(&rest[1..]), + output_format, }), "mcp" => Ok(CliAction::Mcp { args: join_optional_args(&rest[1..]), @@ -389,10 +423,10 @@ fn parse_args(args: &[String]) -> Result { args: join_optional_args(&rest[1..]), output_format, }), - "system-prompt" => parse_system_prompt_args(&rest[1..]), - "login" => Ok(CliAction::Login), - "logout" => Ok(CliAction::Logout), - "init" => Ok(CliAction::Init), + "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), + "login" => Ok(CliAction::Login { output_format }), + "logout" => Ok(CliAction::Logout { output_format }), + "init" => Ok(CliAction::Init { output_format }), "prompt" => { let prompt = rest[1..].join(" "); if prompt.trim().is_empty() { @@ -446,15 +480,15 @@ fn parse_single_word_command_alias( } match rest[0].as_str() { - "help" => Some(Ok(CliAction::Help)), - "version" => Some(Ok(CliAction::Version)), + "help" => Some(Ok(CliAction::Help { output_format })), + "version" => Some(Ok(CliAction::Version { output_format })), "status" => Some(Ok(CliAction::Status { model: model.to_string(), permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), output_format, })), "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), - "doctor" => Some(Ok(CliAction::Doctor)), + "doctor" => Some(Ok(CliAction::Doctor { output_format })), other => bare_slash_command_guidance(other).map(Err), } } @@ -502,8 +536,11 @@ fn parse_direct_slash_cli_action( ) -> Result { let raw = rest.join(" "); match SlashCommand::parse(&raw) { - Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help), - Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { args }), + Ok(Some(SlashCommand::Help)) => Ok(CliAction::Help { output_format }), + Ok(Some(SlashCommand::Agents { args })) => Ok(CliAction::Agents { + args, + output_format, + }), Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp { args: match (action, target) { (None, None) => None, @@ -727,7 +764,10 @@ fn filter_tool_specs( tool_registry.definitions(allowed_tools) } -fn parse_system_prompt_args(args: &[String]) -> Result { +fn parse_system_prompt_args( + args: &[String], + output_format: CliOutputFormat, +) -> Result { let mut cwd = env::current_dir().map_err(|error| error.to_string())?; let mut date = DEFAULT_DATE.to_string(); let mut index = 0; @@ -752,10 +792,14 @@ fn parse_system_prompt_args(args: &[String]) -> Result { } } - Ok(CliAction::PrintSystemPrompt { cwd, date }) + Ok(CliAction::PrintSystemPrompt { + cwd, + date, + output_format, + }) } -fn parse_resume_args(args: &[String]) -> Result { +fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result { let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), Some(first) if looks_like_slash_command_token(first) => { @@ -795,6 +839,7 @@ fn parse_resume_args(args: &[String]) -> Result { Ok(CliAction::ResumeSession { session_path, commands, + output_format, }) } @@ -930,9 +975,21 @@ fn render_doctor_report() -> Result> { }) } -fn run_doctor() -> Result<(), Box> { +fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box> { let report = render_doctor_report()?; - println!("{}", report.render()); + let message = report.render(); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "doctor", + "message": message, + "report": message, + "has_failures": report.has_failures(), + }))? + ), + } if report.has_failures() { return Err("doctor found failing checks".into()); } @@ -1212,26 +1269,54 @@ fn looks_like_slash_command_token(token: &str) -> bool { .any(|spec| spec.name == name || spec.aliases.contains(&name)) } -fn dump_manifests() { +fn dump_manifests(output_format: CliOutputFormat) -> Result<(), Box> { let workspace_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); let paths = UpstreamPaths::from_workspace_dir(&workspace_dir); match extract_manifest(&paths) { Ok(manifest) => { - println!("commands: {}", manifest.commands.entries().len()); - println!("tools: {}", manifest.tools.entries().len()); - println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); - } - Err(error) => { - eprintln!("failed to extract manifests: {error}"); - std::process::exit(1); + match output_format { + CliOutputFormat::Text => { + println!("commands: {}", manifest.commands.entries().len()); + println!("tools: {}", manifest.tools.entries().len()); + println!("bootstrap phases: {}", manifest.bootstrap.phases().len()); + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "dump-manifests", + "commands": manifest.commands.entries().len(), + "tools": manifest.tools.entries().len(), + "bootstrap_phases": manifest.bootstrap.phases().len(), + }))? + ), + } + Ok(()) } + Err(error) => Err(format!("failed to extract manifests: {error}").into()), } } -fn print_bootstrap_plan() { - for phase in runtime::BootstrapPlan::claude_code_default().phases() { - println!("- {phase:?}"); +fn print_bootstrap_plan(output_format: CliOutputFormat) -> Result<(), Box> { + let phases = runtime::BootstrapPlan::claude_code_default() + .phases() + .iter() + .map(|phase| format!("{phase:?}")) + .collect::>(); + match output_format { + CliOutputFormat::Text => { + for phase in &phases { + println!("- {phase}"); + } + } + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "bootstrap-plan", + "phases": phases, + }))? + ), } + Ok(()) } fn default_oauth_config() -> OAuthConfig { @@ -1249,7 +1334,7 @@ fn default_oauth_config() -> OAuthConfig { } } -fn run_login() -> Result<(), Box> { +fn run_login(output_format: CliOutputFormat) -> Result<(), Box> { let cwd = env::current_dir()?; let config = ConfigLoader::default_for(&cwd).load()?; let default_oauth = default_oauth_config(); @@ -1262,8 +1347,10 @@ fn run_login() -> Result<(), Box> { OAuthAuthorizationRequest::from_config(oauth, redirect_uri.clone(), state.clone(), &pkce) .build_url(); - println!("Starting Claude OAuth login..."); - println!("Listening for callback on {redirect_uri}"); + if output_format == CliOutputFormat::Text { + println!("Starting Claude OAuth login..."); + println!("Listening for callback on {redirect_uri}"); + } if let Err(error) = open_browser(&authorize_url) { eprintln!("warning: failed to open browser automatically: {error}"); println!("Open this URL manually:\n{authorize_url}"); @@ -1287,8 +1374,13 @@ fn run_login() -> Result<(), Box> { } let client = AnthropicClient::from_auth(AuthSource::None).with_base_url(api::read_base_url()); - let exchange_request = - OAuthTokenExchangeRequest::from_config(oauth, code, state, pkce.verifier, redirect_uri); + let exchange_request = OAuthTokenExchangeRequest::from_config( + oauth, + code, + state, + pkce.verifier, + redirect_uri.clone(), + ); let runtime = tokio::runtime::Runtime::new()?; let token_set = runtime.block_on(client.exchange_oauth_code(oauth, &exchange_request))?; save_oauth_credentials(&runtime::OAuthTokenSet { @@ -1297,13 +1389,33 @@ fn run_login() -> Result<(), Box> { expires_at: token_set.expires_at, scopes: token_set.scopes, })?; - println!("Claude OAuth login complete."); + match output_format { + CliOutputFormat::Text => println!("Claude OAuth login complete."), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "login", + "callback_port": callback_port, + "redirect_uri": redirect_uri, + "message": "Claude OAuth login complete.", + }))? + ), + } Ok(()) } -fn run_logout() -> Result<(), Box> { +fn run_logout(output_format: CliOutputFormat) -> Result<(), Box> { clear_oauth_credentials()?; - println!("Claude OAuth credentials cleared."); + match output_format { + CliOutputFormat::Text => println!("Claude OAuth credentials cleared."), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "logout", + "message": "Claude OAuth credentials cleared.", + }))? + ), + } Ok(()) } @@ -1361,21 +1473,50 @@ fn wait_for_oauth_callback( Ok(callback) } -fn print_system_prompt(cwd: PathBuf, date: String) { - match load_system_prompt(cwd, date, env::consts::OS, "unknown") { - Ok(sections) => println!("{}", sections.join("\n\n")), - Err(error) => { - eprintln!("failed to build system prompt: {error}"); - std::process::exit(1); - } +fn print_system_prompt( + cwd: PathBuf, + date: String, + output_format: CliOutputFormat, +) -> Result<(), Box> { + let sections = load_system_prompt(cwd, date, env::consts::OS, "unknown")?; + let message = sections.join( + " + +", + ); + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "system-prompt", + "message": message, + "sections": sections, + }))? + ), } + Ok(()) } -fn print_version() { - println!("{}", render_version_report()); +fn print_version(output_format: CliOutputFormat) -> Result<(), Box> { + let report = render_version_report(); + match output_format { + CliOutputFormat::Text => println!("{report}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "version", + "message": report, + "version": VERSION, + "git_sha": GIT_SHA, + "target": BUILD_TARGET, + }))? + ), + } + Ok(()) } -fn resume_session(session_path: &Path, commands: &[String]) { +fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { let resolved_path = if session_path.exists() { session_path.to_path_buf() } else { @@ -1425,7 +1566,35 @@ fn resume_session(session_path: &Path, commands: &[String]) { }) => { session = next_session; if let Some(message) = message { - println!("{message}"); + if output_format == CliOutputFormat::Json + && matches!(command, SlashCommand::Status) + { + let tracker = UsageTracker::from_session(&session); + let usage = tracker.cumulative_usage(); + let context = status_context(Some(&resolved_path)).expect("status context"); + let value = json!({ + "kind": "status", + "messages": session.messages.len(), + "turns": tracker.turns(), + "latest_total": tracker.current_turn_usage().total_tokens(), + "cumulative_input": usage.input_tokens, + "cumulative_output": usage.output_tokens, + "cumulative_total": usage.total_tokens(), + "workspace": { + "cwd": context.cwd, + "project_root": context.project_root, + "git_branch": context.git_branch, + "git_state": context.git_summary.headline(), + "session": context.session_path.as_ref().map_or_else(|| "live-repl".to_string(), |path| path.display().to_string()), + } + }); + println!( + "{}", + serde_json::to_string_pretty(&value).expect("status json") + ); + } else { + println!("{message}"); + } } } Err(error) => { @@ -2357,13 +2526,7 @@ impl RuntimeMcpState { fn build_runtime_mcp_state( runtime_config: &runtime::RuntimeConfig, -) -> Result< - ( - Option>>, - Vec, - ), - Box, -> { +) -> Result> { let Some((mcp_state, discovery)) = RuntimeMcpState::new(runtime_config)? else { return Ok((None, Vec::new())); }; @@ -2809,7 +2972,7 @@ impl LiveCli { false } SlashCommand::Init => { - run_init()?; + run_init(CliOutputFormat::Text)?; false } SlashCommand::Diff => { @@ -2817,7 +2980,7 @@ impl LiveCli { false } SlashCommand::Version => { - Self::print_version(); + Self::print_version(CliOutputFormat::Text); false } SlashCommand::Export { path } => { @@ -2831,7 +2994,7 @@ impl LiveCli { self.handle_plugins_command(action.as_deref(), target.as_deref())? } SlashCommand::Agents { args } => { - Self::print_agents(args.as_deref())?; + Self::print_agents(args.as_deref(), CliOutputFormat::Text)?; false } SlashCommand::Skills { args } => { @@ -3113,9 +3276,23 @@ impl LiveCli { Ok(()) } - fn print_agents(args: Option<&str>) -> Result<(), Box> { + fn print_agents( + args: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { let cwd = env::current_dir()?; - println!("{}", handle_agents_slash_command(args, &cwd)?); + let message = handle_agents_slash_command(args, &cwd)?; + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "agents", + "message": message, + "args": args, + }))? + ), + } Ok(()) } @@ -3154,8 +3331,8 @@ impl LiveCli { Ok(()) } - fn print_version() { - println!("{}", render_version_report()); + fn print_version(output_format: CliOutputFormat) { + let _ = crate::print_version(output_format); } fn export_session( @@ -4060,8 +4237,18 @@ fn init_claude_md() -> Result> { Ok(initialize_repo(&cwd)?.render()) } -fn run_init() -> Result<(), Box> { - println!("{}", init_claude_md()?); +fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { + let message = init_claude_md()?; + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "init", + "message": message, + }))? + ), + } Ok(()) } @@ -5916,16 +6103,13 @@ impl CliToolExecutor { fn execute_search_tool(&self, value: serde_json::Value) -> Result { let input: ToolSearchRequest = serde_json::from_value(value) .map_err(|error| ToolError::new(format!("invalid tool input JSON: {error}")))?; - let (pending_mcp_servers, mcp_degraded) = self - .mcp_state - .as_ref() - .map(|state| { + let (pending_mcp_servers, mcp_degraded) = + self.mcp_state.as_ref().map_or((None, None), |state| { let state = state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); (state.pending_servers(), state.degraded_report()) - }) - .unwrap_or((None, None)); + }); serde_json::to_string_pretty(&self.tool_registry.search( &input.query, input.max_results.unwrap_or(5), @@ -6203,8 +6387,21 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { Ok(()) } -fn print_help() { - let _ = print_help_to(&mut io::stdout()); +fn print_help(output_format: CliOutputFormat) -> Result<(), Box> { + let mut buffer = Vec::new(); + print_help_to(&mut buffer)?; + let message = String::from_utf8(buffer)?; + match output_format { + CliOutputFormat::Text => print!("{message}"), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "help", + "message": message, + }))? + ), + } + Ok(()) } #[cfg(test)] @@ -6513,11 +6710,15 @@ mod tests { fn parses_version_flags_without_initializing_prompt_mode() { assert_eq!( parse_args(&["--version".to_string()]).expect("args should parse"), - CliAction::Version + CliAction::Version { + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["-V".to_string()]).expect("args should parse"), - CliAction::Version + CliAction::Version { + output_format: CliOutputFormat::Text, + } ); } @@ -6579,6 +6780,7 @@ mod tests { CliAction::PrintSystemPrompt { cwd: PathBuf::from("/tmp/project"), date: "2026-04-01".to_string(), + output_format: CliOutputFormat::Text, } ); } @@ -6587,23 +6789,34 @@ mod tests { fn parses_login_and_logout_subcommands() { assert_eq!( parse_args(&["login".to_string()]).expect("login should parse"), - CliAction::Login + CliAction::Login { + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["logout".to_string()]).expect("logout should parse"), - CliAction::Logout + CliAction::Logout { + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["doctor".to_string()]).expect("doctor should parse"), - CliAction::Doctor + CliAction::Doctor { + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["init".to_string()]).expect("init should parse"), - CliAction::Init + CliAction::Init { + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["agents".to_string()]).expect("agents should parse"), - CliAction::Agents { args: None } + CliAction::Agents { + args: None, + output_format: CliOutputFormat::Text + } ); assert_eq!( parse_args(&["mcp".to_string()]).expect("mcp should parse"), @@ -6623,7 +6836,8 @@ mod tests { parse_args(&["agents".to_string(), "--help".to_string()]) .expect("agents help should parse"), CliAction::Agents { - args: Some("--help".to_string()) + args: Some("--help".to_string()), + output_format: CliOutputFormat::Text, } ); } @@ -6653,11 +6867,15 @@ mod tests { std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); assert_eq!( parse_args(&["help".to_string()]).expect("help should parse"), - CliAction::Help + CliAction::Help { + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["version".to_string()]).expect("version should parse"), - CliAction::Version + CliAction::Version { + output_format: CliOutputFormat::Text, + } ); assert_eq!( parse_args(&["status".to_string()]).expect("status should parse"), @@ -6727,7 +6945,10 @@ mod tests { fn parses_direct_agents_mcp_and_skills_slash_commands() { assert_eq!( parse_args(&["/agents".to_string()]).expect("/agents should parse"), - CliAction::Agents { args: None } + CliAction::Agents { + args: None, + output_format: CliOutputFormat::Text + } ); assert_eq!( parse_args(&["/mcp".to_string(), "show".to_string(), "demo".to_string()]) @@ -6807,6 +7028,7 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec!["/compact".to_string()], + output_format: CliOutputFormat::Text, } ); } @@ -6818,6 +7040,7 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec![], + output_format: CliOutputFormat::Text, } ); assert_eq!( @@ -6826,6 +7049,7 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec!["/status".to_string()], + output_format: CliOutputFormat::Text, } ); } @@ -6848,6 +7072,7 @@ mod tests { "/compact".to_string(), "/cost".to_string(), ], + output_format: CliOutputFormat::Text, } ); } @@ -6878,6 +7103,7 @@ mod tests { "/export notes.txt".to_string(), "/clear --confirm".to_string(), ], + output_format: CliOutputFormat::Text, } ); } @@ -6896,6 +7122,7 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()], + output_format: CliOutputFormat::Text, } ); } @@ -8005,6 +8232,7 @@ UU conflicted.rs", } #[test] + #[allow(clippy::too_many_lines)] fn build_runtime_plugin_state_discovers_mcp_tools_and_surfaces_pending_servers() { let config_home = temp_dir(); let workspace = temp_dir(); diff --git a/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs b/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs index b559906..4091613 100644 --- a/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs +++ b/rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs @@ -729,8 +729,7 @@ fn assert_token_cost_reporting(_: &HarnessWorkspace, run: &ScenarioRun) { assert!( run.response["estimated_cost"] .as_str() - .map(|cost| cost.starts_with('$')) - .unwrap_or(false), + .is_some_and(|cost| cost.starts_with('$')), "estimated_cost should be a dollar-prefixed string" ); } diff --git a/rust/crates/rusty-claude-cli/tests/output_format_contract.rs b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs new file mode 100644 index 0000000..7b6848e --- /dev/null +++ b/rust/crates/rusty-claude-cli/tests/output_format_contract.rs @@ -0,0 +1,193 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde_json::Value; + +static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[test] +fn help_emits_json_when_requested() { + let root = unique_temp_dir("help-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let parsed = assert_json_command(&root, &["--output-format", "json", "help"]); + assert_eq!(parsed["kind"], "help"); + assert!(parsed["message"] + .as_str() + .expect("help text") + .contains("Usage:")); +} + +#[test] +fn version_emits_json_when_requested() { + let root = unique_temp_dir("version-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let parsed = assert_json_command(&root, &["--output-format", "json", "version"]); + assert_eq!(parsed["kind"], "version"); + assert_eq!(parsed["version"], env!("CARGO_PKG_VERSION")); +} + +#[test] +fn status_and_sandbox_emit_json_when_requested() { + let root = unique_temp_dir("status-sandbox-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let status = assert_json_command(&root, &["--output-format", "json", "status"]); + assert_eq!(status["kind"], "status"); + assert!(status["workspace"]["cwd"].as_str().is_some()); + + let sandbox = assert_json_command(&root, &["--output-format", "json", "sandbox"]); + assert_eq!(sandbox["kind"], "sandbox"); + assert!(sandbox["filesystem_mode"].as_str().is_some()); +} + +#[test] +fn inventory_commands_emit_structured_json_when_requested() { + let root = unique_temp_dir("inventory-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let agents = assert_json_command(&root, &["--output-format", "json", "agents"]); + assert_eq!(agents["kind"], "agents"); + + let mcp = assert_json_command(&root, &["--output-format", "json", "mcp"]); + assert_eq!(mcp["kind"], "mcp"); + assert_eq!(mcp["action"], "list"); + + let skills = assert_json_command(&root, &["--output-format", "json", "skills"]); + assert_eq!(skills["kind"], "skills"); + assert_eq!(skills["action"], "list"); +} + +#[test] +fn bootstrap_and_system_prompt_emit_json_when_requested() { + let root = unique_temp_dir("bootstrap-system-prompt-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let plan = assert_json_command(&root, &["--output-format", "json", "bootstrap-plan"]); + assert_eq!(plan["kind"], "bootstrap-plan"); + assert!(plan["phases"].as_array().expect("phases").len() > 1); + + let prompt = assert_json_command(&root, &["--output-format", "json", "system-prompt"]); + assert_eq!(prompt["kind"], "system-prompt"); + assert!(prompt["message"] + .as_str() + .expect("prompt text") + .contains("interactive agent")); +} + +#[test] +fn dump_manifests_and_init_emit_json_when_requested() { + let root = unique_temp_dir("manifest-init-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let upstream = write_upstream_fixture(&root); + let manifests = assert_json_command_with_env( + &root, + &["--output-format", "json", "dump-manifests"], + &[( + "CLAUDE_CODE_UPSTREAM", + upstream.to_str().expect("utf8 upstream"), + )], + ); + assert_eq!(manifests["kind"], "dump-manifests"); + assert_eq!(manifests["commands"], 1); + assert_eq!(manifests["tools"], 1); + + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).expect("workspace should exist"); + let init = assert_json_command(&workspace, &["--output-format", "json", "init"]); + assert_eq!(init["kind"], "init"); + assert!(workspace.join("CLAUDE.md").exists()); +} + +#[test] +fn doctor_and_resume_status_emit_json_when_requested() { + let root = unique_temp_dir("doctor-resume-json"); + fs::create_dir_all(&root).expect("temp dir should exist"); + + let doctor = assert_json_command(&root, &["--output-format", "json", "doctor"]); + assert_eq!(doctor["kind"], "doctor"); + assert!(doctor["message"].is_string()); + + let session_path = root.join("session.jsonl"); + fs::write( + &session_path, + "{\"type\":\"session_meta\",\"version\":3,\"session_id\":\"resume-json\",\"created_at_ms\":0,\"updated_at_ms\":0}\n{\"type\":\"message\",\"message\":{\"role\":\"user\",\"blocks\":[{\"type\":\"text\",\"text\":\"hello\"}]}}\n", + ) + .expect("session should write"); + let resumed = assert_json_command( + &root, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 session path"), + "/status", + ], + ); + assert_eq!(resumed["kind"], "status"); + assert_eq!(resumed["messages"], 1); +} + +fn assert_json_command(current_dir: &Path, args: &[&str]) -> Value { + assert_json_command_with_env(current_dir, args, &[]) +} + +fn assert_json_command_with_env(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Value { + let output = run_claw(current_dir, args, envs); + assert!( + output.status.success(), + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("stdout should be valid json") +} + +fn run_claw(current_dir: &Path, args: &[&str], envs: &[(&str, &str)]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); + command.current_dir(current_dir).args(args); + for (key, value) in envs { + command.env(key, value); + } + command.output().expect("claw should launch") +} + +fn write_upstream_fixture(root: &Path) -> PathBuf { + let upstream = root.join("claw-code"); + let src = upstream.join("src"); + let entrypoints = src.join("entrypoints"); + fs::create_dir_all(&entrypoints).expect("upstream entrypoints dir should exist"); + fs::write( + src.join("commands.ts"), + "import FooCommand from './commands/foo'\n", + ) + .expect("commands fixture should write"); + fs::write( + src.join("tools.ts"), + "import ReadTool from './tools/read'\n", + ) + .expect("tools fixture should write"); + fs::write( + entrypoints.join("cli.tsx"), + "if (args[0] === '--version') {}\nstartupProfiler()\n", + ) + .expect("cli fixture should write"); + upstream +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_millis(); + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "claw-output-format-{label}-{}-{millis}-{counter}", + std::process::id() + )) +} diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index d1dcf75..8efa5f3 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -11,7 +11,8 @@ use api::{ use plugins::PluginTool; use reqwest::blocking::Client; use runtime::{ - check_freshness, edit_file, execute_bash, glob_search, grep_search, load_system_prompt, + check_freshness, dedupe_superseded_commit_events, edit_file, execute_bash, glob_search, + grep_search, load_system_prompt, lsp_client::LspRegistry, mcp_tool_bridge::McpToolRegistry, permission_enforcer::{EnforcementResult, PermissionEnforcer}, @@ -1704,7 +1705,7 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result { "method": method, "status_code": status, "body": truncated_body, - "success": status >= 200 && status < 300 + "success": (200..300).contains(&status) })) } Err(e) => to_pretty_json(json!({ @@ -3276,9 +3277,11 @@ fn agent_permission_policy() -> PermissionPolicy { } fn write_agent_manifest(manifest: &AgentOutput) -> Result<(), String> { + let mut normalized = manifest.clone(); + normalized.lane_events = dedupe_superseded_commit_events(&normalized.lane_events); std::fs::write( - &manifest.manifest_file, - serde_json::to_string_pretty(manifest).map_err(|error| error.to_string())?, + &normalized.manifest_file, + serde_json::to_string_pretty(&normalized).map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string()) } @@ -3297,7 +3300,7 @@ fn persist_agent_terminal_state( let mut next_manifest = manifest.clone(); next_manifest.status = status.to_string(); next_manifest.completed_at = Some(iso8601_now()); - next_manifest.current_blocker = blocker.clone(); + next_manifest.current_blocker.clone_from(&blocker); next_manifest.error = error; if let Some(blocker) = blocker { next_manifest @@ -5823,6 +5826,7 @@ mod tests { } #[test] + #[allow(clippy::too_many_lines)] fn agent_fake_runner_can_persist_completion_and_failure() { let _guard = env_lock() .lock()