Compare commits

..

31 Commits

Author SHA1 Message Date
Yeachan-Heo
a9efc734d5 Preserve plugin and hook semantics while finishing the hook-pipeline merge
The merge now keeps plugin lifecycle management, plugin tool permissions,
hook abort/progress handling, permission-rule config, and shared slash-command
help aligned across runtime and CLI codepaths.

Constraint: Merge had to retain both plugin runtime behavior and hook-pipeline permission/abort features
Rejected: Drop plugin-aware runtime paths during merge | would regress installed plugin hooks and lifecycle handling
Rejected: Prefer hook-pipeline tool permissions over the global tool registry | would lose plugin tool permission mapping
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep runtime hook flow, permission policy wiring, and slash-command surfaces synchronized across crates during future merges
Tested: cargo test; cargo fmt --all --check; git diff --check
Not-tested: Live networked ANTHROPIC_API_KEY smoke path
2026-04-01 09:00:55 +00:00
Yeachan-Heo
f509d569ae fix: handle new slash command variants in cli 2026-04-01 08:40:20 +00:00
Yeachan-Heo
8ae96376a2 merge: integrate plugin subsystem and command surface from rcc/plugins 2026-04-01 08:17:27 +00:00
Yeachan-Heo
486fccfa3e feat: expand slash command surface 2026-04-01 08:15:23 +00:00
Yeachan-Heo
7464302fd3 feat: command surface follow-up integration 2026-04-01 08:10:36 +00:00
Yeachan-Heo
0755a36811 Clear stale enabled state during plugin loader pruning
The plugin loader already pruned stale registry entries, but stale enabled state
could linger in settings.json after bundled or installed plugin discovery
cleaned up missing installs. This change removes those orphaned enabled flags
when stale registry entries are dropped so loader-managed state stays coherent.

Constraint: Commit only plugin loader/registry code in this pass
Rejected: Leave stale enabled flags in settings.json | state drift would survive loader self-healing
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future loader-side pruning should remove matching enabled state in the same code path
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Interactive CLI /plugins flows against manually edited settings.json
2026-04-01 08:10:36 +00:00
Yeachan-Heo
7f33569f3a feat: command surface and slash completion wiring 2026-04-01 08:06:10 +00:00
Yeachan-Heo
24fea5db9e Prove raw tool output truncation stays display-only
Add a renderer regression test for long non-JSON tool output so the CLI's fallback rendering path is covered alongside Read and structured tool payload truncation.

Constraint: This follow-up must commit only renderer-related changes
Rejected: Touch commands crate to fix unrelated slash-command work in progress | outside the requested renderer-only scope
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep truncation guarantees covered at the renderer boundary for both structured and raw tool payloads
Tested: cargo fmt --all; cargo test -p rusty-claude-cli tool_rendering_ -- --nocapture; cargo clippy -p rusty-claude-cli --all-targets -- -D warnings
Not-tested: cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings currently fail in rust/crates/commands/src/lib.rs due pre-existing incomplete agents/skills changes outside this commit
2026-04-01 08:06:10 +00:00
Yeachan-Heo
782d9cea71 Preserve ILM-style conversation continuity during auto compaction
Auto compaction was keying off cumulative usage and re-summarizing from the front of the session, which made long chats shed continuity after the first compaction. The runtime now compacts against the current turn's prompt pressure and preserves prior compacted context as retained summary state instead of treating it like disposable history.

Constraint: Existing /compact behavior and saved-session resume flow had to keep working without schema changes
Rejected: Keep using cumulative input tokens | caused repeat compaction after every subsequent turn once the threshold was crossed
Rejected: Re-summarize prior compacted system messages as ordinary history | degraded continuity and could drop earlier context
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Preserve compacted-summary boundaries when extending compaction again; do not fold prior compacted context back into raw-message removal
Tested: cargo fmt --check; cargo clippy -p runtime -p commands --tests -- -D warnings; cargo test -p runtime; cargo test -p commands
Not-tested: End-to-end interactive CLI auto-compaction against a live Anthropic session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
97d725d5e5 Keep CLI tool previews readable without truncating session data
Extend the CLI renderer's generic tool-result path to reuse the existing display-only truncation helper, so large plugin or unknown-tool payloads no longer flood the terminal while the original tool result still flows through runtime/session state unchanged.

The renderer now pretty-prints structured fallback payloads before truncating them for display, and the test suite covers both Read output and generic long tool output rendering. I also added a narrow clippy allow on an oversized slash-command parser test so the workspace lint gate stays green during verification.

Constraint: Tool result truncation must affect screen rendering only, not stored tool output
Rejected: Truncate tool results at execution time | would lose session fidelity and break downstream consumers
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep future tool-output shortening in renderer helpers only; do not trim runtime tool payloads before persistence
Tested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace
Not-tested: Manual interactive terminal run showing truncation in a live REPL session
2026-04-01 08:06:10 +00:00
Yeachan-Heo
d794acd3f4 Keep CLI tool rendering readable without dropping result fidelity
Some tools, especially Read, can emit very large payloads that overwhelm the interactive renderer. This change truncates only the displayed preview for long tool outputs while leaving the underlying tool result string untouched for downstream logic and persisted session state.

Constraint: Rendering changes must not modify stored tool outputs or tool-result messages
Rejected: Truncate tool output before returning from the executor | would corrupt session history and downstream processing
Confidence: high
Scope-risk: narrow
Directive: Keep truncation strictly in presentation helpers; do not move it into tool execution or session persistence paths
Tested: cargo test -p rusty-claude-cli tool_rendering_truncates_ -- --nocapture; cargo test -p rusty-claude-cli tool_rendering_helpers_compact_output -- --nocapture
Not-tested: Manual terminal rendering with real multi-megabyte tool output
2026-04-01 08:06:10 +00:00
Yeachan-Heo
4c1eaa16e0 Ignore reasoning blocks in runtime adapters without affecting tool/text flows
After the parser can accept thinking-style blocks, the CLI and tools adapters must explicitly ignore them so only user-visible text and tool calls drive runtime behavior. This keeps reasoning metadata from surfacing as text or interfering with tool accumulation.

Constraint: Runtime behavior must remain unchanged for normal text/tool streaming
Rejected: Treat thinking blocks as assistant text | would leak hidden reasoning into visible output and session flow
Confidence: high
Scope-risk: narrow
Directive: If future features need persisted reasoning blocks, add a dedicated runtime representation instead of overloading text handling
Tested: cargo test -p rusty-claude-cli response_to_events_ignores_thinking_blocks -- --nocapture; cargo test -p tools response_to_events_ignores_thinking_blocks -- --nocapture
Not-tested: End-to-end interactive run against a live thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
13851d800f Accept reasoning-style content blocks in the Rust API parser
The Rust API layer rejected thinking-enabled responses because it only recognized text and tool_use content blocks. This commit extends the response and SSE parser types to accept reasoning-style content blocks and deltas, with regression coverage for both non-streaming and streaming responses.

Constraint: Keep parsing compatible with existing text and tool-use message flows
Rejected: Deserialize unknown content blocks into an untyped catch-all | would weaken protocol coverage and test precision
Confidence: high
Scope-risk: narrow
Directive: Keep new protocol variants covered at the API boundary so downstream code can make explicit choices about preservation vs. ignoring
Tested: cargo test -p api thinking -- --nocapture
Not-tested: Live API traffic from a real thinking-enabled model
2026-04-01 08:06:10 +00:00
Yeachan-Heo
9e717192f8 Allow subagent tool flows to reach plugin-provided tools
The subagent runtime still advertised and executed only built-in tools, which left plugin-provided tools outside the Agent execution path. This change loads the same plugin-aware registry used by the CLI for subagent tool definitions, permission policy, and execution lookup so delegated runs can resolve plugin tools consistently.

Constraint: Plugin tools must respect the existing runtime plugin config and enabled-plugin state

Rejected: Thread plugin-specific exceptions through execute_tool directly | would bypass registry validation and duplicate lookup rules

Confidence: medium

Scope-risk: moderate

Reversibility: clean

Directive: Keep CLI and subagent registry construction aligned when plugin tool loading rules change

Tested: cargo test -p tools -p rusty-claude-cli

Not-tested: Live Anthropic subagent runs invoking plugin tools end-to-end
2026-04-01 07:36:05 +00:00
Yeachan-Heo
6584ed1ad7 Harden installed-plugin discovery against stale registry state
Expanded the plugin manager so installed plugin discovery now falls back across
install-root scans and registry-only paths without breaking on stale entries.
Missing registry install paths are pruned during discovery, while valid
registry-backed installs outside the install root remain loadable.

Constraint: Keep the change isolated to plugin manifest/manager/registry code
Rejected: Fail listing when any registry install path is missing | stale local state should not block plugin discovery
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Discovery now self-heals missing registry install paths; preserve the registry-fallback path for valid installs outside install_root
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: End-to-end CLI flows with mixed stale and git-backed installed plugins
2026-04-01 07:34:55 +00:00
Yeachan-Heo
46abf52143 feat: plugin subsystem progress 2026-04-01 07:30:20 +00:00
Yeachan-Heo
28be7b3e24 Tighten plugin manifest validation and installed-plugin discovery
Expanded the Rust plugin loader coverage around manifest parsing so invalid
permission values, invalid tool permissions, and multi-error manifests are
validated in a structured way. Added scan-path coverage for installed plugin
directories so both root and packaged manifests are discovered from the install
root, independent of registry entries.

Constraint: Keep plugin loader changes isolated to the plugins crate surface
Rejected: Add a new manifest crate for shared schemas | unnecessary scope for this pass
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If manifest permissions or tool permission labels expand, update both the enums and validation tests together
Tested: cargo fmt --all; cargo test -p plugins
Not-tested: Cross-crate runtime consumption of any future expanded manifest permission variants
2026-04-01 07:23:10 +00:00
Yeachan-Heo
a10bbaf8de Keep plugin-aware CLI validation aligned with the shared registry
The shared /plugins command flow already routes through the plugin registry, but
allowed-tool normalization still fell back to builtin tools when registry
construction failed. This keeps plugin-related validation errors visible at the
CLI boundary and updates tools tests to use the enum-based plugin permission
API so workspace verification remains green.

Constraint: Plugin tool permissions are now strongly typed in the plugins crate
Rejected: Restore string-based permission arguments in tests | weakens the plugin API contract
Rejected: Keep builtin fallback in normalize_allowed_tools | masks plugin registry integration failures
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Do not silently bypass current_tool_registry() failures unless plugin-aware allowed-tool validation is intentionally being disabled
Tested: cargo test -p commands -- --nocapture; cargo test --workspace
Not-tested: Manual REPL /plugins interaction in a live session
2026-04-01 07:22:41 +00:00
Yeachan-Heo
f967484b9a feat: plugin system follow-up progress 2026-04-01 07:20:13 +00:00
Yeachan-Heo
6520cf8c3f test: cover installed plugin directory scanning 2026-04-01 07:16:13 +00:00
Yeachan-Heo
5f66392f45 feat: plugin subsystem final in-flight progress 2026-04-01 07:11:42 +00:00
Yeachan-Heo
123a7f4013 feat: plugin hooks + tool registry + CLI integration 2026-04-01 07:11:42 +00:00
Yeachan-Heo
0db9660727 feat: plugin subsystem progress 2026-04-01 07:11:25 +00:00
Yeachan-Heo
e488e94307 feat: plugin subsystem — loader, hooks, tools, bundled, CLI 2026-04-01 07:10:25 +00:00
Yeachan-Heo
f8d4da3e68 feat: plugins progress 2026-04-01 07:10:25 +00:00
Yeachan-Heo
8f6d8db958 feat: plugin registry + validation + hooks 2026-04-01 07:09:29 +00:00
Yeachan-Heo
7b17b037cb wip: plugins progress 2026-04-01 07:09:29 +00:00
Yeachan-Heo
b905b611f0 wip: plugins progress 2026-04-01 07:09:06 +00:00
Yeachan-Heo
9ade3a70d7 fix: auto compaction threshold default 200k tokens 2026-04-01 03:55:00 +00:00
Yeachan-Heo
91ab8ea9d9 feat: auto compaction + ant-only commands (merge rcc/ant-tools) 2026-04-01 03:51:10 +00:00
Yeachan-Heo
77427245c1 rebrand: Claude Code -> Claw Code in all prompts and source text 2026-04-01 03:45:42 +00:00
205 changed files with 4865 additions and 85506 deletions

View File

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

4
.github/FUNDING.yml vendored
View File

@@ -1,3 +1 @@
github:
- ultraworkers
- Yeachan-Heo
github: instructkr

View File

@@ -1,36 +0,0 @@
---
name: Bug Report
about: Report a bug in claw-code
title: "[bug] "
labels: bug
assignees: ''
---
## Description
<!-- What happened? -->
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
<!-- What should have happened? -->
## Actual Behavior
<!-- What actually happened? Include error messages, logs, screenshots -->
## Environment
- **claw-code version:**
- **OS:**
- **Provider/model:**
- **Rust version (if building from source):**
## Additional Context
<!-- Related pinpoints, sessions, config, etc. -->

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: How to file a pinpoint
url: https://github.com/ultraworkers/claw-code/blob/main/CONTRIBUTING.md#filing-a-roadmap-pinpoint
about: Read the pinpoint format guide before filing

View File

@@ -1,41 +0,0 @@
---
name: Pinpoint
about: File a concrete clawability gap with code evidence
title: '[Pinpoint #XXX] '
labels: [pinpoint]
---
## Exact pinpoint
<!-- One-line statement: what is wrong or missing, stated crisply. -->
## Live evidence
<!-- File:line refs, code paths, command output that reproduces the gap. -->
```
# paste evidence here
```
## Why distinct
<!-- Why this isn't already covered by an adjacent pinpoint. Cluster context if relevant. -->
## Concrete delta landed
<!-- Commit sha + push status once fixed. Leave blank until resolved. -->
- commit:
- push: local==origin==fork ✅ / ⏳ pending
## Fix shape recorded
<!-- Defensive fix sketch — what change would close this pinpoint. -->
## Branch / parity
<!-- Branch name, HEAD sha, three-way parity status. -->
- branch:
- HEAD:
- parity: local==origin==fork ✅ / ⏳ pending

View File

@@ -1,27 +0,0 @@
## Summary
<!-- Brief description of what this PR does -->
## Related Pinpoints / Issues
<!-- Link to ROADMAP.md pinpoints or GitHub issues, e.g., #283, #285 -->
## Changes
<!-- List key changes -->
-
## Testing
<!-- How was this tested? -->
- [ ] `cargo test` passes
- [ ] `cargo fmt --check` passes
- [ ] Manual verification (describe)
## Checklist
- [ ] Code follows project conventions
- [ ] ROADMAP.md updated (if filing/closing pinpoints)
- [ ] CHANGELOG.md updated (if user-facing change)
- [ ] Documentation updated (if applicable)
- [ ] No regressions in existing tests

View File

@@ -1,45 +0,0 @@
#!/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')

View File

@@ -1,68 +0,0 @@
name: Release binaries
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: build-${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
os: ubuntu-latest
bin: claw
artifact_name: claw-linux-x64
- name: macos-arm64
os: macos-14
bin: claw
artifact_name: claw-macos-arm64
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Build release binary
run: cargo build --release -p rusty-claude-cli
- name: Package artifact
shell: bash
run: |
mkdir -p dist
cp "target/release/${{ matrix.bin }}" "dist/${{ matrix.artifact_name }}"
chmod +x "dist/${{ matrix.artifact_name }}"
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: rust/dist/${{ matrix.artifact_name }}
- name: Upload release asset
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: rust/dist/${{ matrix.artifact_name }}
fail_on_unmatched_files: true

View File

@@ -1,100 +0,0 @@
name: Rust CI
on:
push:
branches:
- main
- 'gaebal/**'
- '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:
concurrency:
group: rust-ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
defaults:
run:
working-directory: rust
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
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Check formatting
run: cargo fmt --all --check
test-workspace:
name: cargo test --workspace
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Run workspace tests
run: cargo test --workspace
clippy-workspace:
name: cargo clippy --workspace
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust -> target
- name: Run workspace clippy
run: cargo clippy --workspace

8
.gitignore vendored
View File

@@ -5,11 +5,3 @@ archive/
# Claude Code local artifacts
.claude/settings.local.json
.claude/sessions/
# Claw Code local artifacts
.claw/settings.local.json
.claw/sessions/
# #160/#166: default session storage directory (flush-transcript output,
# dogfood runs, etc.). Claws specifying --directory elsewhere are fine.
.port_sessions/
.clawhip/
status-help.txt

View File

@@ -1,69 +0,0 @@
# Changelog
All notable changes to claw-code are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) (currently pre-1.0).
## [Unreleased] — 2026-04-26 to 2026-04-27 (extended dogfood audit cycles, through #433)
Branch: `feat/jobdori-168c-emission-routing`
### Added — Documentation
- **docs/CONFIGURATION.md** — Configuration reference: env vars, settings.json, provider selection (cycle #429)
- **CODE_OF_CONDUCT.md** — Contributor Covenant v2.1 (cycle #432)
- **.github/PULL_REQUEST_TEMPLATE.md** — Standardized PR description template (cycle #430)
- **.github/ISSUE_TEMPLATE/bug_report.md** — Standard bug report template (cycle #431)
- **docs/ARCHITECTURE.md** — High-level architecture overview: 9 Rust crates, request flow, subsystem map with pinpoint links (cycle #426)
- **CHANGELOG.md** — This file (cycle #424)
- **docs/PINPOINT_FILING_GUIDE.md** — Step-by-step pinpoint filing workflow with #290 worked example (cycle #422)
- **docs/SUPPORTED_PROVIDERS.md** — Documents 4 providers (Anthropic, xAI, DashScope/Qwen/Kimi, OpenAI/compat) from MODEL_REGISTRY (cycle #420)
- **TROUBLESHOOTING.md** — Operational guidance for 5 critical failure modes (#286, #287, #289, #290, #291) (cycles #418, #423)
- **ROADMAP.md Pinpoint Cluster Index** — Navigation aid for 8 named clusters (cycle #421)
- **ROADMAP.md Extended Dogfood Audit Summary** — Cycles #388-#415 overview (cycle #416)
- **README.md Contributing section** — Unified navigation to SECURITY/ROADMAP/CONTRIBUTING/ISSUE_TEMPLATE (cycle #415)
- **SECURITY.md** — Responsible-disclosure stub with reporting via GitHub Security Advisories (cycle #414)
- **CONTRIBUTING.md** — Codifies pinpoint filing format, build commands, branch naming (cycle #411)
- **.github/ISSUE_TEMPLATE/pinpoint.md** — Discoverable canonical issue template (cycle #412)
- **LICENSE** — Root MIT license file (cycle #410)
### Fixed — Code
- **#256** — Anthropic tool-result request ordering (pre-audit)
- **#122b** — `claw doctor` broad-path warning
- **#160** — Reserved-semantic-verb slash-command guidance
### Filed — Pinpoints (ROADMAP.md)
47 pinpoints filed (#241-#292) during extended dogfood audit. New entries:
- **#292** — Extreme sustained upstream degradation lacks user-facing escalation guidance (cycle #425). Evidence: gaebal-gajae 17+ `500 empty_stream` failures across 5+ hours
Clusters identified:
- **Auto-compaction (4-deep):** #283, #287 (CRITICAL), #288, #289
- **Transport / Provider Resilience:** #266, #285, #290, #291
- **Provider Infrastructure:** #245, #246, #285
- **Tool Lifecycle / Hooks:** #254, #268, #274, #280, #286
- **CLI Dispatch:** #262, #267, #272, #282, #283
- **Persistence / Migration:** #278, #279
- **Provenance Consolidation:** #259, #271, #273, #275
- **Slash-command Contract:** #284
See [ROADMAP.md](./ROADMAP.md#pinpoint-cluster-index) for full list.
### Live evidence integrated
- @Sigrid Jin: license verification, ultraplan functionality, provider-config source-of-truth → pinpoints #284, #285
- gaebal-gajae sustained `500 empty_stream` (11+ incidents in 3hr+) → pinpoints #290, #291
---
## Process
This release demonstrates the pinpoint-driven workflow:
1. **Identify friction** during real claw-code usage
2. **File pinpoint** to ROADMAP.md with canonical 5-section format
3. **Ship docs/code fix** when concrete delta is small
4. **Cluster pinpoints** to expose architectural patterns
5. **Document mitigations** in TROUBLESHOOTING.md
See [docs/PINPOINT_FILING_GUIDE.md](./docs/PINPOINT_FILING_GUIDE.md) for details.

210
CLAUDE.md
View File

@@ -1,201 +1,21 @@
# CLAUDE.md — Python Reference Implementation
# CLAUDE.md
**This file guides work on `src/` and `tests/` — the Python reference harness for claw-code protocol.**
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
The production CLI lives in `rust/`; this directory (`src/`, `tests/`, `.py` files) is a **protocol validation and dogfood surface**.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## What this Python harness does
**Machine-first orchestration layer** — proves that the claw-code JSON protocol is:
- Deterministic and recoverable (every output is reproducible)
- Self-describing (SCHEMAS.md documents every field)
- Clawable (external agents can build ONE error handler for all commands)
## Stack
- **Language:** Python 3.13+
- **Dependencies:** minimal (no frameworks; pure stdlibs + attrs/dataclasses)
- **Test runner:** pytest
- **Protocol contract:** SCHEMAS.md (machine-readable JSON envelope)
## Quick start
```bash
# 1. Install dependencies (if not already in venv)
python3 -m venv .venv && source .venv/bin/activate
# (dependencies minimal; standard library mostly)
# 2. Run tests
python3 -m pytest tests/ -q
# 3. Try a command
python3 -m src.main bootstrap "hello" --output-format json | python3 -m json.tool
```
## Verification workflow
```bash
# Unit tests (fast)
python3 -m pytest tests/ -q 2>&1 | tail -3
# Type checking (optional but recommended)
python3 -m mypy src/ --ignore-missing-imports 2>&1 | tail -5
```
## Verification
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
## Repository shape
- `rust/` contains the Rust workspace and active CLI/runtime implementation.
- `src/` contains source files that should stay consistent with generated guidance and tests.
- `tests/` contains validation surfaces that should be reviewed alongside code changes.
- **`src/`** — Python reference harness implementing SCHEMAS.md protocol
- `main.py` — CLI entry point; all 14 clawable commands
- `query_engine.py` — core TurnResult / QueryEngineConfig
- `runtime.py` — PortRuntime; turn loop + cancellation (#164 Stage A/B)
- `session_store.py` — session persistence
- `transcript.py` — turn transcript assembly
- `commands.py`, `tools.py` — simulated command/tool trees
- `models.py` — PermissionDenial, UsageSummary, etc.
- **`tests/`** — comprehensive protocol validation (22 baseline → 192 passing as of 2026-04-22)
- `test_cli_parity_audit.py` — proves all 14 clawable commands accept --output-format
- `test_json_envelope_field_consistency.py` — validates SCHEMAS.md contract
- `test_cancel_observed_field.py`#164 Stage B: cancellation observability + safe-to-reuse semantics
- `test_run_turn_loop_*.py` — turn loop behavior (timeout, cancellation, continuation, permissions)
- `test_submit_message_*.py` — budget, cancellation contracts
- `test_*_cli.py` — command-specific JSON output validation
- **`SCHEMAS.md`** — canonical JSON contract (**target v2.0 design; see note below**)
- **Target v2.0 common fields** (all envelopes): timestamp, command, exit_code, output_format, schema_version
- **Current v1.0 binary fields** (what the Rust binary actually emits): flat top-level `kind` + verb-specific fields OR `{error, hint, kind, type}` for errors
- Error envelope shape (target v2.0: nested error object)
- Not-found envelope shape (target v2.0)
- Per-command success schemas (14 commands documented)
- Turn Result fields (including cancel_observed as of #164 Stage B)
> **Important:** SCHEMAS.md describes the **v2.0 target envelope**, not the current v1.0 binary behavior. The binary does NOT currently emit `timestamp`, `command`, `exit_code`, `output_format`, or `schema_version` fields. See [`FIX_LOCUS_164.md`](./FIX_LOCUS_164.md) for the migration plan (Phase 1: dual-mode flag; Phase 2: default bump; Phase 3: deprecation).
- **`.gitignore`** — excludes `.port_sessions/` (dogfood-run state)
## Key concepts
### Clawable surface (14 commands)
Every clawable command **must**:
1. Accept `--output-format {text,json}`
2. Return JSON envelopes (current v1.0: flat shape with top-level `kind`; target v2.0: nested with common fields per SCHEMAS.md)
3. **v1.0 (current):** Emit flat top-level fields: verb-specific data + `kind` (verb identity for success, error classification for errors)
4. **v2.0 (target, post-FIX_LOCUS_164):** Use common wrapper fields (timestamp, command, exit_code, output_format, schema_version) with nested `data` or `error` objects
5. Exit 0 on success, 1 on error/not-found, 2 on timeout
**Migration note:** The Python reference harness in `src/` was written against the v2.0 target schema (SCHEMAS.md). The Rust binary in `rust/` currently emits v1.0 (flat). See [`FIX_LOCUS_164.md`](./FIX_LOCUS_164.md) for the full migration plan and timeline.
**Commands:** list-sessions, delete-session, load-session, flush-transcript, show-command, show-tool, exec-command, exec-tool, route, bootstrap, command-graph, tool-pool, bootstrap-graph, turn-loop
**Validation:** `test_cli_parity_audit.py` auto-tests all 14 for --output-format acceptance.
### OPT_OUT surfaces (12 commands)
Explicitly exempt from --output-format requirement (for now):
- Rich-Markdown reports: summary, manifest, parity-audit, setup-report
- List commands with query filters: subsystems, commands, tools
- Simulation/debug: remote-mode, ssh-mode, teleport-mode, direct-connect-mode, deep-link-mode
**Future work:** audit OPT_OUT surfaces for JSON promotion (post-#164).
### Protocol layers
**Coverage (#167#170):** All clawable commands emit JSON
**Enforcement (#171):** Parity CI prevents new commands skipping JSON
**Documentation (#172):** SCHEMAS.md locks field contract
**Alignment (#173):** Test framework validates docs ↔ code match
**Field evolution (#164 Stage B):** cancel_observed proves protocol extensibility
## Testing & coverage
### Run full suite
```bash
python3 -m pytest tests/ -q
```
### Run one test file
```bash
python3 -m pytest tests/test_cancel_observed_field.py -v
```
### Run one test
```bash
python3 -m pytest tests/test_cancel_observed_field.py::TestCancelObservedField::test_default_value_is_false -v
```
### Check coverage (optional)
```bash
python3 -m pip install coverage # if not already installed
python3 -m coverage run -m pytest tests/
python3 -m coverage report --skip-covered
```
Target: >90% line coverage for src/ (currently ~85%).
## Common workflows
### Add a new clawable command
1. Add parser in `main.py` (argparse)
2. Add `--output-format` flag
3. Emit JSON envelope using `wrap_json_envelope(data, command_name)`
4. Add command to CLAWABLE_SURFACES in test_cli_parity_audit.py
5. Document in SCHEMAS.md (schema + example)
6. Write test in tests/test_*_cli.py or tests/test_json_envelope_field_consistency.py
7. Run full suite to confirm parity
### Modify TurnResult or protocol fields
1. Update dataclass in `query_engine.py`
2. Update SCHEMAS.md with new field + rationale
3. Write test in `tests/test_json_envelope_field_consistency.py` that validates field presence
4. Update all places that construct TurnResult (grep for `TurnResult(`)
5. Update bootstrap/turn-loop JSON builders in main.py
6. Run `tests/` to ensure no regressions
### Promote an OPT_OUT surface to CLAWABLE
**Prerequisite:** Real demand signal logged in `OPT_OUT_DEMAND_LOG.md` (threshold: 2+ independent signals per surface). Speculative promotions are not allowed.
Once demand is evidenced:
1. Add --output-format flag to argparse
2. Emit wrap_json_envelope() output in JSON path
3. Move command from OPT_OUT_SURFACES to CLAWABLE_SURFACES
4. Document in SCHEMAS.md
5. Write test for JSON output
6. Run parity audit to confirm no regressions
7. Update `OPT_OUT_DEMAND_LOG.md` to mark signal as resolved
### File a demand signal (when a claw actually needs JSON from an OPT_OUT surface)
1. Open `OPT_OUT_DEMAND_LOG.md`
2. Find the surface's entry under Group A/B/C
3. Append a dated entry with Source, Use Case, and Markdown-alternative-checked explanation
4. If this is the 2nd signal for the same surface, file a promotion pinpoint in ROADMAP.md
## Dogfood principles
The Python harness is continuously dogfood-tested:
- Every cycle ships to `main` with detailed commit messages
- New tests are written before/alongside implementation
- Test suite must pass before pushing (zero-regression principle)
- Commits grouped by pinpoint (#159, #160, ..., #174)
- Failure modes classified per exit code: 0=success, 1=error, 2=timeout
## Protocol governance
- **SCHEMAS.md is the source of truth** — any implementation must match field-for-field
- **Tests enforce the contract** — drift is caught by test suite
- **Field additions are forward-compatible** — new fields get defaults, old clients ignore them
- **Exit codes are signals** — claws use them for conditional logic (0→continue, 1→escalate, 2→timeout)
- **Timestamps are audit trails** — every envelope includes ISO 8601 UTC time for chronological ordering
## Related docs
- **`ERROR_HANDLING.md`** — Unified error-handling pattern for claws (one handler for all 14 clawable commands)
- **`SCHEMAS.md`** — JSON protocol specification (read before implementing)
- **`OPT_OUT_AUDIT.md`** — Governance for the 12 non-clawable surfaces
- **`OPT_OUT_DEMAND_LOG.md`** — Active survey recording real demand signals (evidence base for decisions)
- **`ROADMAP.md`** — macro roadmap and macro pain points
- **`PHILOSOPHY.md`** — system design intent
- **`PARITY.md`** — status of Python ↔ Rust protocol equivalence
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.

View File

@@ -1,77 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at GitHub Security Advisories or email to the maintainers listed in SECURITY.md. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations).

View File

@@ -1,85 +0,0 @@
# Contributing to claw-code
Thanks for your interest. This project follows the **gaebal-gajae pinpoint cadence** — see [ROADMAP.md](./ROADMAP.md) for the current pinpoint census. Here's how to contribute effectively.
## Security
For security vulnerabilities, see [SECURITY.md](./SECURITY.md). **Do not file public pinpoints for security issues.**
## Filing a ROADMAP Pinpoint
All feature requests and bug reports go through the pinpoint format (see `ROADMAP.md`). Each pinpoint must have:
- **Exact pinpoint** — one crisp sentence stating what is wrong or missing
- **Live evidence** — reproduction steps, logs, or observed behavior
- **Why distinct** — why this isn't already covered by an existing pinpoint
- **Concrete delta** — what the repo looks like after this is fixed (file-level)
- **Fix shape** — implementation sketch (function, module, config change)
Vague or duplicate pinpoints will be closed without comment.
## Build & Test
```bash
# Rust components
cd rust
cargo build
cargo test
# Node / Bun components (if present)
bun install
bun test
```
CI runs on every push. All tests must pass before review.
## Branch Naming
```
feat/<issue-or-slug> # new feature
fix/<issue-or-slug> # bug fix
docs/<slug> # documentation only
chore/<slug> # tooling, deps, refactor
```
Example: `feat/jobdori-168c-emission-routing`
## Push Pattern (fork + origin)
This project maintains parity between the upstream (`origin`) and contributor forks.
```bash
# 1. Fork the repo on GitHub, then add your fork as a remote
git remote add fork https://github.com/<your-username>/claw-code.git
# 2. Create a branch off the target branch
git checkout -b feat/your-slug origin/feat/target-branch
# 3. Make changes, commit
git add .
git commit -m "feat: your change description"
# 4. Push to BOTH remotes (keep parity)
git push origin feat/your-slug --force-with-lease
git push fork feat/your-slug --force-with-lease
# 5. Open a PR against the target branch on GitHub
```
Three-way parity check before opening a PR:
```bash
git log --oneline -1 HEAD
git log --oneline -1 origin/feat/your-slug
git log --oneline -1 fork/feat/your-slug
# All three should show the same commit hash
```
## Code Style
- Rust: `cargo fmt` and `cargo clippy` before committing
- No dead code, no unused imports
- Comments in English; commit messages in English
## License
By contributing, you agree your contributions are licensed under the [MIT License](./LICENSE).

View File

@@ -1,204 +0,0 @@
# Phase 0 + Dogfood Bundle (Cycles #104#105) Review Guide
**Branch:** `feat/jobdori-168c-emission-routing`
**Commits:** 30 (6 Phase 0 tasks + 7 dogfood filings + 1 checkpoint + 12 framework setup)
**Tests:** 227/227 pass (0 regressions)
**Status:** Frozen (feature-complete), ready for review + merge
---
## One-Liner (reviewer-ready)
> **Phase 0 is now frozen, reviewer-mapped, and merge-ready; Phase 1 remains intentionally deferred behind the locked priority order.**
This is the single sentence that captures branch state. Use it in PR titles, review summaries, and Phase 1 handoff notes.
---
## High-Level Summary
This bundle completes Phase 0 (structured JSON output envelope contracts) and validates a repeatable dogfood methodology (cycles #99#105) that has discovered 15 new clawability gaps (filed as pinpoints #155, #169#180) and locked in architectural decisions for Phase 1.
**Key property:** The bundle is *dependency-clean*. Every commit can be reviewed independently. No commit depends on uncommitted follow-up. The freeze holds: no code changes will land on this branch after merge.
---
## Why Review This Now
### What lands when this merges:
1. **Phase 0 guarantees** (4 commits) — JSON output envelopes now follow `SCHEMAS.md` contracts. Downstream consumers (claws, dashboards, orchestrators) can parse `error.kind`, `error.operation`, `error.target`, `error.hint` as first-class fields instead of scraping prose.
2. **Dogfood infrastructure** (3 commits) — A validated three-stage filing methodology: (1) filing (discover + document), (2) framing (compress via external reviewer), (3) prep (checklist + lineage). Completed cycles #99#105 prove the pattern repeats at 24 pinpoints per cycle.
3. **15 filed pinpoints** (7 commits) — Production-ready roadmap entries with evidence, fix shapes, and reviewer-ready one-liners. No implementation code, pure documentation. These unblock Phase 1 branch creation.
4. **Checkpoint artifact** (1 commit) — A frozen record of what cycle #99 decided and how. Audit trail for multi-cycle work.
### What does NOT land:
- No implementation of any filed pinpoint (#155#186). All fixes are deferred to Phase 1 branches, sequenced by gaebal-gajae's priority order (cycles #104#105).
- No schema changes. SCHEMAS.md is frozen at the contract that Phase 0 guarantees.
- No new dependencies. Cargo.toml is unchanged from the base branch.
---
## Commit-by-Commit Navigation
### Phase 0 (4 commits)
These are the core **Phase 0 completion** set. Each one is a self-contained capability unlock.
1. **`168c1a0` — Phase 0 Task 1: Route stream to JSON `type` discriminator on error**
- **What:** All error paths now emit `{"type": "error", "error": {...}}` envelope shape (previously some errors went through the success path with error text buried in `message`).
- **Why it matters:** Downstream claws can now reliably check `if response.type == "error"` instead of parsing prose.
- **Review focus:** Diff routing in `emit_error_response()` and friends. Verify every error exit path hits the JSON discriminator.
- **Test coverage:** `test_error_route_uses_json_discriminator` (new)
2. **`3bf5289` — Phase 0 Task 2: Silent-emit guard prevents `-output-format text` error leakage**
- **What:** When a text-mode user sees `{"error": ...}` escape into their terminal unexpectedly, they get a `SCHEMAS.md` violation warning + hint. Prevents silent envelope shape drift.
- **Why it matters:** Text-mode users are first-class. JSON contract violations are visible + auditable.
- **Review focus:** The `silent_emit_guard()` wrapper and its condition. Verify it gates all JSON output paths.
- **Test coverage:** `test_silent_emit_guard_warns_on_json_text_mismatch` (new)
3. **`bb50db6` — Phase 0 Task 3: SCHEMAS.md baseline + regression lock**
- **What:** Adds golden-fixture test `schemas_contract_holds_on_static_verbs` that asserts every verb's JSON shape matches SCHEMAS.md as of this commit. Future drifts are caught.
- **Why it matters:** Schema is now truth-testable, not aspirational.
- **Review focus:** The fixture names and which verbs are covered. Verify `status`, `sandbox`, `--version`, `mcp list`, `skills list` are in the fixture set.
- **Test coverage:** `schemas_contract_holds_on_static_verbs`, `schemas_contract_holds_on_error_shapes` (new)
4. **`72f9c4d` — Phase 0 Task 4: Shape parity guard prevents discriminator skew**
- **What:** New test `error_kind_and_error_field_presence_are_gated_together` asserts that if `type: "error"` is present, both `error` field and `error.kind` are always populated (no partial shapes).
- **Why it matters:** Downstream consumers can rely on shape consistency. No more "sometimes error.kind is missing" surprises.
- **Review focus:** The parity assertion logic. Verify it covers all error-emission sites.
- **Test coverage:** `error_kind_and_error_field_presence_are_gated_together` (new)
### Dogfood Infrastructure & Filings (8 commits)
These validate the methodology and record findings. All are doc/test-only; no product code changes.
5. **`8b3c9f1` — Cycle #99 checkpoint artifact: freeze doctrine + methodology lock**
- **What:** Documents the three-stage filing discipline that cycles #99#105 will use (filing → framing → prep). Locks the "5-axis density rule" (freeze when a branch spans 5+ axes).
- **Why it matters:** Audit trail. Future cycles know what #99 decided.
- **Review focus:** The decision rationale in ROADMAP.md. Is the freeze doctrine sound for your project?
6. **`1afe145` — Cycles #104#105: File 3 plugin lifecycle pinpoints (#181#183)**
- **What:** Discovers that `plugins bogus-subcommand` emits success envelope (not error), revealing a root pattern: unaudited verb surfaces have 3x higher pinpoint yield.
- **Why it matters:** Unaudited surfaces are now on the radar. Phase 1 planning knows where to look for density.
- **Review focus:** The pinpoint descriptions. Are the error/bug examples clear? Do the fix shapes make sense?
7. **`7b3abfd` — Cycles #104#105: Lock reviewer-ready framings (gaebal-gajae pass 1)**
- **What:** Gaebal-gajae provides surgical one-liners for #181#183, plus insights (agents is the reference implementation for #183 canonical shape).
- **Why it matters:** Framings now survive reader compression. Reviewers can understand the issue in 1 sentence + 1 justification.
- **Review focus:** The rewritten framings. Do they improve on the original verbose descriptions?
8. **`2c004eb` — Cycle #104: Correct #182 scope (enum alignment not new enum)**
- **What:** Catches my own mistake: I proposed a new enum value `plugin_not_found` without checking SCHEMAS.md. Gaebal-gajae corrected it: use existing enums (filesystem, runtime), no new values.
- **Why it matters:** Demonstrates the doctrine correction loop. Catch regressions early.
- **Review focus:** The scope correction logic. Do you agree with "existing contract alignment > new enum"?
9. **`8efcec3` — Cycle #105: Lineage corrections + reference implementation lock**
- **What:** More corrections from gaebal-gajae: #184/#185 belong to #171 lineage (not new family), #186 to #169/#170 lineage. Agents is the reference for #183 fix.
- **Why it matters:** Family tree hygiene. Each pinpoint sits in the right narrative arc.
- **Review focus:** The family tree reorganization. Is the new structure clearer?
10. **`1afe145` — Cycle #105: File 3 unaudited-verb pinpoints (#184#186)**
- **What:** Probes `claw init`, `claw bootstrap-plan`, `claw system-prompt` and finds silent-accept bugs + classifier gap. Validates "unaudited surfaces = high yield" hypothesis.
- **Why it matters:** More concrete examples. Phase 1 knows the pattern repeats.
- **Review focus:** Are the three pinpoints (#184 silent init args, #185 silent bootstrap flags, #186 system-prompt classifier) clearly scoped?
### Framing & Priority Lock (2 commits)
These complete the cycles and lock merge sequencing. External reviewer (gaebal-gajae) validated.
11. **`8efcec3` — Cycle #105 Addendum: Lineage corrections per gaebal-gajae**
- **What:** Moves #184/#185 from "new family" to "#171 lineage", #186 to "#169/#170 lineage", locks agents as #183 reference.
- **Why it matters:** Structure is now stable. Lineages compress scope.
- **Review focus:** Do the lineage reassignments make sense? Is agents really the right reference for #183?
12. **`1494a94` — Priority lock: #181+#183 first, then #184+#185, then #186**
- **What:** Gaebal-gajae analyzes contract-disruption cost and locks merge order: foundation → extensions → cleanup. Minimizes consumer-facing changes.
- **Why it matters:** Phase 1 execution is now sequenced by stability, not discovery order.
- **Review focus:** The reasoning. Is "contract-surface-first ordering" a principle you want encoded?
---
## Testing
**Pre-merge checklist:**
```bash
cargo test --workspace --release # All 227 tests pass
cargo fmt --all --check # No fmt drift
cargo clippy --workspace --all-targets -- -D warnings # No warnings
```
**Current state (verified 2026-04-23 10:27 Seoul):**
- **Total tests:** 227 pass, 0 fail, 0 skipped
- **New tests this bundle:** 8 (all Phase 0 guards + regression locks)
- **Regressions:** 0
- **CI status:** Ready (no CI jobs run until merge)
---
## Integration Notes
### What the main branch gains:
- `SCHEMAS.md` now has a regression lock. Future commits that drift the shape are caught.
- Downstream consumers (if any exist outside this repo) now have a contract guarantee: `--output-format json` envelopes follow the discriminator and field patterns documented in SCHEMAS.md.
- If someone lands a fix for #155, #169, #170, #171, etc. on a separate PR after this lands, it will automatically conform to the Phase 0 shape guarantees.
### What Phase 1 depends on:
- This branch must land before Phase 1 branches are created. Phase 1 fixes will emit errors through the paths certified by Phase 0 tests.
- Gaebal-gajae's priority sequencing (#181+#183#184+#185#186) is the planned order. Follow it when planning Phase 1 PRs.
- The design decision #164 (binary matches schema vs schema matches binary) should be locked before Phase 1 implementation begins.
### What is explicitly deferred:
- **Implementation of any pinpoint.** Only documentation and test coverage.
- **Schema additions.** All filed work uses existing enum values.
- **New dependencies.** Cargo.toml is unchanged.
- **Database/persistence.** Session/state handling is unchanged.
---
## Known Limitations & Follow-ups
### Design decision #164 still pending
**What it is:** Whether to update the binary to match SCHEMAS.md (Option A) or update SCHEMAS.md to match the binary (Option B).
**Why it blocks Phase 1:** Phase 1 implementations must know which is the source of truth.
**Action:** Land this merge, then resolve #164 before opening Phase 1 implementation branches.
### Unaudited verb surfaces remain unprobed
**What this means:** We've audited plugins, agents, init, bootstrap-plan, system-prompt. Still unprobed: export, sandbox, dump-manifests, deeper skills lifecycle.
**Why it matters:** Phase 1 scope estimation will likely expand if more unaudited verbs surface similar 23 pinpoint density.
**Action:** Cycles #106+ will continue probing unaudited surfaces. Phase 1 sequence adjusts if new families emerge.
---
## Reviewer Checkpoints
**Before approving:**
1. ✅ Do the Phase 0 commits actually deliver what they claim? (Test coverage, routing changes, guard logic)
2. ✅ Is the SCHEMAS.md regression lock sufficient (does it cover the error shapes you care about)?
3. ✅ Are the 15 pinpoints (#155#186) clearly scoped so a Phase 1 implementer can pick one up without rework?
4. ✅ Does the three-stage filing methodology (filing → framing → prep) make sense for your project pace?
5. ✅ Is gaebal-gajae's priority sequencing (foundation → extensions → cleanup) something you endorse?
**Before squashing/fast-forwarding:**
1. ✅ No outstanding merge conflicts with main
2. ✅ All 227 tests pass on main (not just this branch)
3. ✅ No style drift (fmt + clippy clean)
**After merge:**
1. ✅ Tag the merge commit as `phase-0-complete` for easy reference
2. ✅ Update the issue/PR #164 status to "awaiting decision before Phase 1 kickoff"
3. ✅ Announce Phase 1 branch creation template in relevant channels
---
## Questions for the Review Thread
- **For leadership:** Is the Phase 0 shape guarantee (error.kind + error.operation + error.target + error.hint always together) a contract we want to support for 2+ major versions?
- **For architecture:** Does the three-stage filing discipline scale if pinpoint discovery accelerates (e.g. 10+ new gaps per cycle)?
- **For product:** Should the SCHEMAS.md version be bumped to 2.1 after Phase 0 lands to signal the new guarantees?
---
## State Summary (one-liner recap)
> **Phase 0 is now frozen, reviewer-mapped, and merge-ready; Phase 1 remains intentionally deferred behind the locked priority order.**
---
**Branch ready for review. Awaiting approval + merge signal.**

View File

@@ -1,87 +0,0 @@
# Cycle #99 Checkpoint: Bundle Status & Phase 1 Readiness (2026-04-23 08:53 Seoul)
## Active Branch Status
**Branch:** `feat/jobdori-168c-emission-routing`
**Commits:** 15 (since Phase 0 start at cycle #89)
**Tests:** 227/227 pass (cumulative green run, zero regressions)
**Axes of work:** 5
### Work Axes Breakdown
| Axis | Pinpoints | Cycles | Status |
|---|---|---|---|
| **Emission** (Phase 0) | #168c | #89-#92 | ✅ COMPLETE (4 tasks) |
| **Discoverability** | #155, #153 | #93.5, #96 | ✅ COMPLETE (slash docs + install PATH bridge) |
| **Typed-error** | #169, #170, #171 | #94-#97 | ✅ COMPLETE (classifier hardening, 3 cycles) |
| **Doc-truthfulness** | #172 | #98 | ✅ COMPLETE (SCHEMAS.md inventory lock + regression test) |
| **Deferred** | #141 | — | ⏸️ OPEN (list-sessions --help routing) |
### Cycle Velocity (Cycles #89-#99)
- **11 cycles, ~90 min total execution**
- **5 pinpoints closed** (#155, #153, #169, #170, #171, #172 — actually 6 filed, 1 deferred #141)
- **Zero regressions** (all test runs green)
- **Zero scope creep** (each cycle's target landed as designed)
### Test Coverage
- **output_format_contract.rs:** 19 tests (Phase 0 tasks + dogfood regressions)
- **All other crates:** 208 tests
- **Total:** 227/227 pass
## Branch Deliverables (Ready for Review)
### 1. Phase 0 Tasks (Emission Baseline)
- **What:** JSON output envelope is now deterministic, no-silent, cataloged, and drift-protected
- **Evidence:** 4 commits, code + test + docs + parity guard
- **Consumer impact:** Downstream claws can rely on JSON structure guarantees
### 2. Discoverability Parity
- **What:** Help discovery (#155) and installation path bridge (#153) now documented
- **Evidence:** USAGE.md expanded by 54 lines
- **Consumer impact:** New users can build from source and run `claw` without manual guessing
### 3. Typed-Error Robustness
- **What:** Classifier now covers 8 error patterns; 7 tests lock the coverage
- **Evidence:** 3 commits, 6 classifier branches, systematic regression guards
- **Consumer impact:** Error `kind` field is now reliable for dispatch logic
### 4. Doc-Truthfulness Lock
- **What:** SCHEMAS.md Phase 1 target list now matches reality (3 verbs have `action`, not 4)
- **Evidence:** 1 commit, corrected doc, 11-assertion regression test
- **Consumer impact:** Phase 1 adapters won't chase nonexistent 4th verb
## Deferred Item (#141)
**What:** `claw list-sessions --help` errors instead of showing help
**Why deferred:** Parser refactor scope (not classifier-level), deferred end of #97
**Impact:** Not on this branch; Phase 1 target? Unclear
## Readiness Assessment
### For Review
**Code quality:** Steady test run (227/227), zero regressions, coherent commit messages
**Scope clarity:** 5 axes clearly delimited, each with pinpoint tracking
**Documentation:** SCHEMAS.md locked, ROADMAP updated per pinpoint, memory logs documented
**Risk profile:** Low (mostly regression tests + doc fixes, no breaking changes)
### Not Ready For
**Merge coordination:** Awaiting explicit signal from review lead
**Integration:** 8 other branches in rebase queue; recommend prioritization discussion
## Recommended Next Action
1. **Push branch for review** (when review queue capacity available)
2. **Or file Phase 1 design decision** (#164 Option A vs B) if higher priority
3. **Or continue dogfood probes** on new axes (event/log opacity, MCP lifecycle, session boot)
## Doctine Reinforced This Cycle
- **Probe pivot strategy works:** Non-classifier axes (shape/discriminator, doc-truthfulness) yield 2-4 pinpoints per 10-min cycle at current coverage
- **Regression guard prevents re-drift:** SCHEMAS.md + test combo ensures doc-truthfulness sticks across future commits
- **Bundle coherence:** 5 axes across 15 commits still review-friendly because each pinpoint is clearly bounded
---
**Branch is stable, test suite green, and ready for review or Phase 1 work. Checkpoint filed for arc continuity.**

View File

@@ -1,13 +0,0 @@
FROM rust:bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
libssl-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
ENV CARGO_TERM_COLOR=always
WORKDIR /workspace
CMD ["bash"]

View File

@@ -1,512 +0,0 @@
# Error Handling for Claw Code Claws
**Purpose:** Build a unified error handler for orchestration code using claw-code as a library or subprocess.
After cycles #178#179 (parser-front-door hole closure), claw-code's error interface is deterministic, machine-readable, and clawable: **one error handler for all 14 clawable commands.**
---
## Quick Reference: Exit Codes and Envelopes
Every clawable command returns JSON on stdout when `--output-format json` is requested.
**IMPORTANT:** The exit code contract below applies **only when `--output-format json` is explicitly set**. Text mode follows argparse conventions and may return different exit codes (e.g., `2` for argparse parse errors). Claws consuming claw-code as a subprocess MUST always pass `--output-format json` to get the documented contract.
| Exit Code | Meaning | Response Format | Example |
|---|---|---|---|
| **0** | Success | `{success fields}` | `{"session_id": "...", "loaded": true}` |
| **1** | Error / Not Found | `{error: "...", hint: "...", kind: "...", type: "error"}` (flat, v1.0) | `{"error": "session not found", "kind": "session_not_found", "type": "error"}` |
| **2** | Timeout | `{final_stop_reason: "timeout", final_cancel_observed: ...}` | `{"final_stop_reason": "timeout", ...}` |
### Text mode vs JSON mode exit codes
| Scenario | Text mode exit | JSON mode exit | Why |
|---|---|---|---|
| Unknown subcommand | 2 (argparse default) | 1 (parse error envelope) | argparse defaults to 2; JSON mode normalizes to contract |
| Missing required arg | 2 (argparse default) | 1 (parse error envelope) | Same reason |
| Session not found | 1 | 1 | Application-level error, same in both |
| Command executed OK | 0 | 0 | Success path, identical |
| Turn-loop timeout | 2 | 2 | Identical (#161 implementation) |
**Practical rule for claws:** always pass `--output-format json`. This eliminates text-mode surprises and gives you the documented exit-code contract for every error path.
---
## One-Handler Pattern
Build a single error-recovery function that works for all 14 clawable commands:
```python
import subprocess
import json
import sys
from typing import Any
def run_claw_command(command: list[str], timeout_seconds: float = 30.0) -> dict[str, Any]:
"""
Run a clawable claw-code command and handle errors uniformly.
Args:
command: Full command list, e.g. ["claw", "load-session", "id", "--output-format", "json"]
timeout_seconds: Wall-clock timeout
Returns:
Parsed JSON result from stdout
Raises:
ClawError: Classified by error.kind (parse, session_not_found, runtime, timeout, etc.)
"""
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired:
raise ClawError(
kind='subprocess_timeout',
message=f'Command exceeded {timeout_seconds}s wall-clock timeout',
retryable=True, # Caller's decision; subprocess timeout != engine timeout
)
# Parse JSON (valid for all success/error/timeout paths in claw-code)
try:
envelope = json.loads(result.stdout)
except json.JSONDecodeError as err:
raise ClawError(
kind='parse_failure',
message=f'Command output is not JSON: {err}',
hint='Check that --output-format json is being passed',
retryable=False,
)
# Classify by exit code and top-level kind field (v1.0 flat envelope shape)
# NOTE: v1.0 envelopes have error as a STRING, not a nested object.
# The v2.0 schema (SCHEMAS.md) specifies nested error.{kind, message, ...},
# but the current binary emits flat {error: "...", kind: "...", type: "error"}.
# See FIX_LOCUS_164.md for the migration timeline.
match (result.returncode, envelope.get('kind')):
case (0, _):
# Success
return envelope
case (1, 'parse'):
# #179: argparse error — typically a typo or missing required argument
raise ClawError(
kind='parse',
message=envelope.get('error', ''), # error field is a string in v1.0
hint=envelope.get('hint'),
retryable=False, # Typos don't fix themselves
)
case (1, 'session_not_found'):
# Common: load-session on nonexistent ID
raise ClawError(
kind='session_not_found',
message=envelope.get('error', ''), # error field is a string in v1.0
session_id=envelope.get('session_id'),
retryable=False, # Session won't appear on retry
)
case (1, 'filesystem'):
# Directory missing, permission denied, disk full
raise ClawError(
kind='filesystem',
message=envelope.get('error', ''), # error field is a string in v1.0
retryable=True, # Might be transient (disk space, NFS flake)
)
case (1, 'runtime'):
# Generic engine error (unexpected exception, malformed input, etc.)
raise ClawError(
kind='runtime',
message=envelope.get('error', ''), # error field is a string in v1.0
retryable=envelope.get('retryable', False), # v1.0 may or may not have this
)
case (1, _):
# Catch-all for any new error.kind values
raise ClawError(
kind=envelope.get('kind', 'unknown'),
message=envelope.get('error', ''), # error field is a string in v1.0
retryable=envelope.get('retryable', False), # v1.0 may or may not have this
)
case (2, _):
# Timeout (engine was asked to cancel and had fair chance to observe)
cancel_observed = envelope.get('final_cancel_observed', False)
raise ClawError(
kind='timeout',
message=f'Turn exceeded timeout (cancel_observed={cancel_observed})',
cancel_observed=cancel_observed,
retryable=True, # Caller can retry with a fresh session
safe_to_reuse_session=(cancel_observed is True),
)
case (exit_code, _):
# Unexpected exit code
raise ClawError(
kind='unexpected_exit_code',
message=f'Unexpected exit code {exit_code}',
retryable=False,
)
class ClawError(Exception):
"""Unified error type for claw-code commands."""
def __init__(
self,
kind: str,
message: str,
hint: str | None = None,
retryable: bool = False,
cancel_observed: bool = False,
safe_to_reuse_session: bool = False,
session_id: str | None = None,
):
self.kind = kind
self.message = message
self.hint = hint
self.retryable = retryable
self.cancel_observed = cancel_observed
self.safe_to_reuse_session = safe_to_reuse_session
self.session_id = session_id
super().__init__(self.message)
def __str__(self) -> str:
parts = [f"{self.kind}: {self.message}"]
if self.hint:
parts.append(f"Hint: {self.hint}")
if self.retryable:
parts.append("(retryable)")
if self.cancel_observed:
parts.append(f"(safe_to_reuse_session={self.safe_to_reuse_session})")
return "\n".join(parts)
```
---
## Practical Recovery Patterns
### Pattern 1: Retry on transient errors
```python
from time import sleep
def run_with_retry(
command: list[str],
max_attempts: int = 3,
backoff_seconds: float = 0.5,
) -> dict:
"""Retry on transient errors (filesystem, timeout)."""
for attempt in range(1, max_attempts + 1):
try:
return run_claw_command(command)
except ClawError as err:
if not err.retryable:
raise # Non-transient; fail fast
if attempt == max_attempts:
raise # Last attempt; propagate
print(f"Attempt {attempt} failed ({err.kind}); retrying in {backoff_seconds}s...", file=sys.stderr)
sleep(backoff_seconds)
backoff_seconds *= 1.5 # exponential backoff
raise RuntimeError("Unreachable")
```
### Pattern 2: Reuse session after timeout (if safe)
```python
def run_with_timeout_recovery(
command: list[str],
timeout_seconds: float = 30.0,
fallback_timeout: float = 60.0,
) -> dict:
"""
On timeout, check cancel_observed. If True, the session is safe for retry.
If False, the session is potentially wedged; use a fresh one.
"""
try:
return run_claw_command(command, timeout_seconds=timeout_seconds)
except ClawError as err:
if err.kind != 'timeout':
raise
if err.safe_to_reuse_session:
# Engine saw the cancel signal; safe to reuse this session with a larger timeout
print(f"Timeout observed (cancel_observed=true); retrying with {fallback_timeout}s...", file=sys.stderr)
return run_claw_command(command, timeout_seconds=fallback_timeout)
else:
# Engine didn't see the cancel signal; session may be wedged
print(f"Timeout not observed (cancel_observed=false); session is potentially wedged", file=sys.stderr)
raise # Caller should allocate a fresh session
```
### Pattern 3: Detect parse errors (typos in command-line construction)
```python
def validate_command_before_dispatch(command: list[str]) -> None:
"""
Dry-run with --help to detect obvious syntax errors before dispatching work.
This is cheap (no API call) and catches typos like:
- Unknown subcommand: `claw typo-command`
- Unknown flag: `claw bootstrap --invalid-flag`
- Missing required argument: `claw load-session` (no session_id)
"""
help_cmd = command + ['--help']
try:
result = subprocess.run(help_cmd, capture_output=True, timeout=2.0)
if result.returncode != 0:
print(f"Warning: {' '.join(help_cmd)} returned {result.returncode}", file=sys.stderr)
print("(This doesn't prove the command is invalid, just that --help failed)", file=sys.stderr)
except subprocess.TimeoutExpired:
pass # --help shouldn't hang, but don't block on it
```
### Pattern 4: Log and forward errors to observability
```python
import logging
logger = logging.getLogger(__name__)
def run_claw_with_logging(command: list[str]) -> dict:
"""Run command and log errors for observability."""
try:
result = run_claw_command(command)
logger.info(f"Claw command succeeded: {' '.join(command)}")
return result
except ClawError as err:
logger.error(
"Claw command failed",
extra={
'command': ' '.join(command),
'error_kind': err.kind,
'error_message': err.message,
'retryable': err.retryable,
'cancel_observed': err.cancel_observed,
},
)
raise
```
---
## Error Kinds (Enumeration)
After cycles #178#179, the complete set of `error.kind` values is:
| Kind | Exit Code | Meaning | Retryable | Notes |
|---|---|---|---|---|
| **parse** | 1 | Argparse error (unknown command, missing arg, invalid flag) | No | Real error message included (#179); valid choices list for discoverability |
| **session_not_found** | 1 | load-session target doesn't exist | No | session_id and directory included in envelope |
| **filesystem** | 1 | Directory missing, permission denied, disk full | Yes | Transient issues (disk space, NFS flake) can be retried |
| **runtime** | 1 | Engine error (unexpected exception, malformed input) | Depends | `error.retryable` field in envelope specifies |
| **timeout** | 2 | Engine timeout with cooperative cancellation | Yes* | `cancel_observed` field signals session safety (#164) |
*Retry safety depends on `cancel_observed`:
- `cancel_observed=true` → session is safe to reuse
- `cancel_observed=false` → session may be wedged; allocate fresh one
---
## What We Did to Make This Work
### Cycle #178: Parse-Error Envelope
**Problem:** `claw nonexistent --output-format json` returned argparse help text on stderr instead of an envelope.
**Solution:** Catch argparse `SystemExit` in JSON mode and emit a structured error envelope.
**Benefit:** Claws no longer need to parse human help text to understand parse errors.
### Cycle #179: Stderr Hygiene + Real Error Message
**Problem:** Even after #178, argparse usage was leaking to stderr AND the envelope message was generic ("invalid command or argument").
**Solution:** Monkey-patch `parser.error()` in JSON mode to raise an internal exception, preserving argparse's real message verbatim. Suppress stderr entirely in JSON mode.
**Benefit:** Claws see one stream (stdout), one envelope, and real error context (e.g., "invalid choice: typo (choose from ...)") for discoverability.
### Contract: #164 Stage B (`cancel_observed` field)
**Problem:** Timeout results didn't signal whether the engine actually observed the cancellation request.
**Solution:** Add `cancel_observed: bool` field to timeout TurnResult; signal true iff the engine had a fair chance to observe the cancel event.
**Benefit:** Claws can decide "retry with fresh session" vs "reuse this session with larger timeout" based on a single boolean.
---
## Common Mistakes to Avoid
**Don't parse exit code alone**
```python
# BAD: Exit code 1 could mean parse error, not-found, filesystem, or runtime
if result.returncode == 1:
# What should I do? Unclear.
pass
```
**Do parse error.kind**
```python
# GOOD: error.kind tells you exactly how to recover
match envelope['error']['kind']:
case 'parse': ...
case 'session_not_found': ...
case 'filesystem': ...
```
---
**Don't capture both stdout and stderr and assume they're separate concerns**
```python
# BAD (pre-#179): Capture stdout + stderr, then parse stdout as JSON
# But stderr might contain argparse noise that you have to string-match
result = subprocess.run(..., capture_output=True, text=True)
if "invalid choice" in result.stderr:
# ... custom error handling
```
**Do silence stderr in JSON mode**
```python
# GOOD (post-#179): In JSON mode, stderr is guaranteed silent
# Envelope on stdout is your single source of truth
result = subprocess.run(..., capture_output=True, text=True)
envelope = json.loads(result.stdout) # Always valid in JSON mode
```
---
**Don't retry on parse errors**
```python
# BAD: Typos don't fix themselves
error_kind = envelope['error']['kind']
if error_kind == 'parse':
retry() # Will fail again
```
**Do check retryable before retrying**
```python
# GOOD: Let the error tell you
error = envelope['error']
if error.get('retryable', False):
retry()
else:
raise
```
---
**Don't reuse a session after timeout without checking cancel_observed**
```python
# BAD: Reuse session = potential wedge
result = run_claw_command(...) # times out
# ... later, reuse same session
result = run_claw_command(...) # might be stuck in the previous turn
```
**Do allocate a fresh session if cancel_observed=false**
```python
# GOOD: Allocate fresh session if wedge is suspected
try:
result = run_claw_command(...)
except ClawError as err:
if err.cancel_observed:
# Safe to reuse
result = run_claw_command(...)
else:
# Allocate fresh session
fresh_session = create_session()
result = run_claw_command_in_session(fresh_session, ...)
```
---
## Testing Your Error Handler
```python
def test_error_handler_parse_error():
"""Verify parse errors are caught and classified."""
try:
run_claw_command(['claw', 'nonexistent', '--output-format', 'json'])
assert False, "Should have raised ClawError"
except ClawError as err:
assert err.kind == 'parse'
assert 'invalid choice' in err.message.lower()
assert err.retryable is False
def test_error_handler_timeout_safe():
"""Verify timeout with cancel_observed=true marks session as safe."""
# Requires a live claw-code server; mock this test
try:
run_claw_command(
['claw', 'turn-loop', '"x"', '--timeout-seconds', '0.0001'],
timeout_seconds=2.0,
)
assert False, "Should have raised ClawError"
except ClawError as err:
assert err.kind == 'timeout'
assert err.safe_to_reuse_session is True # cancel_observed=true
def test_error_handler_not_found():
"""Verify session_not_found is clearly classified."""
try:
run_claw_command(['claw', 'load-session', 'nonexistent', '--output-format', 'json'])
assert False, "Should have raised ClawError"
except ClawError as err:
assert err.kind == 'session_not_found'
assert err.retryable is False
```
---
## Appendix A: v1.0 Error Envelope (Current Binary)
The actual shape emitted by the current binary (v1.0, flat):
```json
{
"error": "session 'nonexistent' not found in .claw/sessions",
"hint": "use 'list-sessions' to see available sessions",
"kind": "session_not_found",
"type": "error"
}
```
**Key differences from v2.0 schema (below):**
- `error` field is a **string**, not a structured object
- `kind` is at **top-level**, not nested under `error`
- Missing: `timestamp`, `command`, `exit_code`, `output_format`, `schema_version`
- Extra: `type: "error"` field (not in schema)
## Appendix B: SCHEMAS.md Target Shape (v2.0)
For reference, the target JSON error envelope shape (SCHEMAS.md, v2.0):
```json
{
"timestamp": "2026-04-22T11:40:00Z",
"command": "load-session",
"exit_code": 1,
"output_format": "json",
"schema_version": "2.0",
"error": {
"kind": "session_not_found",
"operation": "session_store.load_session",
"target": "nonexistent",
"retryable": false,
"message": "session 'nonexistent' not found in .port_sessions",
"hint": "use 'list-sessions' to see available sessions"
}
}
```
**This is the target schema after [`FIX_LOCUS_164`](./FIX_LOCUS_164.md) is implemented.** The migration plan includes a dual-mode `--envelope-version=2.0` flag in Phase 1, default version bump in Phase 2, and deprecation in Phase 3. For now, code against v1.0 (Appendix A).
---
## Summary
After cycles #178#179, **one error handler works for all 14 clawable commands.** No more string-matching, no more stderr parsing, no more exit-code ambiguity. Just parse the JSON, check `error.kind`, and decide: retry, escalate, or reuse session (if safe).
The handler itself is ~80 lines of Python; the patterns are reusable across any language that can speak JSON.

View File

@@ -1,364 +0,0 @@
# Fix-Locus #164 — JSON Envelope Contract Migration
**Status:** 📋 Proposed (2026-04-23, cycle #77). Updated cycle #85 (2026-04-23) with v1.5 baseline phase after fresh-dogfood discovery (#168) proved v1.0 was never coherent.
**Class:** Contract migration (not a patch). Affects EVERY `--output-format json` command.
**Bundle:** Typed-error family — joins #102 + #121 + #127 + #129 + #130 + #245 + **#164**. Contract-level implementation of §4.44 typed-error envelope.
---
## 0. CRITICAL UPDATE (Cycle #85 via #168 Evidence)
**Premise revision:** This locus document originally framed the problem as **"v1.0 (incoherent) → v2.0 (target schema)"** migration. **Fresh-dogfood validation in cycle #84 proved this framing was underspecified.**
**Actual problem (evidence from #168):**
- There is **no coherent v1.0 envelope contract**. Each verb has a bespoke JSON shape.
- `claw list-sessions --output-format json` emits `{command, sessions}` — has `command` field
- `claw doctor --output-format json` emits `{checks, kind, message, ...}` — no `command` field
- `claw bootstrap hello --output-format json` emits **NOTHING** (silent failure with exit 0)
- Each verb renderer was written independently with no coordinating contract
**Revised migration plan — three phases instead of two:**
1. **Phase 0 (Emergency):** Fix silent failures (#168 bootstrap JSON). Every `--output-format json` command must emit valid JSON.
2. **Phase 1 (v1.5 Baseline):** Establish minimal JSON invariants across all 14 verbs without breaking existing consumers:
- Every command emits valid JSON when `--output-format json` is passed
- Every command has a top-level `kind` field identifying the verb
- Every error envelope follows the confirmed `{error, hint, kind, type}` shape
- Every success envelope has the verb name in a predictable location
- **Effort:** ~3 dev-days (no new design, just fill gaps and normalize bugs)
3. **Phase 2 (v2.0 Wrapped Envelope):** Execute the original Phase 1 plan documented below — common metadata wrapper, nested data/error objects, opt-in via `--envelope-version=2.0`.
4. **Phase 3 (v2.0 Default):** Original Phase 2 plan below.
5. **Phase 4 (v1.0/v1.5 Deprecation):** Original Phase 3 plan below.
**Why add Phase 0 + Phase 1 (v1.5)?**
- You can't migrate from "incoherent" to "coherent v2.0" in one jump. Intermediate coherence (v1.5 baseline) is required.
- Consumer code built against "whatever v1 emits today" needs a stable target to transition from.
- **Silent failures (bootstrap JSON) must be fixed BEFORE any migration** — otherwise consumers have no way to detect breakage.
**Blocker resolved:** The original blocker "v1.0 design vs v2.0 design" is actually "no v1 design exists; let's make one (v1.5) then migrate." This is a **clearer, lower-risk migration path**.
**Revised effort estimate:** ~9 dev-days total (Phase 0: 1 day + Phase 1/v1.5: 3 days + Phase 2/v2.0: 5 days) instead of ~6 dev-days for a direct v1.0→v2.0 migration (which would have failed given the incoherent baseline).
**Doctrine implication:** Cycles #76#82 diagnosed "aspirational vs current" correctly but missed that "current" was never a single thing. Cycle #84 fresh-dogfood caught this. **Fresh-dogfood discipline (principle #9) prevented a 6-day migration effort from hitting an unsolvable baseline problem.**
---
## 1. Scope — What This Migration Affects
**Every JSON-emitting verb.** Audit across the 14 documented verbs:
| Verb | Current top-level keys | Schema-conformant? |
|---|---|---|
| `doctor` | checks, has_failures, **kind**, message, report, summary | ❌ No (kind=verb-id, flat) |
| `status` | config_load_error, **kind**, model, ..., workspace | ❌ No |
| `version` | git_sha, **kind**, message, target, version | ❌ No |
| `sandbox` | active, ..., **kind**, ...supported | ❌ No |
| `help` | **kind**, message | ❌ No (minimal) |
| `agents` | action, agents, count, **kind**, summary, working_directory | ❌ No |
| `mcp` | action, config_load_error, ..., **kind**, servers | ❌ No |
| `skills` | action, **kind**, skills, summary | ❌ No |
| `system-prompt` | **kind**, message, sections | ❌ No |
| `dump-manifests` | error, hint, **kind**, type | ❌ No (emits error envelope for success) |
| `bootstrap-plan` | **kind**, phases | ❌ No |
| `acp` | aliases, ..., **kind**, ...tracking | ❌ No |
| `export` | file, **kind**, markdown, messages, session_id | ❌ No |
| `state` | error, hint, **kind**, type | ❌ No (emits error envelope for success) |
**All 14 verbs diverge from SCHEMAS.md.** The gap is 100%, not a partial drift.
---
## 2. The Two Envelope Shapes
### 2a. Current Binary Shape (Flat Top-Level)
```json
// Success example (claw doctor --output-format json)
{
"kind": "doctor", // verb identity
"checks": [...],
"summary": {...},
"has_failures": false,
"report": "...",
"message": "..."
}
// Error example (claw doctor foo --output-format json)
{
"error": "unrecognized argument...", // string, not object
"hint": "Run `claw --help` for usage.",
"kind": "cli_parse", // error classification (overloaded)
"type": "error" // not in schema
}
```
**Properties:**
- Flat top-level
- `kind` field is **overloaded** (verb-id in success, error-class in error)
- No common wrapper metadata (timestamp, exit_code, schema_version)
- `error` is a string, not a structured object
### 2b. Documented Schema Shape (Nested, Wrapped)
```json
// Success example (per SCHEMAS.md)
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "doctor",
"exit_code": 0,
"output_format": "json",
"schema_version": "1.0",
"data": {
"checks": [...],
"summary": {...},
"has_failures": false
}
}
// Error example (per SCHEMAS.md)
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "doctor",
"exit_code": 1,
"output_format": "json",
"schema_version": "1.0",
"error": {
"kind": "parse", // enum, nested
"operation": "parse_args",
"target": "subcommand `doctor`",
"retryable": false,
"message": "unrecognized argument...",
"hint": "Run `claw --help` for usage."
}
}
```
**Properties:**
- Common metadata wrapper (timestamp, command, exit_code, output_format, schema_version)
- `data` (payload) vs. `error` (failure) as **sibling fields**, never coexisting
- `kind` in error is the enum from §4.44 (filesystem/auth/session/parse/runtime/mcp/delivery/usage/policy/unknown)
- `error` is a structured object with operation/target/retryable
---
## 3. Migration Strategy — Phased Rollout
**Principle:** Don't break downstream consumers mid-migration. Support both shapes during overlap, then deprecate.
### Phase 1 — Dual-Envelope Mode (Opt-In)
**Deliverables:**
- New flag: `--envelope-version=2.0` (or `--schema-version=2.0`)
- When flag set: emit new (schema-conformant) envelope
- When flag absent: emit current (flat) envelope
- SCHEMAS.md: add "Legacy (v1.0)" section documenting current flat shape alongside v2.0
**Implementation:**
- Single `envelope_version` parameter in `CliOutputFormat` enum
- Every verb's JSON writer checks version, branches accordingly
- Shared wrapper helper: `wrap_v2(payload, command, exit_code)`
**Consumer impact:** Opt-in. Existing consumers unchanged. New consumers can opt in.
**Timeline estimate:** ~2 days for 14 verbs + shared wrapper + tests.
### Phase 2 — Default Version Bump
**Deliverables:**
- Default changes from v1.0 → v2.0
- New flag: `--legacy-envelope` to opt back into flat shape
- Migration guide added to SCHEMAS.md and CHANGELOG
- Release notes: "Breaking change in envelope, pre-migration opt-in available via --legacy-envelope"
**Consumer impact:** Existing consumers must add `--legacy-envelope` OR update to v2.0 schema. Grace period = "until Phase 3."
**Timeline estimate:** Immediately after Phase 1 ships.
### Phase 3 — Flat-Shape Deprecation
**Deliverables:**
- `--legacy-envelope` flag prints deprecation warning to stderr
- SCHEMAS.md "Legacy v1.0" section marked DEPRECATED
- v3.0 release (future): remove flag entirely, binary only emits v2.0
**Consumer impact:** Full migration required by v3.0.
**Timeline estimate:** Phase 3 after ~6 months of Phase 2 usage.
---
## 4. Implementation Details
### 4a. Shared Wrapper Helper
```rust
// rust/crates/rusty-claude-cli/src/json_envelope.rs (new file)
pub fn wrap_v2_success<T: Serialize>(command: &str, data: T) -> Value {
serde_json::json!({
"timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"command": command,
"exit_code": 0,
"output_format": "json",
"schema_version": "2.0",
"data": data,
})
}
pub fn wrap_v2_error(command: &str, error: StructuredError) -> Value {
serde_json::json!({
"timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"command": command,
"exit_code": 1,
"output_format": "json",
"schema_version": "2.0",
"error": {
"kind": error.kind,
"operation": error.operation,
"target": error.target,
"retryable": error.retryable,
"message": error.message,
"hint": error.hint,
},
})
}
pub struct StructuredError {
pub kind: &'static str, // enum from §4.44
pub operation: String,
pub target: String,
pub retryable: bool,
pub message: String,
pub hint: Option<String>,
}
```
### 4b. Per-Verb Migration Pattern
```rust
// Before (current flat shape):
match output_format {
CliOutputFormat::Json => {
serde_json::to_string_pretty(&DoctorOutput {
kind: "doctor",
checks,
summary,
has_failures,
message,
report,
})
}
CliOutputFormat::Text => render_text(&data),
}
// After (v2.0 with v1.0 fallback):
match (output_format, envelope_version) {
(CliOutputFormat::Json, 2) => {
json_envelope::wrap_v2_success("doctor", DoctorData { checks, summary, has_failures })
}
(CliOutputFormat::Json, 1) => {
// Legacy flat shape (with deprecation warning at Phase 3)
serde_json::to_value(&LegacyDoctorOutput { kind: "doctor", ...})
}
(CliOutputFormat::Text, _) => render_text(&data),
}
```
### 4c. Error Classification Migration
Current error `kind` values (found in binary):
- `cli_parse`, `no_managed_sessions`, `unknown`, `missing_credentials`, `session_not_found`
Target v2.0 enum (per §4.44):
- `filesystem`, `auth`, `session`, `parse`, `runtime`, `mcp`, `delivery`, `usage`, `policy`, `unknown`
**Migration table:**
| Current kind | v2.0 error.kind |
|---|---|
| `cli_parse` | `parse` |
| `no_managed_sessions` | `session` (with operation: "list_sessions") |
| `missing_credentials` | `auth` |
| `session_not_found` | `session` (with operation: "resolve_session") |
| `unknown` | `unknown` |
---
## 5. Acceptance Criteria
1. **Schema parity:** Every `--output-format json` command emits v2.0 envelope shape exactly per SCHEMAS.md
2. **Success/error symmetry:** Success envelopes have `data` field; error envelopes have `error` object; never both
3. **kind semantic unification:** `data.kind` = verb identity (when present); `error.kind` = enum from §4.44. No overloading.
4. **Common metadata:** `timestamp`, `command`, `exit_code`, `output_format`, `schema_version` present in ALL envelopes
5. **Dual-mode support:** `--envelope-version=1|2` flag allows opt-in/opt-out during migration
6. **Tests:** Per-verb golden test fixtures for both v1.0 and v2.0 envelopes
7. **Documentation:** SCHEMAS.md documents both versions with deprecation timeline
---
## 6. Risks
### 6a. Breaking Change Risk
Phase 2 (default version bump) WILL break consumers that depend on flat-shape envelope. Mitigations:
- Dual-mode flag allows opt-in testing before default change
- Long grace period (Phase 3 deprecation ~6 months post-Phase 2)
- Clear migration guide + example consumer code
### 6b. Implementation Risk
14 verbs to migrate. Each verb has its own success shape (`checks`, `agents`, `phases`, etc.). Payload structure stays the same; only the wrapper changes. Mechanical but high-volume.
**Estimated diff size:** ~200 lines per verb × 14 verbs = ~2,800 lines (mostly boilerplate).
**Mitigation:** Start with doctor, status, version as pilot. If pattern works, batch remaining 11.
### 6c. Error Classification Remapping Risk
Changing `kind: "cli_parse"` to `error.kind: "parse"` is a breaking change even within the error envelope. Consumers doing `response["kind"] == "cli_parse"` will break.
**Mitigation:** Document explicitly in migration guide. Provide sed script if needed.
---
## 7. Deliverables Summary
| Item | Phase | Effort |
|---|---|---|
| `json_envelope.rs` shared helper | Phase 1 | 1 day |
| 14 verb migrations (pilot 3 + batch 11) | Phase 1 | 2 days |
| `--envelope-version` flag | Phase 1 | 0.5 day |
| Dual-mode tests (golden fixtures) | Phase 1 | 1 day |
| SCHEMAS.md updates (v1.0 + v2.0) | Phase 1 | 0.5 day |
| Default version bump | Phase 2 | 0.5 day |
| Deprecation warnings | Phase 3 | 0.5 day |
| Migration guide doc | Phase 1 | 0.5 day |
**Total estimate:** ~6 developer-days for Phase 1 (the core work). Phases 2/3 are cheap follow-ups.
---
## 8. Rollout Timeline (Proposed)
- **Week 1:** Phase 1 — dual-mode support + pilot migration (3 verbs)
- **Week 2:** Phase 1 completion — remaining 11 verbs + full test coverage
- **Week 3:** Stabilization period, gather consumer feedback
- **Month 2:** Phase 2 — default version bump
- **Month 8:** Phase 3 — deprecation warnings
- **v3.0 release:** Remove `--legacy-envelope` flag, v1.0 shape no longer supported
---
## 9. Related
- **ROADMAP #164:** The originating pinpoint (this document is its fix-locus)
- **ROADMAP §4.44:** Typed-error contract (defines the error.kind enum this migration uses)
- **SCHEMAS.md:** The envelope schema this migration makes reality
- **Typed-error family:** #102, #121, #127, #129, #130, #245, **#164**
---
**Cycle #77 locus doc. Ready for author review + pilot implementation decision.**

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 ultraworkers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,208 +0,0 @@
# Merge Checklist — claw-code
**Purpose:** Streamline merging of the 17 review-ready branches by grouping them into safe clusters and providing per-cluster merge order + validation steps.
**Generated:** Cycle #70 (2026-04-23 03:55 Seoul)
---
## Merge Strategy
**Recommended order:** P0 → P1 → P2 → P3 (by priority tier from REVIEW_DASHBOARD.md).
**Batch strategy:** Merge by cluster, not individual branches. Each cluster shares the same fix pattern, so reviewers can validate one cluster and merge all members together.
**Estimated throughput:** 2-3 clusters per merge session. At current cycle velocity (~1 cluster per 15 min), full queue → merged main in ~2 hours.
---
## Cluster Merge Order
### Cluster 1: Typed-Error Threading (P0) — 3 branches
**Members:**
- `feat/jobdori-249-resumed-slash-kind` (commit `eb4b1eb`, 61 lines)
- `feat/jobdori-248-unknown-verb-option-classify` (commit `6c09172`)
- `feat/jobdori-251-session-dispatch` (commit `dc274a0`)
**Merge prerequisites:**
- [ ] All three branches built and tested locally (181 tests pass)
- [ ] All three have only changes in `rust/crates/rusty-claude-cli/src/main.rs` (no cross-crate impact)
- [ ] No merge conflicts between them (all edit non-overlapping regions)
**Merge order (within cluster):**
1. #249 (smallest, lowest risk)
2. #248 (medium)
3. #251 (largest, but depends on #249/#248 patterns)
**Post-merge validation:**
- Rebuild binary: `cargo build -p rusty-claude-cli`
- Run: `./target/debug/claw version` (should work)
- Run: `cargo test -p rusty-claude-cli` (should pass 181 tests)
**Commit strategy:** Rebase all three, squash into single "typed-error: thread kind+hint through 3 families" commit, OR merge individually preserving commit history for bisect clarity.
---
### Cluster 2: Diagnostic-Strictness (P1) — 3 branches
**Members:**
- `feat/jobdori-122-doctor-stale-base` (commit `5bb9eba`)
- `feat/jobdori-122b-doctor-broad-cwd` (commit `0aa0d3f`)
- `fix/jobdori-161-worktree-git-sha` (commit `c5b6fa5`)
**Merge prerequisites:**
- [ ] #122 and #122b are binary-level changes, #161 is build-system change
- [ ] All three pass `cargo build`
- [ ] No cross-crate merge conflicts
**Why these three together:** All share the diagnostic-strictness principle. #122 and #122b extend `doctor`, #161 fixes `version`. Merging as a cluster signals the principle to future reviewers.
**Post-merge validation:**
- Rebuild binary
- Run: `claw doctor` (should now check stale-base + broad-cwd)
- Run: `claw version` (should report correct SHA even in worktrees)
- Run: `cargo test` (full suite)
**Commit strategy:** Merge individually preserving history, then add ROADMAP commit explaining the cluster principle. This makes the doctrine visible in git log.
---
### Cluster 3: Help-Parity (P1) — 4 branches
**Members:**
- `feat/jobdori-130b-filesystem-context` (commit `d49a75c`)
- `feat/jobdori-130c-diff-help` (commit `83f744a`)
- `feat/jobdori-130d-config-help` (commit `19638a0`)
- `feat/jobdori-130e-dispatch-help` + `feat/jobdori-130e-surface-help` (commits `0ca0344`, `9dd7e79`)
**Merge prerequisites:**
- [ ] All four branches edit help-topic routing in the same regions
- [ ] Verify no merge conflicts (should be sequential, non-overlapping edits)
- [ ] `cargo build` passes
**Why these four together:** All address help-parity (verbs in `--help` → correct help topics). This cluster is the most "batch-like" — identical fix pattern repeated.
**Post-merge validation:**
- Rebuild binary
- Run: `claw diff --help` (should route to help topic, not crash)
- Run: `claw config --help` (ditto)
- Run: `claw --help` (should list all verbs)
**Merge strategy:** Can be fast-forwarded or squashed as a unit since they're all the same pattern.
---
### Cluster 4: Suffix-Guard (P2) — 2 branches
**Members:**
- `feat/jobdori-152-init-suffix-guard` (commit `860f285`)
- `feat/jobdori-152-bootstrap-plan-suffix-guard` (commit `3a533ce`)
**Merge prerequisites:**
- [ ] Both branches add `rest.len() > 1` check to no-arg verbs
- [ ] No conflicts
**Post-merge validation:**
- `claw init extra-arg` (should reject)
- `claw bootstrap-plan extra-arg` (should reject)
**Merge strategy:** Merge together.
---
### Cluster 5: Verb-Classification (P2) — 1 branch
**Member:**
- `feat/jobdori-160-verb-classification` (commit `5538934`)
**Merge prerequisites:**
- [ ] Binary tested (23-line change to parser)
- [ ] `cargo test` passes 181 tests
**Post-merge validation:**
- `claw resume bogus-id` (should emit slash-command guidance, not missing_credentials)
- `claw explain this` (should still route to Prompt)
**Note:** Can merge solo or batch with #4. No dependencies.
---
### Cluster 6: Doc-Truthfulness (P3) — 2 branches
**Members:**
- `docs/parity-update-2026-04-23` (commit `92a79b5`)
- `docs/jobdori-162-usage-verb-parity` (commit `48da190`)
**Merge prerequisites:**
- [ ] Both are doc-only (no code risk)
- [ ] USAGE.md sections match verbs in `--help`
- [ ] PARITY.md stats are current
**Post-merge validation:**
- `claw --help` (all verbs listed)
- `grep "dump-manifests\|bootstrap-plan" USAGE.md` (should find sections)
- Read PARITY.md (should cite current date + stats)
**Merge strategy:** Can merge in any order.
---
## Merge Conflict Risk Assessment
**High-risk clusters (potential conflicts):**
- Cluster 1 (Typed-error) — all edit `main.rs` dispatch/error arms, but in different methods (likely non-overlapping)
- Cluster 3 (Help-parity) — all edit help-routing, but different verbs (should sequence cleanly)
**Low-risk clusters (isolated changes):**
- Cluster 2 (Diagnostic-strictness) — #122 and #122b both edit `check_workspace_health()`, could conflict. #161 edits `build.rs` (no overlap).
- Cluster 4 (Suffix-guard) — two independent verbs, no conflict
- Cluster 5 (Verb-classification) — solo, no conflict
- Cluster 6 (Doc-truthfulness) — doc-only, no conflict
**Conflict mitigation:** Merge Cluster 2 sub-groups: (#122#122b#161) to avoid simultaneous edits to `check_workspace_health()`.
---
## Post-Merge Validation Checklist
**After all clusters are merged to main:**
- [ ] `cargo build --all` (full workspace build)
- [ ] `cargo test -p rusty-claude-cli` (181 tests pass)
- [ ] `cargo fmt --all --check` (no formatting regressions)
- [ ] `./target/debug/claw version` (correct SHA, not stale)
- [ ] `./target/debug/claw doctor` (stale-base + broad-cwd warnings work)
- [ ] `./target/debug/claw --help` (all verbs listed)
- [ ] `grep -c "### \`" USAGE.md` (all 12 verbs documented, not 8)
- [ ] Fresh dogfood run: `./target/debug/claw prompt "test"` (works)
---
## Timeline Estimate
| Phase | Time | Action |
|---|---|---|
| Merge Cluster 1 (P0 typed-error) | ~15 min | Merge 3 branches, test, validate |
| Merge Cluster 2 (P1 diagnostic-strictness) | ~15 min | Merge 3 branches (mind #122/#122b conflict) |
| Merge Cluster 3 (P1 help-parity) | ~20 min | Merge 4 branches (batch-friendly) |
| Merge Cluster 46 (P2P3, low-risk) | ~10 min | Fast merges |
| **Total** | **~60 min** | **All 17 branches → main** |
---
## Notes for Reviewer
**Branch-last protocol validation:** All 17 branches here represent work that was:
1. Pinpoint filed (with repro + fix shape)
2. Implemented in scratch/worktree (not directly on main)
3. Verified to build + pass tests
4. Only then branched for review
This artifact provides the final step: **validated merge order + per-cluster risks.**
**Integration-support artifact:** This checklist reduces reviewer cognitive load by pre-answering "which merge order is safest?" and "what could go wrong?" questions.
---
**Checklist source:** Cycle #70 (2026-04-23 03:55 Seoul)

View File

@@ -1,151 +0,0 @@
# OPT_OUT Surface Audit Roadmap
**Status:** Pre-audit (decision table ready, survey pending)
This document governs the audit and potential promotion of 12 OPT_OUT surfaces (commands that currently do **not** support `--output-format json`).
## OPT_OUT Classification Rationale
A surface is classified as OPT_OUT when:
1. **Human-first by nature:** Rich Markdown prose / diagrams / structured text where JSON would be information loss
2. **Query-filtered alternative exists:** Commands with internal `--query` / `--limit` don't need JSON (users already have escape hatch)
3. **Simulation/debug only:** Not meant for production orchestration (e.g., mode simulators)
4. **Future JSON work is planned:** Documented in ROADMAP with clear upgrade path
---
## OPT_OUT Surfaces (12 Total)
### Group A: Rich-Markdown Reports (4 commands)
**Rationale:** These emit structured narrative prose. JSON would require lossy serialization.
| Command | Output | Current use | JSON case |
|---|---|---|---|
| `summary` | Multi-section workspace summary (Markdown) | Human readability | Not applicable; Markdown is the output |
| `manifest` | Workspace manifest with project tree (Markdown) | Human readability | Not applicable; Markdown is the output |
| `parity-audit` | TypeScript/Python port comparison report (Markdown) | Human readability | Not applicable; Markdown is the output |
| `setup-report` | Preflight + startup diagnostics (Markdown) | Human readability | Not applicable; Markdown is the output |
**Audit decision:** These likely remain OPT_OUT long-term (Markdown-as-output is intentional). If JSON version needed in future, would be a separate `--output-format json` path generating structured data (project summary object, manifest array, audit deltas, setup checklist) — but that's a **new contract**, not an addition to existing Markdown surfaces.
**Pinpoint:** #175 (deferred) — audit whether `summary`/`manifest` should emit JSON structured versions *in parallel* with Markdown, or if Markdown-only is the right UX.
---
### Group B: List Commands with Query Filters (3 commands)
**Rationale:** These already support `--query` and `--limit` for filtering. JSON output would be redundant; users can pipe to `jq`.
| Command | Filtering | Current output | JSON case |
|---|---|---|---|
| `subsystems` | `--limit` | Human-readable list | Use `--query` to filter, users can parse if needed |
| `commands` | `--query`, `--limit`, `--no-plugin-commands`, `--no-skill-commands` | Human-readable list | Use `--query` to filter, users can parse if needed |
| `tools` | `--query`, `--limit`, `--simple-mode` | Human-readable list | Use `--query` to filter, users can parse if needed |
**Audit decision:** `--query` / `--limit` are already the machine-friendly escape hatch. These commands are **intentionally** list-filter-based (not orchestration-primary). Promoting to CLAWABLE would require:
1. Formalizing what the structured output *is* (command array? tool array?)
2. Versioning the schema per command
3. Updating tests to validate per-command schemas
**Cost-benefit:** Low. Users who need structured data can already use `--query` to narrow results, then parse. Effort to promote > value.
**Pinpoint:** #176 (backlog) — audit `--query` UX; consider if a `--query-json` escape hatch (output JSON of matching items) is worth the schema tax.
---
### Group C: Simulation / Debug Surfaces (5 commands)
**Rationale:** These are intentionally **not production-orchestrated**. They simulate behavior, test modes, or debug scenarios. JSON output doesn't add value.
| Command | Purpose | Output | Use case |
|---|---|---|---|
| `remote-mode` | Simulate remote execution | Text (mock session) | Testing harness behavior under remote constraints |
| `ssh-mode` | Simulate SSH execution | Text (mock SSH session) | Testing harness behavior over SSH-like transport |
| `teleport-mode` | Simulate teleport hop | Text (mock hop session) | Testing harness behavior with teleport bouncing |
| `direct-connect-mode` | Simulate direct network | Text (mock session) | Testing harness behavior with direct connectivity |
| `deep-link-mode` | Simulate deep-link invocation | Text (mock deep-link) | Testing harness behavior from URL/deeplink |
**Audit decision:** These are **intentionally simulation-only**. Promoting to CLAWABLE means:
1. "This simulated mode is now a valid orchestration surface"
2. Need to define what JSON output *means* (mock session state? simulation log?)
3. Need versioning + test coverage
**Cost-benefit:** Very low. These are debugging tools, not orchestration endpoints. Effort to promote >> value.
**Pinpoint:** #177 (backlog) — decide if mode simulators should ever be CLAWABLE (probably no).
---
## Audit Workflow (Future Cycles)
### For each surface:
1. **Survey:** Check if any external claw actually uses --output-format with this surface
2. **Cost estimate:** How much schema work + testing?
3. **Value estimate:** How much demand for JSON version?
4. **Decision:** CLAWABLE, remain OPT_OUT, or new pinpoint?
### Promotion criteria (if promoting to CLAWABLE):
A surface moves from OPT_OUT → CLAWABLE **only if**:
- ✅ Clear use case for JSON (not just "hypothetically could be JSON")
- ✅ Schema is simple and stable (not 20+ fields)
- ✅ At least one external claw has requested it
- ✅ Tests can be added without major refactor
- ✅ Maintainability burden is worth the value
### Demote criteria (if staying OPT_OUT):
A surface stays OPT_OUT **if**:
- ✅ JSON would be information loss (Markdown reports)
- ✅ Equivalent filtering already exists (`--query` / `--limit`)
- ✅ Use case is simulation/debug, not production
- ✅ Promotion effort > value to users
---
## Post-Audit Outcomes
### Likely scenario (high confidence)
**Group A (Markdown reports):** Remain OPT_OUT
- `summary`, `manifest`, `parity-audit`, `setup-report` are **intentionally** human-first
- If JSON-like structure is needed in future, would be separate `*-json` commands or distinct `--output-format`, not added to Markdown surfaces
**Group B (List filters):** Remain OPT_OUT
- `subsystems`, `commands`, `tools` have `--query` / `--limit` as query layer
- Users who need structured data already have escape hatch
**Group C (Mode simulators):** Remain OPT_OUT
- `remote-mode`, `ssh-mode`, etc. are debug tools, not orchestration endpoints
- No demand for JSON version; promotion would be forced, not driven
**Result:** OPT_OUT audit concludes that 12/12 surfaces should **remain OPT_OUT** (no promotions).
### If demand emerges
If external claws report needing JSON from any OPT_OUT surface:
1. File pinpoint with use case + rationale
2. Estimate cost + value
3. If value > cost, promote to CLAWABLE with full test coverage
4. Update SCHEMAS.md
5. Update CLAUDE.md
---
## Timeline
- **Post-#174 (now):** OPT_OUT audit documented (this file)
- **Cycles #19#21 (deferred):** Survey period — collect data on external demand
- **Cycle #22 (deferred):** Final audit decision + any promotions
- **Post-audit:** Move to protocol maintenance mode (new commands/fields/surfaces)
---
## Related
- **OPT_OUT_DEMAND_LOG.md** — Active survey recording real demand signals (evidentiary base for any promotion decision)
- **SCHEMAS.md** — Clawable surface contracts
- **CLAUDE.md** — Development guidance
- **test_cli_parity_audit.py** — Parametrized tests for CLAWABLE_SURFACES enforcement
- **ROADMAP.md** — Macro phases (this audit is Phase 3 before Phase 2 closure)

View File

@@ -1,167 +0,0 @@
# OPT_OUT Demand Log
**Purpose:** Record real demand signals for promoting OPT_OUT surfaces to CLAWABLE. Without this log, the audit criteria in `OPT_OUT_AUDIT.md` have no evidentiary base.
**Status:** Active survey window (post-#178/#179, cycles #21+)
## How to file a demand signal
When any external claw, operator, or downstream consumer actually needs JSON output from one of the 12 OPT_OUT surfaces, add an entry below. **Speculation, "could be useful someday," and internal hypotheticals do NOT count.**
A valid signal requires:
- **Source:** Who/what asked (human, automation, agent session, external tool)
- **Surface:** Which OPT_OUT command (from the 12)
- **Use case:** The concrete orchestration problem they're trying to solve
- **Would-parse-Markdown alternative checked?** Why the existing OPT_OUT output is insufficient
- **Date:** When the signal was received
## Promotion thresholds
Per `OPT_OUT_AUDIT.md` criteria:
- **2+ independent signals** for the same surface within a survey window → file promotion pinpoint
- **1 signal + existing stable schema** → file pinpoint for discussion
- **0 signals** → surface stays OPT_OUT (documented rationale in audit file)
The threshold is intentionally high. Single-use hacks can be served via one-off Markdown parsing; schema promotion is expensive (docs, tests, maintenance).
---
## Demand Signals Received
### Group A: Rich-Markdown Reports
#### `summary`
**Signals received: 0**
Notes: No demand recorded. Markdown output is intentional and useful for human review.
#### `manifest`
**Signals received: 0**
Notes: No demand recorded.
#### `parity-audit`
**Signals received: 0**
Notes: No demand recorded. Report consumers are humans reviewing porting progress, not automation.
#### `setup-report`
**Signals received: 0**
Notes: No demand recorded.
---
### Group B: List Commands with Query Filters
#### `subsystems`
**Signals received: 0**
Notes: `--limit` already provides filtering. No claws requesting JSON.
#### `commands`
**Signals received: 0**
Notes: `--query`, `--limit`, `--no-plugin-commands`, `--no-skill-commands` already allow filtering. No demand recorded.
#### `tools`
**Signals received: 0**
Notes: `--query`, `--limit`, `--simple-mode` provide filtering. No demand recorded.
---
### Group C: Simulation / Debug Surfaces
#### `remote-mode`
**Signals received: 0**
Notes: Simulation-only. No production orchestration need.
#### `ssh-mode`
**Signals received: 0**
Notes: Simulation-only.
#### `teleport-mode`
**Signals received: 0**
Notes: Simulation-only.
#### `direct-connect-mode`
**Signals received: 0**
Notes: Simulation-only.
#### `deep-link-mode`
**Signals received: 0**
Notes: Simulation-only.
---
## Survey Window Status
| Cycle | Date | New Signals | Running Total | Action |
|---|---|---|---|---|
| #21 | 2026-04-22 | 0 | 0 | Survey opened; log established |
**Current assessment:** Zero demand for any OPT_OUT surface promotion. This is consistent with `OPT_OUT_AUDIT.md` prediction that all 12 likely stay OPT_OUT long-term.
---
## Signal Entry Template
```
### <surface-name>
**Signal received: [N]**
Entry N (YYYY-MM-DD):
- Source: <who/what>
- Use case: <concrete orchestration problem>
- Markdown-alternative-checked: <yes/no + why insufficient>
- Follow-up: <filed pinpoint / discussion thread / closed>
```
---
## Decision Framework
At cycle #22 (or whenever survey window closes):
### If 0 signals total (likely):
- Move all 12 surfaces to `PERMANENTLY_OPT_OUT` or similar
- Remove `OPT_OUT_SURFACES` from `test_cli_parity_audit.py` (everything is explicitly non-goal)
- Update `CLAUDE.md` to reflect maintainership mode
- Close `OPT_OUT_AUDIT.md` with "audit complete, no promotions"
### If 12 signals on isolated surfaces:
- File individual promotion pinpoints per surface with demand evidence
- Each goes through standard #171/#172/#173 loop (parity audit, SCHEMAS.md, consistency test)
### If high demand (3+ signals):
- Reopen audit: is the OPT_OUT classification actually correct?
- Review whether protocol expansion is warranted
---
## Related Files
- **`OPT_OUT_AUDIT.md`** — Audit criteria, decision table, rationale by group
- **`SCHEMAS.md`** — JSON contract for the 14 CLAWABLE surfaces
- **`tests/test_cli_parity_audit.py`** — Machine enforcement of CLAWABLE/OPT_OUT classification
- **`CLAUDE.md`** — Development posture (maintainership mode)
---
## Philosophy
**Prevent speculative expansion.** The discipline of requiring real signals before promotion protects the protocol from schema bloat. Every new CLAWABLE surface adds:
- A SCHEMAS.md section (maintenance burden)
- Test coverage (test suite tax)
- Documentation (cognitive load for new developers)
- Version compatibility (schema_version bump risk)
If a claw can't articulate *why* it needs JSON for `summary` beyond "it would be nice," then JSON for `summary` is not needed. The Markdown output is a feature, not a gap.
The audit log closes the loop on "governed non-goals": OPT_OUT surfaces are intentionally not clawable until proven otherwise by evidence.

327
PARITY.md
View File

@@ -1,217 +1,214 @@
# Parity Status — claw-code Rust Port
# PARITY GAP ANALYSIS
Last updated: 2026-04-23
Scope: read-only comparison between the original TypeScript source at `/home/bellman/Workspace/claude-code/src/` and the Rust port under `rust/crates/`.
## Summary
Method: compared feature surfaces, registries, entrypoints, and runtime plumbing only. No TypeScript source was copied.
- Canonical document: this top-level `PARITY.md` is the file consumed by `rust/scripts/run_mock_parity_diff.py`.
- Requested 9-lane checkpoint: **All 9 lanes merged on `main`.**
- Current `main` HEAD: `ad1cf92` (doctrine loop canonical example).
- Repository stats at this checkpoint: **979 commits on `main`**, **9 crates**, **80,789 tracked Rust LOC**, **4,533 test LOC**, **3 authors**, date **2026-04-23**.
- **Growth since last PARITY update (2026-04-03):** Rust LOC +66% (48,599 → 80,789), Test LOC +76% (2,568 → 4,533), Commits +235% (292 → 979). Current phase: 13 branches awaiting review/integration.
- Mock parity harness stats: **10 scripted scenarios**, **19 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`.
## Executive summary
## Mock parity harness — milestone 1
The Rust port has a good foundation for:
- Anthropic API/OAuth basics
- local conversation/session state
- a core tool loop
- MCP stdio/bootstrap support
- CLAUDE.md discovery
- a small but usable built-in tool set
- [x] Deterministic Anthropic-compatible mock service (`rust/crates/mock-anthropic-service`)
- [x] Reproducible clean-environment CLI harness (`rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`)
- [x] Scripted scenarios: `streaming_text`, `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, `write_file_denied`
It is **not feature-parity** with the TypeScript CLI.
## Mock parity harness — milestone 2 (behavioral expansion)
Largest gaps:
- **plugins** are effectively absent in Rust
- **hooks** are parsed but not executed in Rust
- **CLI breadth** is much narrower in Rust
- **skills** are local-file only in Rust, without the TS registry/bundled pipeline
- **assistant orchestration** lacks TS hook-aware orchestration and remote/structured transports
- **services** beyond core API/OAuth/MCP are mostly missing in Rust
- [x] Scripted multi-tool turn coverage: `multi_tool_turn_roundtrip`
- [x] Scripted bash coverage: `bash_stdout_roundtrip`
- [x] Scripted permission prompt coverage: `bash_permission_prompt_approved`, `bash_permission_prompt_denied`
- [x] Scripted plugin-path coverage: `plugin_tool_roundtrip`
- [x] Behavioral diff/checklist runner: `rust/scripts/run_mock_parity_diff.py`
---
## Harness v2 behavioral checklist
## tools/
Canonical scenario map: `rust/mock_parity_scenarios.json`
### TS exists
Evidence:
- `src/tools/` contains broad tool families including `AgentTool`, `AskUserQuestionTool`, `BashTool`, `ConfigTool`, `FileReadTool`, `FileWriteTool`, `GlobTool`, `GrepTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `SkillTool`, `Task*`, `Team*`, `TodoWriteTool`, `ToolSearchTool`, `WebFetchTool`, `WebSearchTool`.
- Tool execution/orchestration is split across `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolHooks.ts`, and `src/services/tools/toolOrchestration.ts`.
- Multi-tool assistant turns
- Bash flow roundtrips
- Permission enforcement across tool paths
- Plugin tool execution path
- File tools — harness-validated flows
- Streaming response support validated by the mock parity harness
### Rust exists
Evidence:
- Tool registry is centralized in `rust/crates/tools/src/lib.rs` via `mvp_tool_specs()`.
- Current built-ins include shell/file/search/web/todo/skill/agent/config/notebook/repl/powershell primitives.
- Runtime execution is wired through `rust/crates/tools/src/lib.rs` and `rust/crates/runtime/src/conversation.rs`.
## 9-lane checkpoint
### Missing or broken in Rust
- No Rust equivalents for major TS tools such as `AskUserQuestionTool`, `LSPTool`, `ListMcpResourcesTool`, `MCPTool`, `McpAuthTool`, `ReadMcpResourceTool`, `RemoteTriggerTool`, `ScheduleCronTool`, `Task*`, `Team*`, and several workflow/system tools.
- Rust tool surface is still explicitly an MVP registry, not a parity registry.
- Rust lacks TSs layered tool orchestration split.
| Lane | Status | Feature commit | Merge commit | Evidence |
|---|---|---|---|---|
| 1. Bash validation | merged | `36dac6c` | `1cfd78a` | `jobdori/bash-validation-submodules`, `rust/crates/runtime/src/bash_validation.rs` (`+1004` on `main`) |
| 2. CI fix | merged | `89104eb` | `f1969ce` | `rust/crates/runtime/src/sandbox.rs` (`+22/-1`) |
| 3. File-tool | merged | `284163b` | `a98f2b6` | `rust/crates/runtime/src/file_ops.rs` (`+195/-1`) |
| 4. TaskRegistry | merged | `5ea138e` | `21a1e1d` | `rust/crates/runtime/src/task_registry.rs` (`+336`) |
| 5. Task wiring | merged | `e8692e4` | `d994be6` | `rust/crates/tools/src/lib.rs` (`+79/-35`) |
| 6. Team+Cron | merged | `c486ca6` | `49653fe` | `rust/crates/runtime/src/team_cron_registry.rs`, `rust/crates/tools/src/lib.rs` (`+441/-37`) |
| 7. MCP lifecycle | merged | `730667f` | `cc0f92e` | `rust/crates/runtime/src/mcp_tool_bridge.rs`, `rust/crates/tools/src/lib.rs` (`+491/-24`) |
| 8. LSP client | merged | `2d66503` | `d7f0dc6` | `rust/crates/runtime/src/lsp_client.rs`, `rust/crates/tools/src/lib.rs` (`+461/-9`) |
| 9. Permission enforcement | merged | `66283f4` | `336f820` | `rust/crates/runtime/src/permission_enforcer.rs`, `rust/crates/tools/src/lib.rs` (`+357`) |
**Status:** partial core only.
## Lane details
---
### Lane 1 — Bash validation
## hooks/
- **Status:** merged on `main`.
- **Feature commit:** `36dac6c``feat: add bash validation submodules — readOnlyValidation, destructiveCommandWarning, modeValidation, sedValidation, pathValidation, commandSemantics`
- **Evidence:** branch-only diff adds `rust/crates/runtime/src/bash_validation.rs` and a `runtime::lib` export (`+1005` across 2 files).
- **Main-branch reality:** `rust/crates/runtime/src/bash.rs` is still the active on-`main` implementation at **283 LOC**, with timeout/background/sandbox execution. `PermissionEnforcer::check_bash()` adds read-only gating on `main`, but the dedicated validation module is not landed.
### TS exists
Evidence:
- Hook command surface under `src/commands/hooks/`.
- Runtime hook machinery in `src/services/tools/toolHooks.ts` and `src/services/tools/toolExecution.ts`.
- TS supports `PreToolUse`, `PostToolUse`, and broader hook-driven behaviors configured through settings and documented in `src/skills/bundled/updateConfig.ts`.
### Bash tool — upstream has 18 submodules, Rust has 1:
### Rust exists
Evidence:
- Hook config is parsed and merged in `rust/crates/runtime/src/config.rs`.
- Hook config can be inspected via Rust config reporting in `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
- Prompt guidance mentions hooks in `rust/crates/runtime/src/prompt.rs`.
- On `main`, this statement is still materially true.
- Harness coverage proves bash execution and prompt escalation flows, but not the full upstream validation matrix.
- The branch-only lane targets `readOnlyValidation`, `destructiveCommandWarning`, `modeValidation`, `sedValidation`, `pathValidation`, and `commandSemantics`.
### Missing or broken in Rust
- No actual hook execution pipeline in `rust/crates/runtime/src/conversation.rs`.
- No PreToolUse/PostToolUse mutation/deny/rewrite/result-hook behavior.
- No Rust `/hooks` parity command.
### Lane 2 — CI fix
**Status:** config-only; runtime behavior missing.
- **Status:** merged on `main`.
- **Feature commit:** `89104eb``fix(sandbox): probe unshare capability instead of binary existence`
- **Merge commit:** `f1969ce``Merge jobdori/fix-ci-sandbox: probe unshare capability for CI fix`
- **Evidence:** `rust/crates/runtime/src/sandbox.rs` is **385 LOC** and now resolves sandbox support from actual `unshare` capability and container signals instead of assuming support from binary presence alone.
- **Why it matters:** `.github/workflows/rust-ci.yml` runs `cargo fmt --all --check` and `cargo test -p rusty-claude-cli`; this lane removed a CI-specific sandbox assumption from runtime behavior.
---
### Lane 3 — File-tool
## plugins/
- **Status:** merged on `main`.
- **Feature commit:** `284163b``feat(file_ops): add edge-case guards — binary detection, size limits, workspace boundary, symlink escape`
- **Merge commit:** `a98f2b6``Merge jobdori/file-tool-edge-cases: binary detection, size limits, workspace boundary guards`
- **Evidence:** `rust/crates/runtime/src/file_ops.rs` is **744 LOC** and now includes `MAX_READ_SIZE`, `MAX_WRITE_SIZE`, NUL-byte binary detection, and canonical workspace-boundary validation.
- **Harness coverage:** `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, and `write_file_denied` are in the manifest and exercised by the clean-env harness.
### TS exists
Evidence:
- Built-in plugin scaffolding in `src/plugins/builtinPlugins.ts` and `src/plugins/bundled/index.ts`.
- Plugin lifecycle/services in `src/services/plugins/PluginInstallationManager.ts` and `src/services/plugins/pluginOperations.ts`.
- CLI/plugin command surface under `src/commands/plugin/` and `src/commands/reload-plugins/`.
### File tools — harness-validated flows
### Rust exists
Evidence:
- No dedicated plugin subsystem appears under `rust/crates/`.
- Repo-wide Rust references to plugins are effectively absent beyond text/help mentions.
- `read_file_roundtrip` checks read-path execution and final synthesis.
- `grep_chunk_assembly` checks chunked grep tool output handling.
- `write_file_allowed` and `write_file_denied` validate both write success and permission denial.
### Missing or broken in Rust
- No plugin loader.
- No marketplace install/update/enable/disable flow.
- No `/plugin` or `/reload-plugins` parity.
- No plugin-provided hook/tool/command/MCP extension path.
### Lane 4 — TaskRegistry
**Status:** missing.
- **Status:** merged on `main`.
- **Feature commit:** `5ea138e``feat(runtime): add TaskRegistry — in-memory task lifecycle management`
- **Merge commit:** `21a1e1d``Merge jobdori/task-runtime: TaskRegistry in-memory lifecycle management`
- **Evidence:** `rust/crates/runtime/src/task_registry.rs` is **335 LOC** and provides `create`, `get`, `list`, `stop`, `update`, `output`, `append_output`, `set_status`, and `assign_team` over a thread-safe in-memory registry.
- **Scope:** this lane replaces pure fixed-payload stub state with real runtime-backed task records, but it does not add external subprocess execution by itself.
---
### Lane 5 — Task wiring
## skills/ and CLAUDE.md discovery
- **Status:** merged on `main`.
- **Feature commit:** `e8692e4``feat(tools): wire TaskRegistry into task tool dispatch`
- **Merge commit:** `d994be6``Merge jobdori/task-registry-wiring: real TaskRegistry backing for all 6 task tools`
- **Evidence:** `rust/crates/tools/src/lib.rs` dispatches `TaskCreate`, `TaskGet`, `TaskList`, `TaskStop`, `TaskUpdate`, and `TaskOutput` through `execute_tool()` and concrete `run_task_*` handlers.
- **Current state:** task tools now expose real registry state on `main` via `global_task_registry()`.
### TS exists
Evidence:
- Skill loading/registry pipeline in `src/skills/loadSkillsDir.ts`, `src/skills/bundledSkills.ts`, and `src/skills/mcpSkillBuilders.ts`.
- Bundled skills under `src/skills/bundled/`.
- Skills command surface under `src/commands/skills/`.
### Lane 6 — Team+Cron
### Rust exists
Evidence:
- `Skill` tool in `rust/crates/tools/src/lib.rs` resolves and reads local `SKILL.md` files.
- CLAUDE.md discovery is implemented in `rust/crates/runtime/src/prompt.rs`.
- Rust supports `/memory` and `/init` via `rust/crates/commands/src/lib.rs` and `rust/crates/rusty-claude-cli/src/main.rs`.
- **Status:** merged on `main`.
- **Feature commit:** `c486ca6``feat(runtime+tools): TeamRegistry and CronRegistry — replace team/cron stubs`
- **Merge commit:** `49653fe``Merge jobdori/team-cron-runtime: TeamRegistry + CronRegistry wired into tool dispatch`
- **Evidence:** `rust/crates/runtime/src/team_cron_registry.rs` is **363 LOC** and adds thread-safe `TeamRegistry` and `CronRegistry`; `rust/crates/tools/src/lib.rs` wires `TeamCreate`, `TeamDelete`, `CronCreate`, `CronDelete`, and `CronList` into those registries.
- **Current state:** team/cron tools now have in-memory lifecycle behavior on `main`; they still stop short of a real background scheduler or worker fleet.
### Missing or broken in Rust
- No bundled skill registry equivalent.
- No `/skills` command.
- No MCP skill-builder pipeline.
- No TS-style live skill discovery/reload/change handling.
- No comparable session-memory / team-memory integration around skills.
### Lane 7 — MCP lifecycle
**Status:** basic local skill loading only.
- **Status:** merged on `main`.
- **Feature commit:** `730667f``feat(runtime+tools): McpToolRegistry — MCP lifecycle bridge for tool surface`
- **Merge commit:** `cc0f92e``Merge jobdori/mcp-lifecycle: McpToolRegistry lifecycle bridge for all MCP tools`
- **Evidence:** `rust/crates/runtime/src/mcp_tool_bridge.rs` is **406 LOC** and tracks server connection status, resource listing, resource reads, tool listing, tool dispatch acknowledgements, auth state, and disconnects.
- **Wiring:** `rust/crates/tools/src/lib.rs` routes `ListMcpResources`, `ReadMcpResource`, `McpAuth`, and `MCP` into `global_mcp_registry()` handlers.
- **Scope:** this lane replaces pure stub responses with a registry bridge on `main`; end-to-end MCP connection population and broader transport/runtime depth still depend on the wider MCP runtime (`mcp_stdio.rs`, `mcp_client.rs`, `mcp.rs`).
---
### Lane 8 — LSP client
## cli/
- **Status:** merged on `main`.
- **Feature commit:** `2d66503``feat(runtime+tools): LspRegistry — LSP client dispatch for tool surface`
- **Merge commit:** `d7f0dc6``Merge jobdori/lsp-client: LspRegistry dispatch for all LSP tool actions`
- **Evidence:** `rust/crates/runtime/src/lsp_client.rs` is **438 LOC** and models diagnostics, hover, definition, references, completion, symbols, and formatting across a stateful registry.
- **Wiring:** the exposed `LSP` tool schema in `rust/crates/tools/src/lib.rs` currently enumerates `symbols`, `references`, `diagnostics`, `definition`, and `hover`, then routes requests through `registry.dispatch(action, path, line, character, query)`.
- **Scope:** current parity is registry/dispatch-level; completion/format support exists in the registry model, but not as clearly exposed at the tool schema boundary, and actual external language-server process orchestration remains separate.
### TS exists
Evidence:
- Large command surface under `src/commands/` including `agents`, `hooks`, `mcp`, `memory`, `model`, `permissions`, `plan`, `plugin`, `resume`, `review`, `skills`, `tasks`, and many more.
- Structured/remote transport stack in `src/cli/structuredIO.ts`, `src/cli/remoteIO.ts`, and `src/cli/transports/*`.
- CLI handler split in `src/cli/handlers/*`.
### Lane 9 — Permission enforcement
### Rust exists
Evidence:
- Shared slash command registry in `rust/crates/commands/src/lib.rs`.
- Rust slash commands currently cover `help`, `status`, `compact`, `model`, `permissions`, `clear`, `cost`, `resume`, `config`, `memory`, `init`, `diff`, `version`, `export`, `session`.
- Main CLI/repl/prompt handling lives in `rust/crates/rusty-claude-cli/src/main.rs`.
- **Status:** merged on `main`.
- **Feature commit:** `66283f4``feat(runtime+tools): PermissionEnforcer — permission mode enforcement layer`
- **Merge commit:** `336f820``Merge jobdori/permission-enforcement: PermissionEnforcer with workspace + bash enforcement`
- **Evidence:** `rust/crates/runtime/src/permission_enforcer.rs` is **340 LOC** and adds tool gating, file write boundary checks, and bash read-only heuristics on top of `rust/crates/runtime/src/permissions.rs`.
- **Wiring:** `rust/crates/tools/src/lib.rs` exposes `enforce_permission_check()` and carries per-tool `required_permission` values in tool specs.
### Missing or broken in Rust
- Missing major TS command families: `/agents`, `/hooks`, `/mcp`, `/plugin`, `/skills`, `/plan`, `/review`, `/tasks`, and many others.
- No Rust equivalent to TS structured IO / remote transport layers.
- No TS-style handler decomposition for auth/plugins/MCP/agents.
- JSON prompt mode is improved on this branch, but still not clean transport parity: empirical verification shows tool-capable JSON output can emit human-readable tool-result lines before the final JSON object.
### Permission enforcement across tool paths
**Status:** functional local CLI core, much narrower than TS.
- Harness scenarios validate `write_file_denied`, `bash_permission_prompt_approved`, and `bash_permission_prompt_denied`.
- `PermissionEnforcer::check()` delegates to `PermissionPolicy::authorize()` and returns structured allow/deny results.
- `check_file_write()` enforces workspace boundaries and read-only denial; `check_bash()` denies mutating commands in read-only mode and blocks prompt-mode bash without confirmation.
---
## Tool Surface: 40 exposed tool specs on `main`
## assistant/ (agentic loop, streaming, tool calling)
- `mvp_tool_specs()` in `rust/crates/tools/src/lib.rs` exposes **40** tool specs.
- Core execution is present for `bash`, `read_file`, `write_file`, `edit_file`, `glob_search`, and `grep_search`.
- Existing product tools in `mvp_tool_specs()` include `WebFetch`, `WebSearch`, `TodoWrite`, `Skill`, `Agent`, `ToolSearch`, `NotebookEdit`, `Sleep`, `SendUserMessage`, `Config`, `EnterPlanMode`, `ExitPlanMode`, `StructuredOutput`, `REPL`, and `PowerShell`.
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`.
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
### TS exists
Evidence:
- Assistant/session surface at `src/assistant/sessionHistory.ts`.
- Tool orchestration in `src/services/tools/StreamingToolExecutor.ts`, `src/services/tools/toolExecution.ts`, `src/services/tools/toolOrchestration.ts`.
- Remote/structured streaming layers in `src/cli/structuredIO.ts` and `src/cli/remoteIO.ts`.
### Still limited or intentionally shallow
### Rust exists
Evidence:
- Core loop in `rust/crates/runtime/src/conversation.rs`.
- Stream/tool event translation in `rust/crates/rusty-claude-cli/src/main.rs`.
- Session persistence in `rust/crates/runtime/src/session.rs`.
- `AskUserQuestion` still returns a pending response payload rather than real interactive UI wiring.
- `RemoteTrigger` remains a stub response.
- `TestingPermission` remains test-only.
- Task, team, cron, MCP, and LSP are no longer just fixed-payload stubs in `execute_tool()`, but several remain registry-backed approximations rather than full external-runtime integrations.
- Bash deep validation remains branch-only until `36dac6c` is merged.
### Missing or broken in Rust
- No TS-style hook-aware orchestration layer.
- No TS structured/remote assistant transport stack.
- No richer TS assistant/session-history/background-task integration.
- JSON output path is no longer single-turn only on this branch, but output cleanliness still lags TS transport expectations.
## Reconciled from the older PARITY checklist
**Status:** strong core loop, missing orchestration layers.
- [x] Path traversal prevention (symlink following, `../` escapes)
- [x] Size limits on read/write
- [x] Binary file detection
- [x] Permission mode enforcement (read-only vs workspace-write)
- [x] Config merge precedence (user > project > local) — `ConfigLoader::discover()` loads user → project → local, and `loads_and_merges_claude_code_config_files_by_precedence()` verifies the merge order.
- [x] Plugin install/enable/disable/uninstall flow — `/plugin` slash handling in `rust/crates/commands/src/lib.rs` delegates to `PluginManager::{install, enable, disable, uninstall}` in `rust/crates/plugins/src/lib.rs`.
- [x] No `#[ignore]` tests hiding failures — `grep` over `rust/**/*.rs` found 0 ignored tests.
---
## Still open
## services/ (API client, auth, models, MCP)
- [ ] End-to-end MCP runtime lifecycle beyond the registry bridge now on `main`
- [x] Output truncation (large stdout/file content)
- [ ] Session compaction behavior matching
- [ ] Token counting / cost tracking accuracy
- [x] Bash validation lane merged onto `main`
- [ ] CI green on every commit
### TS exists
Evidence:
- API services under `src/services/api/*`.
- OAuth services under `src/services/oauth/*`.
- MCP services under `src/services/mcp/*`.
- Additional service layers for analytics, prompt suggestion, session memory, plugin operations, settings sync, policy limits, team memory sync, notifier, voice, and more under `src/services/*`.
## Migration Readiness
### Rust exists
Evidence:
- Core Anthropic API client in `rust/crates/api/src/{client,error,sse,types}.rs`.
- OAuth support in `rust/crates/runtime/src/oauth.rs`.
- MCP config/bootstrap/client support in `rust/crates/runtime/src/{config,mcp,mcp_client,mcp_stdio}.rs`.
- Usage accounting in `rust/crates/runtime/src/usage.rs`.
- Remote upstream-proxy support in `rust/crates/runtime/src/remote.rs`.
- [x] `PARITY.md` maintained and honest
- [x] 9 requested lanes documented with commit hashes and current status
- [x] All 9 requested lanes landed on `main` (`bash-validation` is still branch-only)
- [x] No `#[ignore]` tests hiding failures
- [ ] CI green on every commit
- [x] Codebase shape clean enough for handoff documentation
### Missing or broken in Rust
- Most TS service ecosystem beyond core messaging/auth/MCP is absent.
- No TS-equivalent plugin service layer.
- No TS-equivalent analytics/settings-sync/policy-limit/team-memory subsystems.
- No TS-style MCP connection-manager/UI layer.
- Model/provider ergonomics remain thinner than TS.
## Documentation Parity (Extended Dogfood Audit, cycles #410-#427)
**Status:** core foundation exists; broader service ecosystem missing.
Repo documentation suite shipped during extended dogfood audit. Status: present/absent vs standard OSS project expectations.
---
| Document | Status | Cycle | Notes |
|----------|--------|-------|-------|
| LICENSE (MIT) | ✅ Present | #410 | Root license file |
| CONTRIBUTING.md | ✅ Present | #411 | Pinpoint format, build commands, branch naming |
| .github/ISSUE_TEMPLATE/pinpoint.md | ✅ Present | #412 | GitHub-discoverable template |
| SECURITY.md | ✅ Present | #414 | Responsible-disclosure stub |
| README.md contributing nav | ✅ Present | #415 | Links to all docs |
| ROADMAP.md audit summary | ✅ Present | #416 | Extended audit header |
| TROUBLESHOOTING.md | ✅ Present | #418, #423 | 5 failure modes with mitigation |
| docs/SUPPORTED_PROVIDERS.md | ✅ Present | #420 | 4 providers documented |
| ROADMAP.md cluster index | ✅ Present | #421 | 8 named clusters |
| docs/PINPOINT_FILING_GUIDE.md | ✅ Present | #422 | 5-step workflow |
| CHANGELOG.md | ✅ Present | #424, #427 | Keep-a-Changelog format |
| docs/ARCHITECTURE.md | ✅ Present | #426 | 9 crates, request flow, subsystem map |
## Critical bug status in this worktree
### Remaining doc gaps (not yet shipped)
### Fixed
- **Prompt mode tools enabled**
- `rust/crates/rusty-claude-cli/src/main.rs` now constructs prompt mode with `LiveCli::new(model, true, ...)`.
- **Default permission mode = DangerFullAccess**
- Runtime default now resolves to `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/main.rs`.
- Clap default also uses `DangerFullAccess` in `rust/crates/rusty-claude-cli/src/args.rs`.
- Init template writes `dontAsk` in `rust/crates/rusty-claude-cli/src/init.rs`.
- **Streaming `{}` tool-input prefix bug**
- `rust/crates/rusty-claude-cli/src/main.rs` now strips the initial empty object only for streaming tool input, while preserving legitimate `{}` in non-stream responses.
- **Unlimited max_iterations**
- Verified at `rust/crates/runtime/src/conversation.rs` with `usize::MAX`.
| Document | Status | Priority | Notes |
|----------|--------|----------|-------|
| CODE_OF_CONDUCT.md | ✅ Present | Low | Contributor Covenant v2.1 |
| .github/PULL_REQUEST_TEMPLATE.md | ✅ Present | Medium | Standardizes PR descriptions |
| docs/CONFIGURATION.md | ✅ Present | High | env vars, settings.json, provider config — relates to #283, #285 |
| docs/API_REFERENCE.md | ✅ Present | Medium | JSON envelope schema, output format contract — #288, #266, #168c |
| .github/ISSUE_TEMPLATE/bug_report.md | ✅ Present | #431 | Standard bug template with repro steps, environment, context sections |
### Remaining notable parity issue
- **JSON prompt output cleanliness**
- Tool-capable JSON mode now loops, but empirical verification still shows pre-JSON human-readable tool-result output when tools fire.

View File

@@ -1,192 +0,0 @@
# Phase 1 Kickoff — Classifier Sweeps + Doc-Truth + Design Decisions
**Status:** Ready for execution once Phase 0 (`feat/jobdori-168c-emission-routing`) merges.
**Date prepared:** 2026-04-23 11:47 Seoul (cycles #104#108 complete, all unaudited surfaces probed)
---
## What Got Done (Phase 0)
- ✅ JSON output shape routing (no-silent test, SCHEMAS baseline, parity guard)
- ✅ 7 dogfood filings (#155, #169, #170, #171, #172, #153, checkpoint)
- ✅ 9 probe cycles (plugins, agents, init, bootstrap-plan, system-prompt, export, sandbox, dump-manifests, skills)
- ✅ 82 pinpoints filed, 67 genuinely open
- ✅ 227/227 tests pass, 0 regressions
- ✅ Review guide + priority queue locked
- ✅ Doctrine: 28 principles accumulated
---
## What Phase 1 Will Do (Confirmed via Gaebal-Gajae)
Execute priority-ordered fixes in 6 bundles + independents:
### Priority 1: Error Envelope Contract Drift
**Bundle:** `feat/jobdori-181-error-envelope-contract-drift` (#181 + #183)
**What it fixes:**
- #181: `plugins bogus-subcommand` returns success-shaped envelope (no `type: "error"`, error buried in message)
- #183: `plugins` and `mcp` emit different shapes on unknown subcommand
**Why it's Priority 1:** Foundation layer. Error envelope is the root contract. All downstream fixes assume correct envelope shape.
**Implementation:** Align `plugins` unknown-subcommand handler to `agents` canonical reference. Ensure both emit `type: "error"` + correct `kind`.
**Risk profile:** HIGH (touches error routing, breaks if consumers depend on old shape) → but gated by Phase 0 freeze + comprehensive tests
---
### Priority 2: CLI Contract Hygiene Sweep
**Bundle:** `feat/jobdori-184-cli-contract-hygiene-sweep` (#184 + #185)
**What it fixes:**
- #184: `claw init` silently accepts unknown positional arguments (should reject)
- #185: `claw bootstrap-plan` silently accepts unknown flags (should reject)
**Why it's Priority 2:** Extensions. Guard clauses on existing envelope shape. Uses envelope from Priority 1.
**Implementation:** Add trailing-args rejection to `init` and unknown-flag rejection to `bootstrap-plan`. Pattern: match existing guard in #171 (extra-args classifier).
**Risk profile:** MEDIUM (adds guards, no shape changes)
---
### Priority 3: Classifier Sweep (4 Verbs)
**Bundle:** `feat/jobdori-186-192-classifier-sweep` (#186 + #187 + #189 + #192)
**What it fixes:**
- #186: `system-prompt --<unknown>` classified as `unknown` → should be `cli_parse`
- #187: `export --<unknown>` classified as `unknown` → should be `cli_parse`
- #189: `dump-manifests --<unknown>` classified as `unknown` → should be `cli_parse`
- #192: `skills install --<unknown>` classified as `unknown` → should be `cli_parse`
**Why it's Priority 3:** Cleanup. Classifier additions, same envelope, one unified pattern across 4 verbs.
**Implementation:** Add 4 classifier branches (one per verb) to the unknown-option handler. Same test pattern for all.
**Risk profile:** LOW (classifier-only, no routing changes)
---
### Priority 4: USAGE.md Standalone Surface Audit
**Bundle:** `feat/jobdori-180-usage-standalone-surface` (#180)
**What it fixes:**
- #180: USAGE.md incomplete verb coverage (doc-truthfulness audit-flow)
**Why it's Priority 4:** Doc audit. Prerequisite for #188 (help-text gaps).
**Implementation:** Audit USAGE.md against all verbs (compare against `claw --help` verb list). Add missing verb documentation.
**Risk profile:** LOW (docs-only)
---
### Priority 5: Dump-Manifests Help-Text Fix
**Bundle:** `feat/jobdori-188-dump-manifests-help-prerequisite` (#188)
**What it fixes:**
- #188: `dump-manifests --help` omits prerequisite (env var or flag required)
**Why it's Priority 5:** Doc-truth probe-flow. Comes after audit-flow (#180).
**Implementation:** Update help text to show required alternatives and environment variable.
**Risk profile:** LOW (help-text only)
---
### Priority 6+: Independent Fixes
- #190: Design decision (help-routing for no-args install) — needs architecture review
- #191: `skills install` filesystem classifier gap — can bundle with #177/#178/#179 or standalone
- #182: Plugin classifier alignment (unknown → filesystem/runtime) — depends on #181 resolution
- #177/#178/#179: Install-surface taxonomy (possible 4-verb bundle)
- #173: Config hint field (consumer-parity)
- #174: Resume trailing classifier (closed? verify)
- #175: CI fmt/test decoupling (gaebal-gajae owned)
---
## Concrete Next Steps (Once Phase 0 Merges)
1. **Create branch 1:** `feat/jobdori-181-error-envelope-contract-drift`
- Files: error router, tests for #181 + #183
- PR against main
- Expected: 2 commits, 5 new tests, 0 regressions
2. **Create branch 2:** `feat/jobdori-184-cli-contract-hygiene-sweep`
- Files: init guard, bootstrap-plan guard
- PR against main
- Expected: 2 commits, 3 new tests
3. **Create branch 3:** `feat/jobdori-186-192-classifier-sweep`
- Files: unknown-option handler (4 verbs)
- PR against main
- Expected: 1 commit, 4 new tests
4. **Create branch 4:** `feat/jobdori-180-usage-standalone-surface`
- Files: USAGE.md additions
- PR against main
- Expected: 1 commit, 0 tests
5. **Create branch 5:** `feat/jobdori-188-dump-manifests-help-prerequisite`
- Files: help text update (string change)
- PR against main
- Expected: 1 commit, 0 tests
6. **Triage independents:** #190 requires architecture discussion; others can follow once above merges.
---
## Hypothesis Validation (Codified for Future Probes)
**Multi-flag verbs (install, enable, init, bootstrap-plan, system-prompt, export, dump-manifests):** 34 classifier gaps each.
**Single-issue verbs (list, show, sandbox, agents):** 01 gaps.
**Future probe strategy:** Prioritize multi-flag verbs; single-issue verbs are mostly clean.
---
## Doctrine Points Relevant to Phase 1 Execution
- **Doctrine #22:** Schema baseline check before enum proposal
- **Doctrine #25:** Contract-surface-first ordering (foundation → extensions → cleanup)
- **Doctrine #27:** Same-pattern pinpoints should bundle into one classifier sweep PR
- **Doctrine #28:** First observation is hypothesis, not filing (verify before classifying)
---
## Known Blockers & Risks
1. **Phase 0 merge gating:** Can't create Phase 1 branches until Phase 0 lands (28 base + 37 new = 65 total pending)
2. **#190 design decision:** help-routing behavior needs architectural consensus (intentional vs inconsistency)
3. **Cross-family dependencies:** #182 depends on #181 (plugin error envelope must be correct first)
---
## Testing Strategy for Phase 1
- **Priority 13 bundles:** Existing test framework (`output_format_contract.rs`, classifier tests). Comprehensive coverage per bundle.
- **Priority 45 bundles:** Light doc verification (grep USAGE.md, spot-check help text).
- **Independent fixes:** Case-by-case once prioritized.
---
## Success Criteria
- ✅ All Priority 15 bundles merge to main
- ✅ 0 regressions (227+ tests pass across all merges)
- ✅ CI green on all PRs
- ✅ Reviewer sign-offs on all bundles
---
**Phase 1 is ready to execute. Awaiting Phase 0 merge approval.**

View File

@@ -1,114 +0,0 @@
# Claw Code Philosophy
## Stop Staring at the Files
If you only look at the generated files in this repository, you are looking at the wrong layer.
The Python rewrite was a byproduct. The Rust rewrite was also a byproduct. The real thing worth studying is the **system that produced them**: a clawhip-based coordination loop where humans give direction and autonomous claws execute the work.
Claw Code is not just a codebase. It is a public demonstration of what happens when:
- a human provides clear direction,
- multiple coding agents coordinate in parallel,
- notification routing is pushed out of the agent context window,
- planning, execution, review, and retry loops are automated,
- and the human does **not** sit in a terminal micromanaging every step.
## The Human Interface Is Discord
The important interface here is not tmux, Vim, SSH, or a terminal multiplexer.
The real human interface is a Discord channel.
A person can type a sentence from a phone, walk away, sleep, or do something else. The claws read the directive, break it into tasks, assign roles, write code, run tests, argue over failures, recover, and push when the work passes.
That is the philosophy: **humans set direction; claws perform the labor.**
## The Three-Part System
### 1. OmX (`oh-my-codex`)
[oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex) provides the workflow layer.
It turns short directives into structured execution:
- planning keywords
- execution modes
- persistent verification loops
- parallel multi-agent workflows
This is the layer that converts a sentence into a repeatable work protocol.
### 2. clawhip
[clawhip](https://github.com/Yeachan-Heo/clawhip) is the event and notification router.
It watches:
- git commits
- tmux sessions
- GitHub issues and PRs
- agent lifecycle events
- channel delivery
Its job is to keep monitoring and delivery **outside** the coding agent's context window so the agents can stay focused on implementation instead of status formatting and notification routing.
### 3. OmO (`oh-my-openagent`)
[oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent) handles multi-agent coordination.
This is where planning, handoffs, disagreement resolution, and verification loops happen across agents.
When Architect, Executor, and Reviewer disagree, OmO provides the structure for that loop to converge instead of collapse.
## The Real Bottleneck Changed
The bottleneck is no longer typing speed.
When agent systems can rebuild a codebase in hours, the scarce resource becomes:
- architectural clarity
- task decomposition
- judgment
- taste
- conviction about what is worth building
- knowing which parts can be parallelized and which parts must stay constrained
A fast agent team does not remove the need for thinking. It makes clear thinking even more valuable.
## What Claw Code Demonstrates
Claw Code demonstrates that a repository can be:
- **autonomously built in public**
- coordinated by claws/lobsters rather than human pair-programming alone
- operated through a chat interface
- continuously improved by structured planning/execution/review loops
- maintained as a showcase of the coordination layer, not just the output files
The code is evidence.
The coordination system is the product lesson.
## What Still Matters
As coding intelligence gets cheaper and more available, the durable differentiators are not raw coding output.
What still matters:
- product taste
- direction
- system design
- human trust
- operational stability
- judgment about what to build next
In that world, the job of the human is not to out-type the machine.
The job of the human is to decide what deserves to exist.
## Short Version
**Claw Code is a demo of autonomous software development.**
Humans provide direction.
Claws coordinate, build, test, recover, and push.
The repository is the artifact.
The philosophy is the system behind it.
## Related explanation
For the longer public explanation behind this philosophy, see:
- https://x.com/realsigridjin/status/2039472968624185713

321
README.md
View File

@@ -1,228 +1,191 @@
# Claw Code
# Rewriting Project Claw Code
<p align="center">
<a href="https://github.com/ultraworkers/claw-code">ultraworkers/claw-code</a>
·
<a href="./USAGE.md">Usage</a>
·
<a href="./ERROR_HANDLING.md">Error Handling</a>
·
<a href="./rust/README.md">Rust workspace</a>
·
<a href="./PARITY.md">Parity</a>
·
<a href="./ROADMAP.md">Roadmap</a>
·
<a href="./TROUBLESHOOTING.md">Troubleshooting</a>
·
<a href="https://discord.gg/5TUQKqFWd">UltraWorkers Discord</a>
<strong>⭐ The fastest repo in history to surpass 50K stars, reaching the milestone in just 2 hours after publication ⭐</strong>
</p>
<p align="center">
<a href="https://star-history.com/#ultraworkers/claw-code&Date">
<a href="https://star-history.com/#instructkr/claw-code&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date" />
<img alt="Star history for ultraworkers/claw-code" src="https://api.star-history.com/svg?repos=ultraworkers/claw-code&type=Date" width="600" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" width="600" />
</picture>
</a>
</p>
<p align="center">
<img src="assets/claw-hero.jpeg" alt="Claw Code" width="300" />
<img src="assets/clawd-hero.jpeg" alt="Claw" width="300" />
</p>
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**.
<p align="center">
<strong>Better Harness Tools, not merely storing the archive of leaked Claude Code</strong>
</p>
<p align="center">
<a href="https://github.com/sponsors/instructkr"><img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github&style=for-the-badge" alt="Sponsor on GitHub" /></a>
</p>
> [!IMPORTANT]
> 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, see [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) for a high-level crate/subsystem map, see [`docs/CONFIGURATION.md`](./docs/CONFIGURATION.md) for env vars and settings, and see [`docs/container.md`](./docs/container.md) for the container-first workflow.
> **Rust port is now in progress** on the [`dev/rust`](https://github.com/instructkr/claw-code/tree/dev/rust) branch and is expected to be merged into main today. The Rust implementation aims to deliver a faster, memory-safe harness runtime. Stay tuned — this will be the definitive version of the project.
> If you find this work useful, consider [sponsoring @instructkr on GitHub](https://github.com/sponsors/instructkr) to support continued open-source harness engineering research.
---
## Backstory
At 4 AM on March 31, 2026, I woke up to my phone blowing up with notifications. The Claude Code source had been exposed, and the entire dev community was in a frenzy. My girlfriend in Korea was genuinely worried I might face legal action from Anthropic just for having the code on my machine — so I did what any engineer would do under pressure: I sat down, ported the core features to Python from scratch, and pushed it before the sun came up.
The whole thing was orchestrated end-to-end using [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) by [@bellman_ych](https://x.com/bellman_ych) — a workflow layer built on top of OpenAI's Codex ([@OpenAIDevs](https://x.com/OpenAIDevs)). I used `$team` mode for parallel code review and `$ralph` mode for persistent execution loops with architect-level verification. The entire porting session — from reading the original harness structure to producing a working Python tree with tests — was driven through OmX orchestration.
The result is a clean-room Python rewrite that captures the architectural patterns of Claude Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.**
https://github.com/instructkr/claw-code
![Tweet screenshot](assets/tweet-screenshot.png)
## The Creators Featured in Wall Street Journal For Avid Claude Code Fans
I've been deeply interested in **harness engineering** — studying how agent systems wire tools, orchestrate tasks, and manage runtime context. This isn't a sudden thing. The Wall Street Journal featured my work earlier this month, documenting how I've been one of the most active power users exploring these systems:
> AI startup worker Sigrid Jin, who attended the Seoul dinner, single-handedly used 25 billion of Claude Code tokens last year. At the time, usage limits were looser, allowing early enthusiasts to reach tens of billions of tokens at a very low cost.
>
> **ACP / Zed status:** `claw-code` does not ship an ACP/Zed daemon entrypoint yet. Run `claw acp` (or `claw --acp`) for the current status instead of guessing from source layout; `claw acp serve` is currently a discoverability alias only, and real ACP support remains tracked separately in `ROADMAP.md`.
> Despite his countless hours with Claude Code, Jin isn't faithful to any one AI lab. The tools available have different strengths and weaknesses, he said. Codex is better at reasoning, while Claude Code generates cleaner, more shareable code.
>
> Jin flew to San Francisco in February for Claude Code's first birthday party, where attendees waited in line to compare notes with Cherny. The crowd included a practicing cardiologist from Belgium who had built an app to help patients navigate care, and a California lawyer who made a tool for automating building permit approvals using Claude Code.
>
> "It was basically like a sharing party," Jin said. "There were lawyers, there were doctors, there were dentists. They did not have software engineering backgrounds."
>
> — *The Wall Street Journal*, March 21, 2026, [*"The Trillion Dollar Race to Automate Our Entire Lives"*](https://lnkd.in/gs9td3qd)
## Current repository shape
![WSJ Feature](assets/wsj-feature.png)
- **`rust/`** — canonical Rust workspace and the `claw` CLI binary
- **`USAGE.md`** — task-oriented usage guide for the current product surface
- **`ERROR_HANDLING.md`** — unified error-handling pattern for orchestration code
- **`PARITY.md`** — Rust-port parity status and migration notes
- **`ROADMAP.md`** — active roadmap and cleanup backlog
- **`PHILOSOPHY.md`** — project intent and system-design framing
- **`SCHEMAS.md`** — JSON protocol contract (Python harness reference)
- **`src/` + `tests/`** — companion Python/reference workspace and audit helpers; not the primary runtime surface
---
## Quick start
## Porting Status
> [!NOTE]
> [!WARNING]
> **`cargo install claw-code` installs the wrong thing.** The `claw-code` crate on crates.io is a deprecated stub that places `claw-code-deprecated.exe` — not `claw`. Running it only prints `"claw-code has been renamed to agent-code"`. **Do not use `cargo install claw-code`.** Either build from source (this repo) or install the upstream binary:
> ```bash
> cargo install agent-code # upstream binary — installs 'agent.exe' (Windows) / 'agent' (Unix), NOT 'agent-code'
> ```
> This repo (`ultraworkers/claw-code`) is **build-from-source only** — follow the steps below.
The main source tree is now Python-first.
```bash
# 1. Clone and build
git clone https://github.com/ultraworkers/claw-code
cd claw-code/rust
cargo build --workspace
- `src/` contains the active Python porting workspace
- `tests/` verifies the current Python workspace
- the exposed snapshot is no longer part of the tracked repository state
# 2. Set your API key (Anthropic API key — not a Claude subscription)
export ANTHROPIC_API_KEY="sk-ant-..."
The current Python workspace is not yet a complete one-to-one replacement for the original system, but the primary implementation surface is now Python.
# 3. Verify everything is wired correctly
./target/debug/claw doctor
## Why this rewrite exists
# 4. Run a prompt
./target/debug/claw prompt "say hello"
I originally studied the exposed codebase to understand its harness, tool wiring, and agent workflow. After spending more time with the legal and ethical questions—and after reading the essay linked below—I did not want the exposed snapshot itself to remain the main tracked source tree.
This repository now focuses on Python porting work instead.
## Repository Layout
```text
.
├── src/ # Python porting workspace
│ ├── __init__.py
│ ├── commands.py
│ ├── main.py
│ ├── models.py
│ ├── port_manifest.py
│ ├── query_engine.py
│ ├── task.py
│ └── tools.py
├── tests/ # Python verification
├── assets/omx/ # OmX workflow screenshots
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
└── README.md
```
> [!NOTE]
> **Windows (PowerShell):** the binary is `claw.exe`, not `claw`. Use `.\target\debug\claw.exe` or run `cargo run -- prompt "say hello"` to skip the path lookup.
## Python Workspace Overview
### Windows setup
The new Python `src/` tree currently provides:
**PowerShell is a supported Windows path.** Use whichever shell works for you. The common onboarding issues on Windows are:
- **`port_manifest.py`** — summarizes the current Python workspace structure
- **`models.py`** — dataclasses for subsystems, modules, and backlog state
- **`commands.py`** — Python-side command port metadata
- **`tools.py`** — Python-side tool port metadata
- **`query_engine.py`** — renders a Python porting summary from the active workspace
- **`main.py`** — a CLI entrypoint for manifest and summary output
1. **Install Rust first** — download from <https://rustup.rs/> and run the installer. Close and reopen your terminal when it finishes.
2. **Verify Rust is on PATH:**
```powershell
cargo --version
```
If this fails, reopen your terminal or run the PATH setup from the Rust installer output, then retry.
3. **Clone and build** (works in PowerShell, Git Bash, or WSL):
```powershell
git clone https://github.com/ultraworkers/claw-code
cd claw-code/rust
cargo build --workspace
```
4. **Run** (PowerShell — note `.exe` and backslash):
```powershell
$env:ANTHROPIC_API_KEY = "sk-ant-..."
.\target\debug\claw.exe prompt "say hello"
```
## Quickstart
**Git Bash / WSL** are optional alternatives, not requirements. If you prefer bash-style paths (`/c/Users/you/...` instead of `C:\Users\you\...`), Git Bash (ships with Git for Windows) works well. In Git Bash, the `MINGW64` prompt is expected and normal — not a broken install.
## Post-build: locate the binary and verify
After running `cargo build --workspace`, the `claw` binary is built but **not** automatically installed to your system. Here's where to find it and how to verify the build succeeded.
### Binary location
After `cargo build --workspace` in `claw-code/rust/`:
**Debug build (default, faster compile):**
- **macOS/Linux:** `rust/target/debug/claw`
- **Windows:** `rust/target/debug/claw.exe`
**Release build (optimized, slower compile):**
- **macOS/Linux:** `rust/target/release/claw`
- **Windows:** `rust/target/release/claw.exe`
If you ran `cargo build` without `--release`, the binary is in the `debug/` folder.
### Verify the build succeeded
Test the binary directly using its path:
Render the Python porting summary:
```bash
# macOS/Linux (debug build)
./rust/target/debug/claw --help
./rust/target/debug/claw doctor
# Windows PowerShell (debug build)
.\rust\target\debug\claw.exe --help
.\rust\target\debug\claw.exe doctor
python3 -m src.main summary
```
If these commands succeed, the build is working. `claw doctor` is your first health check — it validates your API key, model access, and tool configuration.
### Optional: Add to PATH
If you want to run `claw` from any directory without the full path, choose one of these approaches:
**Option 1: Symlink (macOS/Linux)**
```bash
ln -s $(pwd)/rust/target/debug/claw /usr/local/bin/claw
```
Then reload your shell and test:
```bash
claw --help
```
**Option 2: Use `cargo install` (all platforms)**
Build and install to Cargo's default location (`~/.cargo/bin/`, which is usually on PATH):
```bash
# From the claw-code/rust/ directory
cargo install --path . --force
# Then from anywhere
claw --help
```
**Option 3: Update shell profile (bash/zsh)**
Add this line to `~/.bashrc` or `~/.zshrc`:
```bash
export PATH="$(pwd)/rust/target/debug:$PATH"
```
Reload your shell:
```bash
source ~/.bashrc # or source ~/.zshrc
claw --help
```
### Troubleshooting
- **"command not found: claw"** — The binary is in `rust/target/debug/claw`, but it's not on your PATH. Use the full path `./rust/target/debug/claw` or symlink/install as above.
- **"permission denied"** — On macOS/Linux, you may need `chmod +x rust/target/debug/claw` if the executable bit isn't set (rare).
- **Debug vs. release** — If the build is slow, you're in debug mode (default). Add `--release` to `cargo build` for faster runtime, but the build itself will take 510 minutes.
> [!NOTE]
> **Auth:** claw requires an **API key** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) — Claude subscription login is not a supported auth path.
Run the workspace test suite after verifying the binary works:
Print the current Python workspace manifest:
```bash
cd rust
cargo test --workspace
python3 -m src.main manifest
```
## Documentation map
List the current Python modules:
- [`USAGE.md`](./USAGE.md) — quick commands, auth, sessions, config, parity harness
- [`rust/README.md`](./rust/README.md) — crate map, CLI surface, features, workspace layout
- [`PARITY.md`](./PARITY.md) — parity status for the Rust port
- [`rust/MOCK_PARITY_HARNESS.md`](./rust/MOCK_PARITY_HARNESS.md) — deterministic mock-service harness details
- [`ROADMAP.md`](./ROADMAP.md) — active roadmap and open cleanup work
- [`CHANGELOG.md`](./CHANGELOG.md) — history of notable changes by dogfood cycle
- [`PHILOSOPHY.md`](./PHILOSOPHY.md) — why the project exists and how it is operated
```bash
python3 -m src.main subsystems --limit 16
```
## Ecosystem
Run verification:
Claw Code is built in the open alongside the broader UltraWorkers toolchain:
```bash
python3 -m unittest discover -s tests -v
```
- [clawhip](https://github.com/Yeachan-Heo/clawhip)
- [oh-my-openagent](https://github.com/code-yeongyu/oh-my-openagent)
- [oh-my-claudecode](https://github.com/Yeachan-Heo/oh-my-claudecode)
- [oh-my-codex](https://github.com/Yeachan-Heo/oh-my-codex)
- [UltraWorkers Discord](https://discord.gg/5TUQKqFWd)
Run the parity audit against the local ignored archive (when present):
## Contributing
```bash
python3 -m src.main parity-audit
```
We welcome contributions! Before filing an issue or pull request:
Inspect mirrored command/tool inventories:
- **Troubleshooting:** See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for common issues and recovery steps
- **Supported providers:** See [docs/SUPPORTED_PROVIDERS.md](./docs/SUPPORTED_PROVIDERS.md)
- **For security issues:** See [SECURITY.md](./SECURITY.md)
- **For bug reports / features:** Check [ROADMAP.md](./ROADMAP.md) to see if it's already pinpointed
- **How to file a pinpoint:** See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Pinpoint Filing Guide](./docs/PINPOINT_FILING_GUIDE.md)
- **Issue templates:** Use [.github/ISSUE_TEMPLATE/pinpoint.md](./.github/ISSUE_TEMPLATE/pinpoint.md)
```bash
python3 -m src.main commands --limit 10
python3 -m src.main tools --limit 10
```
## Ownership / affiliation disclaimer
## Current Parity Checkpoint
The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source.
## Built with `oh-my-codex`
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
- **`$team` mode:** used for coordinated parallel review and architectural feedback
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
### OmX workflow screenshots
![OmX workflow screenshot 1](assets/omx/omx-readme-review-1.png)
*Ralph/team orchestration view while the README and essay context were being reviewed in terminal panes.*
![OmX workflow screenshot 2](assets/omx/omx-readme-review-2.png)
*Split-pane review and verification flow during the final README wording pass.*
## Community
<p align="center">
<a href="https://instruct.kr/"><img src="assets/instructkr.png" alt="instructkr" width="400" /></a>
</p>
Join the [**instructkr Discord**](https://instruct.kr/) — the best Korean language model community. Come chat about LLMs, harness engineering, agent workflows, and everything in between.
[![Discord](https://img.shields.io/badge/Join%20Discord-instruct.kr-5865F2?logo=discord&style=for-the-badge)](https://instruct.kr/)
## Star History
See the chart at the top of this README.
## Ownership / Affiliation Disclaimer
- This repository does **not** claim ownership of the original Claude Code source material.
- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.

View File

@@ -1,191 +0,0 @@
# Review Dashboard — claw-code
**Last updated:** 2026-04-23 03:34 Seoul
**Queue state:** 14 review-ready branches
**Main HEAD:** `f18f45c` (ROADMAP #161 filed)
This is an integration support artifact (per cycle #64 doctrine). Its purpose: let reviewers see all queued branches, cluster membership, and merge priorities without re-deriving from git log.
---
## At-A-Glance
| Priority | Cluster | Branches | Complexity | Status |
|---|---|---|---|---|
| P0 | Typed-error threading | #248, #249, #251 | SM | Merge-ready |
| P1 | Diagnostic-strictness | #122, #122b | S | Merge-ready |
| P1 | Help-parity | #130b-#130e | S each | Merge-ready (batch) |
| P2 | Suffix-guard | #152-init, #152-bootstrap-plan | XS each | Merge-ready (batch) |
| P2 | Verb-classification | #160 | S | Merge-ready (just shipped) |
| P3 | Doc truthfulness | docs/parity-update | XS | Merge-ready |
**Suggested merge order:** P0 → P1 → P2 → P3. Within P0, start with #249 (smallest diff).
---
## Detailed Branch Inventory
### P0: Typed-Error Threading (3 branches)
#### `feat/jobdori-249-resumed-slash-kind` — **SMALLEST. START HERE.**
- **Commit:** `eb4b1eb`
- **Diff:** 61 lines in `rust/crates/rusty-claude-cli/src/main.rs`
- **Scope:** Two Err arms in `resume_session()` at lines 2745, 2782 now emit `kind` + `hint`
- **Cluster:** Completes #247 parent's typed-error family
- **Tests:** 181 binary tests pass (no regressions)
- **Reviewer checklist:** see `/tmp/pr-summary-249.md`
- **Expected merge time:** ~5 minutes
#### `feat/jobdori-248-unknown-verb-option-classify`
- **Commit:** `6c09172`
- **Scope:** Unknown verb + option classifier family
- **Cluster:** #247 parent's typed-error family (sibling of #249)
#### `feat/jobdori-251-session-dispatch`
- **Commit:** `dc274a0`
- **Scope:** Intercepts session-management verbs (`list-sessions`, `load-session`, `delete-session`, `flush-transcript`) at top-level parser
- **Cluster:** #247 parent's typed-error family
- **Note:** Larger change than #248/#249 — prefer merging those first
### P1: Diagnostic-Strictness (2 branches)
#### `feat/jobdori-122-doctor-stale-base`
- **Commit:** `5bb9eba`
- **Scope:** `claw doctor` now warns on stale-base (same check as prompt preflight)
- **Cluster:** Diagnostic surfaces reflect runtime reality (cycle #57 principle)
#### `feat/jobdori-122b-doctor-broad-cwd`
- **Commit:** `0aa0d3f`
- **Scope:** `claw doctor` now warns when cwd is broad path (home/root)
- **Cluster:** Same as #122 (direct sibling)
- **Batch suggestion:** Review together with #122
### P1: Help-Parity (4 branches, batch-reviewable)
All four implement uniform `--help` flag handling. Related by fix locus (help-topic routing).
#### `feat/jobdori-130b-filesystem-context`
- **Commit:** `d49a75c`
- **Scope:** Filesystem I/O errors enriched with operation + path context
#### `feat/jobdori-130c-diff-help`
- **Commit:** `83f744a`
- **Scope:** `claw diff --help` routes to help topic
#### `feat/jobdori-130d-config-help`
- **Commit:** `19638a0`
- **Scope:** `claw config --help` routes to help topic
#### `feat/jobdori-130e-dispatch-help` + `feat/jobdori-130e-surface-help`
- **Commits:** `0ca0344`, `9dd7e79`
- **Scope:** Category A (dispatch-order) + Category B (surface) help-anomaly fixes from systematic sweep
- **Batch suggestion:** Review #130c, #130d, #130e-dispatch, #130e-surface as one unit — all use same pattern (add help flag guard before action)
### P2: Suffix-Guard (2 branches, batch-reviewable)
#### `feat/jobdori-152-init-suffix-guard`
- **Commit:** `860f285`
- **Scope:** `claw init` rejects trailing args
- **Cluster:** Uniform no-arg verb suffix guards
#### `feat/jobdori-152-bootstrap-plan-suffix-guard`
- **Commit:** `3a533ce`
- **Scope:** `claw bootstrap-plan` rejects trailing args
- **Cluster:** Same as above (direct sibling)
- **Batch suggestion:** Review together
### P2: Verb-Classification (1 branch, just shipped cycle #63)
#### `feat/jobdori-160-verb-classification`
- **Commit:** `5538934`
- **Scope:** Reserved-semantic verbs (resume, compact, memory, commit, pr, issue, bughunter) with positional args now emit slash-command guidance
- **Cluster:** Sibling of #251 (dispatch leak family), applied to promptable/reserved split
- **Design closure note:** Investigation in cycle #61 revealed verb-classification was the actual need; cycle #63 implemented the class table
### P3: Doc Truthfulness (1 branch, just shipped cycle #64)
#### `docs/parity-update-2026-04-23`
- **Commit:** `92a79b5`
- **Scope:** PARITY.md stats refreshed (Rust LOC +66%, Test LOC +76%, Commits +235% since 2026-04-03)
- **Risk:** Near-zero (4-line diff, doc-only)
- **Merge time:** ~1 minute
---
## Batch Review Patterns
For reviewer efficiency, these groups share the same fix-locus or pattern:
| Batch | Branches | Shared pattern |
|---|---|---|
| Help-parity bundle | #130c, #130d, #130e-dispatch, #130e-surface | All add help-flag guard before action in dispatch |
| Suffix-guard bundle | #152-init, #152-bootstrap-plan | Both add `rest.len() > 1` check to no-arg verbs |
| Diagnostic-strictness bundle | #122, #122b | Both extend `check_workspace_health()` with new preflights |
| Typed-error bundle | #248, #249, #251 | All thread `classify_error_kind` + `split_error_hint` into specific Err arms |
If reviewer has limited time, batch review saves context switches.
---
## Review Friction Map
**Lowest friction (safe start):**
- docs/parity-update (4 lines, doc-only)
- #249 (61 lines, 2 Err arms, 181 tests pass)
- #160 (23 lines, new helper + pre-check)
**Medium friction:**
- #122, #122b (each ~100 lines, diagnostic extensions)
- #248 (classifier family)
- #152-* branches (XS each)
**Highest friction:**
- #251 (broader parser changes, multi-verb coverage)
- #130e bundle (help-parity systematic sweep)
---
## Open Pinpoints Awaiting Implementation
| # | Title | Priority | Est. diff | Notes |
|---|---|---|---|---|
| #157 | Auth remediation registry | S-M | 50-80 lines | Cycle #59 audit pre-fill |
| #158 | Hook validation at worker boot | S | 30-50 lines | Cycle #59 audit pre-fill |
| #159 | Plugin manifest validation at worker boot | S | 30-50 lines | Cycle #59 audit pre-fill |
| #161 | Stale Git SHA in worktree builds | S | ~15 lines in build.rs | Cycle #65 just filed |
None of these should be implemented while current queue is 14. Prioritize merging queue first.
---
## Merge Throughput Notes
**Target throughput:** 2-3 branches per review session. At current cycle velocity (cycles #39#65 = 27 cycles in ~3 hours), 2-3 merges unblock:
- 3+ cluster closures (typed-error, diagnostic-strictness, help-parity)
- 1 doctrine loop closure (verb-classification → #160)
- 1 doc freshness (PARITY.md)
**Post-merge expected state:** ~10 branches remaining, queue shifts from saturated (14) to manageable (10), velocity cycles can resume in safe zone.
---
## For The Reviewer
**Reviewing checklist (per-branch):**
- [ ] Diff matches pinpoint description
- [ ] Tests pass (cite count: should be 181+ for branches that touched main.rs)
- [ ] Backward compatibility verified (check-list in commit message)
- [ ] No related cluster branches yet to land (check cluster column above)
**Reviewer shortcut for #249** (recommended first-merge):
```bash
cd /tmp/jobdori-249
git log --oneline -1 # eb4b1eb
git diff main..HEAD -- rust/crates/rusty-claude-cli/src/main.rs | head -50
```
Or skip straight to: `/tmp/pr-summary-249.md` (pre-prepared PR-ready artifact).
---
**Dashboard source:** Cycle #66 (2026-04-23 03:34 Seoul). Updates should be re-run when branches merge or new pinpoints land.

17547
ROADMAP.md

File diff suppressed because one or more lines are too long

View File

@@ -1,708 +0,0 @@
# JSON Envelope Schemas — Clawable CLI Contract
> **⚠️ CRITICAL: This document describes the TARGET v2.0 envelope schema, not the current v1.0 binary behavior.** The Rust binary currently emits a **flat v1.0 envelope** that does NOT include `timestamp`, `command`, `exit_code`, `output_format`, or `schema_version` fields. See [`FIX_LOCUS_164.md`](./FIX_LOCUS_164.md) for the full migration plan and timeline. **Do not build automation against the field shapes below without first testing against the actual binary output.** Use `claw <command> --output-format json` to inspect what your binary version actually emits.
This document locks the **target** field-level contract for all clawable-surface commands. After the v1.0→v2.0 migration (FIX_LOCUS_164 Phase 2), every command accepting `--output-format json` will conform to the envelope shapes documented here.
**Target audience:** Claws planning v2.0 migration, reference implementers, contract validators.
**Current v1.0 reality:** See [`ERROR_HANDLING.md`](./ERROR_HANDLING.md) Appendix A for the flat envelope shape the binary actually emits today.
---
## Common Fields (All Envelopes) — TARGET v2.0 SCHEMA
**This section describes the v2.0 target schema. The current v1.0 binary does NOT emit these fields.** See FIX_LOCUS_164.md for the migration timeline.
After v2.0 migration, every command response, success or error, will carry:
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "list-sessions",
"exit_code": 0,
"output_format": "json",
"schema_version": "2.0"
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `timestamp` | ISO 8601 UTC | Yes | Time command completed |
| `command` | string | Yes | argv[1] (e.g. "list-sessions") |
| `exit_code` | int (0/1/2) | Yes | 0=success, 1=error/not-found, 2=timeout |
| `output_format` | string | Yes | Always "json" (for symmetry with text mode) |
| `schema_version` | string | Yes | "1.0" (bump for breaking changes) |
---
## Turn Result Fields (Multi-Turn Sessions)
When a command's response includes a `turn` object (e.g., in `bootstrap` or `turn-loop`), it carries:
| Field | Type | Required | Notes |
|---|---|---|---|
| `prompt` | string | Yes | User input for this turn |
| `output` | string | Yes | Assistant response |
| `stop_reason` | enum | Yes | One of: `completed`, `timeout`, `cancelled`, `max_budget_reached`, `max_turns_reached` |
| `cancel_observed` | bool | Yes | #164 Stage B: cancellation was signaled and observed (#161/#164) |
---
## Error Envelope
When a command fails (exit code 1), responses carry:
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "exec-command",
"exit_code": 1,
"error": {
"kind": "filesystem",
"operation": "write",
"target": "/tmp/nonexistent/out.md",
"retryable": true,
"message": "No such file or directory",
"hint": "intermediate directory does not exist; try mkdir -p /tmp/nonexistent"
}
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `error.kind` | enum | Yes | One of: `filesystem`, `auth`, `session`, `parse`, `runtime`, `mcp`, `delivery`, `usage`, `policy`, `unknown` |
| `error.operation` | string | Yes | Syscall/method that failed (e.g. "write", "open", "resolve_session") |
| `error.target` | string | Yes | Resource that failed (path, session-id, server-name, etc.) |
| `error.retryable` | bool | Yes | Whether caller can safely retry without intervention |
| `error.message` | string | Yes | Platform error message (e.g. errno text) |
| `error.hint` | string | No | Optional actionable next step |
---
## Not-Found Envelope
When an entity does not exist (exit code 1, but not a failure):
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "load-session",
"exit_code": 1,
"name": "does-not-exist",
"found": false,
"error": {
"kind": "session_not_found",
"message": "session 'does-not-exist' not found in .claw/sessions/",
"retryable": false
}
}
```
| Field | Type | Required | Notes |
|---|---|---|---|
| `name` | string | Yes | Entity name/id that was looked up |
| `found` | bool | Yes | Always `false` for not-found |
| `error.kind` | enum | Yes | One of: `command_not_found`, `tool_not_found`, `session_not_found` |
| `error.message` | string | Yes | User-visible explanation |
| `error.retryable` | bool | Yes | Usually `false` (entity will not magically appear) |
---
## Per-Command Success Schemas
### `list-sessions`
**Status**: ✅ Implemented (closed #251 cycle #45, 2026-04-23).
**Actual binary envelope** (as of #251 fix):
```json
{
"command": "list-sessions",
"sessions": [
{
"id": "session-1775777421902-1",
"path": "/path/to/.claw/sessions/session-1775777421902-1.jsonl",
"updated_at_ms": 1775777421902,
"message_count": 0
}
]
}
```
**Aspirational (future) shape**:
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "list-sessions",
"exit_code": 0,
"output_format": "json",
"schema_version": "1.0",
"directory": ".claw/sessions",
"sessions_count": 2,
"sessions": [
{
"session_id": "sess_abc123",
"created_at": "2026-04-21T15:30:00Z",
"last_modified": "2026-04-22T09:45:00Z",
"prompt_count": 5,
"stopped": false
}
]
}
```
**Gap**: Current impl lacks `timestamp`, `exit_code`, `output_format`, `schema_version`, `directory`, `sessions_count` (derivable), and the session object uses `id`/`updated_at_ms`/`message_count` instead of `session_id`/`last_modified`/`prompt_count`. Follow-up #250 Option B to align field names and add common-envelope fields.
### `delete-session`
**Status**: ⚠️ Stub only (closed #251 dispatch-order fix; full impl deferred).
**Actual binary envelope** (as of #251 fix):
```json
{
"type": "error",
"command": "delete-session",
"error": "not_yet_implemented",
"kind": "not_yet_implemented"
}
```
Exit code: 1. No credentials required. The stub ensures the verb does NOT fall through to Prompt/auth (the #251 fix), but the actual delete operation is not yet wired.
**Aspirational (future) shape**:
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "delete-session",
"exit_code": 0,
"session_id": "sess_abc123",
"deleted": true,
"directory": ".claw/sessions"
}
```
### `load-session`
**Status**: ✅ Implemented (closed #251 cycle #45, 2026-04-23).
**Actual binary envelope** (as of #251 fix):
```json
{
"command": "load-session",
"session": {
"id": "session-abc123",
"path": "/path/to/.claw/sessions/session-abc123.jsonl",
"messages": 5
}
}
```
For nonexistent sessions, emits a local `session_not_found` error (NOT `missing_credentials`):
```json
{
"error": "session not found: nonexistent",
"kind": "session_not_found",
"type": "error",
"hint": "Hint: managed sessions live in .claw/sessions/<hash>/ ..."
}
```
**Aspirational (future) shape**:
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "load-session",
"exit_code": 0,
"session_id": "sess_abc123",
"loaded": true,
"directory": ".claw/sessions",
"path": ".claw/sessions/sess_abc123.jsonl"
}
```
**Gap**: Current impl uses nested `session: {...}` instead of flat fields, and omits common-envelope fields. Follow-up #250 Option B to align.
### `flush-transcript`
**Status**: ⚠️ Stub only (closed #251 dispatch-order fix; full impl deferred).
**Actual binary envelope** (as of #251 fix):
```json
{
"type": "error",
"command": "flush-transcript",
"error": "not_yet_implemented",
"kind": "not_yet_implemented"
}
```
Exit code: 1. No credentials required. Like `delete-session`, this stub resolves the #251 dispatch-order bug but the actual flush operation is not yet wired.
**Aspirational (future) shape**:
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "flush-transcript",
"exit_code": 0,
"session_id": "sess_abc123",
"path": ".claw/sessions/sess_abc123.jsonl",
"flushed": true,
"messages_count": 12,
"input_tokens": 4500,
"output_tokens": 1200
}
```
### `show-command`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "show-command",
"exit_code": 0,
"name": "add-dir",
"found": true,
"source_hint": "commands/add-dir/add-dir.tsx",
"responsibility": "creates a new directory in the worktree"
}
```
### `show-tool`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "show-tool",
"exit_code": 0,
"name": "BashTool",
"found": true,
"source_hint": "tools/BashTool/BashTool.tsx"
}
```
### `exec-command`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "exec-command",
"exit_code": 0,
"name": "add-dir",
"prompt": "create src/util/",
"handled": true,
"message": "created directory",
"source_hint": "commands/add-dir/add-dir.tsx"
}
```
### `exec-tool`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "exec-tool",
"exit_code": 0,
"name": "BashTool",
"payload": "cargo build",
"handled": true,
"message": "exit code 0",
"source_hint": "tools/BashTool/BashTool.tsx"
}
```
### `route`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "route",
"exit_code": 0,
"prompt": "add a test",
"limit": 10,
"match_count": 3,
"matches": [
{
"kind": "command",
"name": "add-file",
"score": 0.92,
"source_hint": "commands/add-file/add-file.tsx"
}
]
}
```
### `bootstrap`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "bootstrap",
"exit_code": 0,
"prompt": "hello",
"setup": {
"python_version": "3.13.12",
"implementation": "CPython",
"platform_name": "darwin",
"test_command": "pytest"
},
"routed_matches": [
{"kind": "command", "name": "init", "score": 0.85, "source_hint": "..."}
],
"turn": {
"prompt": "hello",
"output": "...",
"stop_reason": "completed"
},
"persisted_session_path": ".claw/sessions/sess_abc.jsonl"
}
```
### `command-graph`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "command-graph",
"exit_code": 0,
"builtins_count": 185,
"plugin_like_count": 20,
"skill_like_count": 2,
"total_count": 207,
"builtins": [
{"name": "add-dir", "source_hint": "commands/add-dir/add-dir.tsx"}
],
"plugin_like": [],
"skill_like": []
}
```
### `tool-pool`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "tool-pool",
"exit_code": 0,
"simple_mode": false,
"include_mcp": true,
"tool_count": 184,
"tools": [
{"name": "BashTool", "source_hint": "tools/BashTool/BashTool.tsx"}
]
}
```
### `bootstrap-graph`
```json
{
"timestamp": "2026-04-22T10:10:00Z",
"command": "bootstrap-graph",
"exit_code": 0,
"stages": ["stage 1", "stage 2", "..."],
"note": "bootstrap-graph is markdown-only in this version"
}
```
---
## Versioning & Compatibility
- **schema_version = "1.0":** Current as of 2026-04-22. Covers all 13 clawable commands.
- **Breaking changes** (e.g. renaming a field) bump schema_version to "2.0".
- **Additive changes** (e.g. new optional field) stay at "1.0" and are backward compatible.
- Downstream claws **must** check `schema_version` before relying on field presence.
---
## Regression Testing
Each command is covered by:
1. **Fixture file** (golden JSON snapshot under `tests/fixtures/json/<command>.json`)
2. **Parametrised test** in `test_cli_parity_audit.py::TestJsonOutputContractEndToEnd`
3. **Field consistency test** (new, tracked as ROADMAP #172)
To update a fixture after a intentional schema change:
```bash
claw <command> --output-format json <args> > tests/fixtures/json/<command>.json
# Review the diff, commit
git add tests/fixtures/json/<command>.json
```
To verify no regressions:
```bash
cargo test --release test_json_envelope_field_consistency
```
---
## Design Notes
**Why common fields on every response?**
- Downstream claws can build one error handler that works for all commands
- Timestamp + command + exit_code give context without scraping argv or timestamps from command output
- `schema_version` signals compatibility for future upgrades
**Why both "found" and "error" on not-found?**
- Exit code 1 covers both "entity missing" and "operation failed"
- `found=false` distinguishes not-found from error without string matching
- `error.kind` and `error.retryable` let automation decide: retry a temporary miss vs escalate a permanent refusal
**Why "operation" and "target" in error?**
- Claws can aggregate failures by operation type (e.g. "how many `write` ops failed?")
- Claws can implement per-target retry policy (e.g. "skip missing files, retry networking")
- Pure text errors ("No such file") do not provide enough structure for pattern matching
**Why "handled" vs "found"?**
- `show-command` reports `found: bool` (inventory signal: "does this exist?")
- `exec-command` reports `handled: bool` (operational signal: "was this work performed?")
- The names matter: a command can be found but not handled (e.g. too large for context window), or handled silently (no output message)
---
## Appendix: Current v1.0 vs. Target v2.0 Envelope Shapes
### ⚠️ IMPORTANT: Binary Reality vs. This Document
**This entire SCHEMAS.md document describes the TARGET v2.0 schema.** The actual Rust binary currently emits v1.0 (flat) envelopes.
**Do not assume the fields documented above are in the binary right now.** They are not.
### Current v1.0 Envelope (What the Rust Binary Actually Emits)
The Rust binary in `rust/` currently emits a **flat v1.0 envelope** without common metadata wrapper:
#### v1.0 Success Envelope Example
```json
{
"kind": "list-sessions",
"sessions": [
{"id": "abc123", "created": "2026-04-22T10:00:00Z", "turns": 5}
],
"type": "success"
}
```
**Key differences from v2.0 above:**
- NO `timestamp`, `command`, `exit_code`, `output_format`, `schema_version` fields
- `kind` field contains the verb name (or is entirely absent for success)
- `type: "success"` flag at top level
- Verb-specific fields (`sessions`, `turn`, etc.) at top level
#### v1.0 Error Envelope Example
```json
{
"error": "session 'xyz789' not found in .claw/sessions",
"hint": "use 'list-sessions' to see available sessions",
"kind": "session_not_found",
"type": "error"
}
```
**Key differences from v2.0 error above:**
- `error` field is a **STRING**, not a nested object
- NO `error.operation`, `error.target`, `error.retryable` structured fields
- `kind` is at top-level, not nested
- NO `timestamp`, `command`, `exit_code`, `output_format`, `schema_version`
- Extra `type: "error"` flag
### Migration Timeline (FIX_LOCUS_164)
See [`FIX_LOCUS_164.md`](./FIX_LOCUS_164.md) for the full phased migration:
- **Phase 1 (Opt-in):** `claw <cmd> --output-format json --envelope-version=2.0` emits v2.0 shape
- **Phase 2 (Default):** v2.0 becomes default; `--legacy-envelope` flag opts into v1.0
- **Phase 3 (Deprecation):** v1.0 warnings, then removal
### Building Automation Against v1.0 (Current)
**For claws building automation today** (against the real binary, not this schema):
1. **Check `type` field first** (string: "success" or "error")
2. **For success:** verb-specific fields are at top level. Use `jq .kind` for verb ID (if present)
3. **For error:** access `error` (string), `hint` (string), `kind` (string) all at top level
4. **Do not expect:** `timestamp`, `command`, `exit_code`, `output_format`, `schema_version` — they don't exist yet
5. **Test your code** against `claw <cmd> --output-format json` output to verify assumptions before deploying
### Example: Python Consumer Code (v1.0)
**Correct pattern for v1.0 (current binary):**
```python
import json
import subprocess
result = subprocess.run(
["claw", "list-sessions", "--output-format", "json"],
capture_output=True,
text=True
)
envelope = json.loads(result.stdout)
# v1.0: type is at top level
if envelope.get("type") == "error":
error_msg = envelope.get("error", "unknown error") # error is a STRING
error_kind = envelope.get("kind") # kind is at TOP LEVEL
print(f"Error: {error_kind}{error_msg}")
else:
# Success path: verb-specific fields at top level
sessions = envelope.get("sessions", [])
for session in sessions:
print(f"Session: {session['id']}")
```
**After v2.0 migration, this code will break.** Claws building for v2.0 compatibility should:
1. Check `schema_version` field
2. Parse differently based on version
3. Or wait until Phase 2 default bump is announced, then migrate
### Why This Mismatch Exists
SCHEMAS.md was written as the **target design** for v2.0. The Rust binary is still on v1.0. The migration (FIX_LOCUS_164) will bring the binary in line with this schema, but it hasn't happened yet.
**This mismatch is the root cause of doc-truthfulness issues #78, #79, #165.** All three docs were documenting the v2.0 target as if it were current reality.
### Questions?
- **"Is v2.0 implemented?"** No. The binary is v1.0. See FIX_LOCUS_164.md for the implementation roadmap.
- **"Should I build against v2.0 schema?"** No. Build against v1.0 (current). Test your code with `claw` to verify.
- **"When does v2.0 ship?"** See FIX_LOCUS_164.md Phase 1 estimate: ~6 dev-days. Not scheduled yet.
- **"Can I use v2.0 now?"** Only if you explicitly pass `--envelope-version=2.0` (which doesn't exist yet in v1.0 binary).
---
## v1.5 Emission Baseline — Per-Verb Shape Catalog (Cycle #91, Phase 0 Task 3)
**Status:** 📸 Snapshot of actual binary behavior as of cycle #91 (2026-04-23). Anchored by controlled matrix `/tmp/cycle87-audit/matrix.json` + Phase 0 tests in `output_format_contract.rs`.
### Purpose
This section documents **what each verb actually emits under `--output-format json`** as of the v1.5 emission baseline (post-cycle #89 emission routing fix, pre-Phase 1 shape normalization).
This is a **reference artifact**, not a target schema. It describes the reality that:
1. `--output-format json` exists and emits JSON (enforced by Phase 0 Task 2)
2. All output goes to stdout (enforced by #168c fix, cycle #89)
3. Each verb has a bespoke top-level shape (documented below; to be normalized in Phase 1)
### Emission Contract (v1.5 Baseline)
| Property | Rule | Enforced By |
|---|---|---|
| Exit 0 + stdout empty (silent success) | **Forbidden** | Test: `emission_contract_no_silent_success_under_output_format_json_168c_task2` |
| Exit 0 + stdout contains valid JSON | Required | Test: same (parses each safe-success verb) |
| Exit != 0 + JSON envelope on stdout | Required | Test: same + `error_envelope_emitted_to_stdout_under_output_format_json_168c` |
| Error envelope on stderr under `--output-format json` | **Forbidden** | Test: #168c regression test |
| Text mode routes errors to stderr | Preserved | Backward compat; not changed by cycle #89 |
### Per-Verb Shape Catalog
Captured from controlled matrix (cycle #87) and verified against post-#168c binary (cycle #91).
#### Verbs with `kind` top-level field (12/13)
| Verb | Top-level keys | Notes |
|---|---|---|
| `help` | `kind, message` | Minimal shape |
| `version` | `git_sha, kind, message, target, version` | Build metadata |
| `doctor` | `checks, has_failures, kind, message, report, summary` | Diagnostic results |
| `mcp` | `action, config_load_error, configured_servers, kind, servers, status, working_directory` | MCP state |
| `skills` | `action, kind, skills, summary` | Skills inventory |
| `agents` | `action, agents, count, kind, summary, working_directory` | Agent inventory |
| `sandbox` | `active, active_namespace, active_network, allowed_mounts, enabled, fallback_reason, filesystem_active, filesystem_mode, in_container, kind, markers, requested_namespace, requested_network, supported` | Sandbox state (14 keys) |
| `status` | `config_load_error, kind, model, model_raw, model_source, permission_mode, sandbox, status, usage, workspace` | Runtime status |
| `system-prompt` | `kind, message, sections` | Prompt sections |
| `bootstrap-plan` | `kind, phases` | Bootstrap phases |
| `export` | `file, kind, message, messages, session_id` | Export metadata |
| `acp` | `aliases, discoverability_tracking, kind, launch_command, message, recommended_workflows, serve_alias_only, status, supported, tracking` | ACP discoverability |
#### Verb with `command` top-level field (1/13) — Phase 1 normalization target
| Verb | Top-level keys | Notes |
|---|---|---|
| `list-sessions` | `command, sessions` | **Deviation:** uses `command` instead of `kind`. Target Phase 1 fix. |
#### Verbs with error-only emission in test env (exit != 0)
These verbs require external state (credentials, session fixtures, manifests) and return error envelopes in clean test environments:
| Verb | Error envelope keys | Notes |
|---|---|---|
| `bootstrap` | `error, hint, kind, type` | Requires `ANTHROPIC_AUTH_TOKEN` for success path |
| `dump-manifests` | `error, hint, kind, type` | Requires upstream manifest source |
| `state` | `error, hint, kind, type` | Requires worker state file |
**Common error envelope shape (all verbs):** `{error, hint, kind, type}` — this is the one consistently-shaped part of v1.5.
### Standard Error Envelope (v1.5)
Error envelopes are the **only** part of v1.5 with a guaranteed consistent shape across all verbs:
```json
{
"type": "error",
"error": "short human-readable reason",
"kind": "snake_case_machine_readable_classification",
"hint": "optional remediation hint (may be null)"
}
```
**Classification kinds** (from `classify_error_kind` in `main.rs`):
- `cli_parse` — argument parsing error
- `missing_credentials` — auth token/key missing
- `session_not_found` — load-session target missing
- `session_load_failed` — persisted session unreadable
- `no_managed_sessions` — no sessions exist to list
- `missing_manifests` — upstream manifest sources absent
- `filesystem_io_error` — file operation failure
- `api_http_error` — upstream API returned non-2xx
- `unknown` — classifier fallthrough
### How This Differs from v2.0 Target
| Aspect | v1.5 (this doc) | v2.0 Target (SCHEMAS.md top) |
|---|---|---|
| Top-level verb ID | 12 use `kind`, 1 uses `command` | Common `command` field |
| Common metadata | None (no `timestamp`, `exit_code`, etc.) | `timestamp`, `command`, `exit_code`, `output_format`, `schema_version` |
| Error envelope | `{error, hint, kind, type}` flat | `{error: {message, kind, operation, target, retryable}, ...}` nested |
| Success shape | Verb-specific (13 bespoke) | Common wrapper with `data` field |
### Consumer Guidance (Against v1.5 Baseline)
**For claws consuming v1.5 today:**
1. **Always use `--output-format json`** — text format has no stability contract (#167)
2. **Check `type` field first** — "error" or absent/other (treat as success)
3. **For errors:** access `error` (string), `kind` (string), `hint` (nullable string)
4. **For success:** use verb-specific keys per catalog above
5. **Do NOT assume** `kind` field exists on success path — `list-sessions` uses `command` instead
6. **Do NOT assume** metadata fields (`timestamp`, `exit_code`, etc.) — they are v2.0 target only
7. **Check exit code** for pass/fail; don't infer from payload alone
### Phase 1 Normalization Targets (After This Baseline Locks)
Phase 1 (shape stabilization) will normalize these divergences:
- `list-sessions`: `command``kind` (align with 12/13 convention)
- Potentially: unify where `message` field appears (9/13 have it, inconsistently populated)
- Potentially: unify where `action` field appears (only in 3 inventory verbs: `mcp`, `skills`, `agents`)
Phase 1 does **not** add common metadata (`timestamp`, `exit_code`) — that's Phase 2 (v2.0 wrapper).
### Regenerating This Catalog
The catalog is derived from running the controlled matrix. Phase 0 Task 4 will add a deterministic script; for now, reproduce with:
```
for verb in help version list-sessions doctor mcp skills agents sandbox status system-prompt bootstrap-plan export acp; do
echo "=== $verb ==="
claw $verb --output-format json | jq 'keys'
done
```
This matches what the Phase 0 Task 2 test enforces programmatically.

View File

@@ -1,49 +0,0 @@
# Security Policy
## Supported Versions
This project is pre-1.0 / active development. Only the `main` branch (and the current active feature branch) receives security attention. No LTS commitment exists yet.
| Branch | Supported |
|--------|-----------|
| `main` | ✅ |
| older forks/branches | ❌ |
## Reporting a Vulnerability
**Do not file a public GitHub issue for security vulnerabilities.**
Please use [GitHub Security Advisories](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability) to report privately:
1. Go to the **Security** tab of this repository
2. Click **"Report a vulnerability"**
3. Describe the issue with reproduction steps and impact
We aim to acknowledge within **72 hours** and work toward coordinated disclosure.
## Disclosure Process
1. Report received → acknowledgement within 72h
2. We assess severity and reproduce the issue
3. Fix developed and reviewed privately
4. Fix shipped; advisory published after patch is live
5. Credit given to reporter (unless they prefer anonymity)
## Scope
**In scope:**
- Remote code execution (RCE)
- Authentication or authorization bypass
- Secrets / credentials exfiltration
- Sandbox escape (agent isolation boundary violations)
- Privilege escalation
**Out of scope:**
- Denial of service (DoS/resource exhaustion)
- Social engineering attacks
- Vulnerabilities in third-party dependencies — report those upstream
- Behavior that is working as intended (check ROADMAP.md pinpoints first)
## License
This project is [MIT-licensed](./LICENSE) — provided as-is, without warranty of any kind.

View File

@@ -1,98 +0,0 @@
# Troubleshooting
## Upstream stream-init failures (`500 empty_stream`)
**Symptom:** claw-code exits with `500 empty_stream: upstream stream closed before first payload` or similar upstream stream-init error.
**Root cause:** Upstream provider (Anthropic, OpenAI, other) closed the HTTP connection before sending the first response payload. Common causes:
- Transient network issue between claw-code and provider
- Provider overload / temporary service degradation
- Authentication token expired or invalid
- Rate limit exceeded (even if not visible in response headers)
**Mitigation:**
1. **Check credentials:** Verify `claw whoami` shows the expected provider and account. Re-authenticate if expired.
2. **Wait and retry:** Provider transient issues usually resolve within 30-60 seconds. Wait a minute, then retry the same command.
3. **Check provider status:** Visit the provider's status page (e.g., status.anthropic.com, status.openai.com).
4. **Reduce request size:** If the prompt is large, try a smaller request first to isolate stream-init from context-window failures.
5. **Check network:** Ensure your network connection is stable. If behind a proxy, verify proxy allows streaming responses.
**When to escalate:**
- If stream-init failures persist >10 minutes across multiple requests
- If `claw whoami` fails to authenticate
- If no provider status page shows degradation
**Related pinpoint:** #290 (typed stream-init failure envelope — future improvement for better diagnostics)
---
## Context-window-blocked errors
**Symptom:** claw-code exits with `context_window_blocked` or similar provider error when resuming a long session, or when sending a request with a very large prompt + accumulated history.
**Root cause:** Session size exceeded provider context window before claw-code's auto-compaction could reduce it. Auto-compaction is currently REACTIVE-AFTER-SUCCESS — it only fires after a successful provider response. If the request itself is oversized, compaction never runs.
**Mitigation:**
1. **Resume with manual compact:** `claw resume <session> --compact-before` (if available); else manually compact via `/compact` slash command before retrying
2. **Start a fresh session:** Sometimes the cleanest path; existing session-state preserved in `~/.claw/sessions/<id>/`
3. **Reduce prompt size:** If interactive, send shorter prompts; truncate file contents before pasting
4. **Adjust threshold:** Lower `CLAW_AUTO_COMPACT_INPUT_TOKENS_THRESHOLD` env var (default varies by provider)
**Related pinpoints:** #287 (auto-compaction reactive-not-preflight, CRITICAL), #283 (threshold env-only no settings.json key), #288 (failure envelope omits diagnostics)
---
## Manual `/compact` reports "session below compaction threshold"
**Symptom:** You run `/compact` to manually compact a session, but it reports `session below compaction threshold` even though the session feels large.
**Root cause:** The "below threshold" message is currently a catch-all for multiple skip reasons:
- Too few compactable messages
- Already compacted (only summary remains)
- Compactable tokens below threshold
- Tool-use/tool-result boundary preserved
- Live vs resume threshold divergence
**Mitigation:**
1. **Check session state:** `claw session info <id>` to inspect message count, total tokens
2. **Force compaction:** Currently no `--force` flag exists; track #289 for typed skip-reason discriminants
3. **Workaround:** Continue session and let auto-compact fire after next provider response (when reactive-after-success path is available)
**Related pinpoint:** #289 (manual `/compact` skip-reason flattened, lacks typed discriminants)
---
## Parallel agent stuck in "running" state
**Symptom:** A parallel agent lane shows `status: running` indefinitely, never transitioning to `completed` or `error`. Downstream coordination treats it as still-working.
**Root cause:** `Agent::execute_agent` writes a `running` manifest BEFORE spawning a detached `std::thread::spawn`. The `JoinHandle` is dropped. If the process crashes during agent execution, the manifest stays as `running` forever (zombie state). No heartbeat or stale-reaper exists.
**Mitigation:**
1. **Manual cleanup:** Inspect `~/.claw/agents/<lane>/` and remove stale `manifest.json` files where last-modified > N minutes ago
2. **Restart agent lane:** `claw agent restart <lane>`
3. **Kill orphaned processes:** `pgrep claw` to find lingering processes
**Related pinpoint:** #286 (Parallel `Agent` detached-thread no-heartbeat no-reaper)
---
## Sustained upstream provider failures (`500 empty_stream` repeating)
**Symptom:** Same upstream provider error (e.g., `500 empty_stream: upstream stream closed before first payload`) repeats 5+ times in <60 minutes. Retries hit the same dead upstream blindly.
**Root cause:** claw-code does NOT detect repeat-failure patterns. No circuit-breaker. No automatic provider-fallback when configured. Each retry attempts the same provider+endpoint regardless of recent failure history.
**Mitigation:**
1. **Manual circuit-breaker:** Wait 5-10 minutes after repeated failures before retrying
2. **Switch provider:** If you have multiple providers configured (`ANTHROPIC_API_KEY` + `OPENAI_API_KEY`), restart with different model prefix (e.g., `gpt-4` instead of `claude-`)
3. **Check provider status pages:** status.anthropic.com, status.openai.com
4. **Verify upstream endpoint:** If using a proxy (CCAPI, custom OpenAI-compatible endpoint), check proxy logs
**Related pinpoints:** #291 (no repeat-failure detection / circuit-breaker), #285 (declarative providers config for fallback), #290 (stream-init failure envelope)
---
## Other common failures
*[placeholder for future sections: tool-use failures, session corruption]*

709
USAGE.md
View File

@@ -1,709 +0,0 @@
# Claw Code Usage
This guide covers the current Rust workspace under `rust/` and the `claw` CLI binary. If you are brand new, make the doctor health check your first run: start `claw`, then run `/doctor`.
> [!TIP]
> **Building orchestration code that calls `claw` as a subprocess?** See [`ERROR_HANDLING.md`](./ERROR_HANDLING.md) for the unified error-handling pattern (one handler for all 14 clawable commands, exit codes, JSON envelope contract, and recovery strategies).
## Quick-start health check
Run this before prompts, sessions, or automation:
```bash
cd rust
cargo build --workspace
./target/debug/claw
# first command inside the REPL
/doctor
```
`/doctor` is the built-in setup and preflight diagnostic. Once you have a saved session, you can rerun it with `./target/debug/claw --resume latest /doctor`.
## Prerequisites
- Rust toolchain with `cargo`
- One of:
- `ANTHROPIC_API_KEY` for direct API access
- `ANTHROPIC_AUTH_TOKEN` for bearer-token auth
- Optional: `ANTHROPIC_BASE_URL` when targeting a proxy or local service
## Install / build the workspace
```bash
cd rust
cargo build --workspace
```
The CLI binary is available at `rust/target/debug/claw` after a debug build. Make the doctor check above your first post-build step.
### Add binary to PATH
To run `claw` from anywhere without typing the full path:
**Option 1: Symlink to a directory already in your PATH**
```bash
# Find a PATH directory (usually ~/.local/bin or /usr/local/bin)
echo $PATH
# Create symlink (adjust path and PATH-dir as needed)
ln -s /Users/yeongyu/clawd/claw-code/rust/target/debug/claw ~/.local/bin/claw
# Verify it's in PATH
which claw
```
**Option 2: Add the binary directory to PATH directly**
Add this to your shell rc file (`~/.bashrc`, `~/.zshrc`, etc.):
```bash
export PATH="$PATH:/Users/yeongyu/clawd/claw-code/rust/target/debug"
```
Then reload:
```bash
source ~/.zshrc # or ~/.bashrc
```
### Verify install
After adding to PATH, verify the binary works:
```bash
# Should print version and exit successfully
claw version
# Should run health check (shows which components are initialized)
claw doctor
# Should show available commands
claw --help
```
If `claw: command not found`, the PATH addition didn't take. Re-check:
```bash
echo $PATH # verify your PATH directory is listed
which claw # should show full path to binary
ls -la ~/.local/bin/claw # if using symlink, verify it exists and points to target/debug/claw
```
## Quick start
### First-run doctor check
```bash
cd rust
./target/debug/claw
/doctor
```
Or run doctor directly with JSON output for scripting:
```bash
cd rust
./target/debug/claw doctor --output-format json
```
**Note:** Diagnostic verbs (`doctor`, `status`, `sandbox`, `version`) support `--output-format json` for machine-readable output. Invalid suffix arguments (e.g., `--json`) are now rejected at parse time rather than falling through to prompt dispatch.
### Initialize a repository
Set up a new repository with `.claw` config, `.claw.json`, `.gitignore` entries, and a `CLAUDE.md` guidance file:
```bash
cd /path/to/your/repo
./target/debug/claw init
```
Text mode (human-readable) shows artifact creation summary with project path and next steps. Idempotent — running multiple times in the same repo marks already-created files as "skipped".
JSON mode for scripting:
```bash
./target/debug/claw init --output-format json
```
Returns structured output with `project_path`, `created[]`, `updated[]`, `skipped[]` arrays (one per artifact), and `artifacts[]` carrying each file's `name` and machine-stable `status` tag. The legacy `message` field preserves backward compatibility.
**Why structured fields matter:** Claws can detect per-artifact state (`created` vs `updated` vs `skipped`) without substring-matching human prose. Use the `created[]`, `updated[]`, and `skipped[]` arrays for conditional follow-up logic (e.g., only commit if files were actually created, not just updated).
### Interactive REPL
```bash
cd rust
./target/debug/claw
```
### One-shot prompt
```bash
cd rust
./target/debug/claw prompt "summarize this repository"
```
### Shorthand prompt mode
```bash
cd rust
./target/debug/claw "explain rust/crates/runtime/src/lib.rs"
```
### JSON output for scripting
All clawable commands support `--output-format json` for machine-readable output.
**IMPORTANT SCHEMA VERSION NOTICE:**
The JSON envelope is currently in **v1.0 (flat shape)** and is scheduled to migrate to **v2.0 (nested schema)** in a future release. See [`FIX_LOCUS_164.md`](./FIX_LOCUS_164.md) for the full migration plan.
#### Current (v1.0) envelope shape
**Success envelope** — verb-specific fields + `kind: "<verb-name>"`:
```json
{
"kind": "doctor",
"checks": [...],
"summary": {...},
"has_failures": false,
"report": "...",
"message": "..."
}
```
**Error envelope** — flat error fields at top level:
```json
{
"error": "unrecognized argument `foo`",
"hint": "Run `claw --help` for usage.",
"kind": "cli_parse",
"type": "error"
}
```
**Known issues with v1.0:**
- Missing `exit_code`, `command`, `timestamp`, `output_format`, `schema_version` fields
- `error` is a string, not a structured object with operation/target/retryable/message/hint
- `kind` field is semantically overloaded (verb identity in success, error classification in error)
- See [`SCHEMAS.md`](./SCHEMAS.md) for documented (v2.0 target) schema and [`FIX_LOCUS_164.md`](./FIX_LOCUS_164.md) for migration details
#### Using v1.0 envelopes in your code
**Success path:** Check for absence of `type: "error"`, then access verb-specific fields:
```bash
cd rust
./target/debug/claw doctor --output-format json | jq '.kind, .has_failures'
```
**Error path:** Check for `type == "error"`, then access `error` (string) and `kind` (error classification):
```bash
cd rust
./target/debug/claw doctor invalid-arg --output-format json | jq '.error, .kind'
```
**Do NOT rely on `kind` alone for dispatching** — it has different meanings in success vs. error. Always check `type == "error"` first.
```bash
cd rust
./target/debug/claw --output-format json prompt "status"
./target/debug/claw --output-format json load-session my-session-id
./target/debug/claw --output-format json turn-loop "analyze logs" --max-turns 1
```
**Building a dispatcher or orchestration script?** See [`ERROR_HANDLING.md`](./ERROR_HANDLING.md) for the unified error-handling pattern. One code example works for all 14 clawable commands: parse the exit code, classify by `error.kind`, apply recovery strategies (retry, timeout recovery, validation, logging). Use that pattern instead of reimplementing error handling per command.
**Migrating to v2.0?** Check back after [`FIX_LOCUS_164`](./FIX_LOCUS_164.md) is implemented. Phase 1 will add a `--envelope-version=2.0` flag for opt-in access to the structured envelope schema. Phase 2 will make v2.0 the default. Phase 3 will deprecate v1.0.
### Inspect worker state
The `claw state` command reads `.claw/worker-state.json`, which is written by the interactive REPL or a one-shot prompt when a worker executes a task. This file contains the worker ID, session reference, model, and permission mode.
Prerequisite: You must run `claw` (interactive REPL) or `claw prompt <text>` at least once in the repository to produce the worker state file.
```bash
cd rust
./target/debug/claw state
```
JSON mode:
```bash
./target/debug/claw state --output-format json
```
If you run `claw state` before any worker has executed, you will see a helpful error:
```
error: no worker state file found at .claw/worker-state.json
Hint: worker state is written by the interactive REPL or a non-interactive prompt.
Run: claw # start the REPL (writes state on first turn)
Or: claw prompt <text> # run one non-interactive turn
Then rerun: claw state [--output-format json]
```
## Advanced slash commands (Interactive REPL only)
These commands are available inside the interactive REPL (`claw` with no args). They extend the assistant with workspace analysis, planning, and navigation features.
### `/ultraplan` — Deep planning with multi-step reasoning
**Purpose:** Break down a complex task into steps using extended reasoning.
```bash
# Start the REPL
claw
# Inside the REPL
/ultraplan refactor the auth module to use async/await
/ultraplan design a caching layer for database queries
/ultraplan analyze this module for performance bottlenecks
```
Output: A structured plan with numbered steps, reasoning for each step, and expected outcomes. Use this when you want the assistant to think through a problem in detail before coding.
### `/teleport` — Jump to a file or symbol
**Purpose:** Quickly navigate to a file, function, class, or struct by name.
```bash
# Jump to a symbol
/teleport UserService
/teleport authenticate_user
/teleport RequestHandler
# Jump to a file
/teleport src/auth.rs
/teleport crates/runtime/lib.rs
/teleport ./ARCHITECTURE.md
```
Output: The file content, with the requested symbol highlighted or the file fully loaded. Useful for exploring the codebase without manually navigating directories. If multiple matches exist, the assistant shows the top candidates.
### `/bughunter` — Scan for likely bugs and issues
**Purpose:** Analyze code for common pitfalls, anti-patterns, and potential bugs.
```bash
# Scan the entire workspace
/bughunter
# Scan a specific directory or file
/bughunter src/handlers
/bughunter rust/crates/runtime
/bughunter src/auth.rs
```
Output: A list of suspicious patterns with explanations (e.g., "unchecked unwrap()", "potential race condition", "missing error handling"). Each finding includes the file, line number, and suggested fix. Use this as a first pass before a full code review.
## Model and permission controls
```bash
cd rust
./target/debug/claw --model sonnet prompt "review this diff"
./target/debug/claw --permission-mode read-only prompt "summarize Cargo.toml"
./target/debug/claw --permission-mode workspace-write prompt "update README.md"
./target/debug/claw --allowedTools read,glob "inspect the runtime crate"
```
Supported permission modes:
- `read-only`
- `workspace-write`
- `danger-full-access`
Model aliases currently supported by the CLI:
- `opus``claude-opus-4-6`
- `sonnet``claude-sonnet-4-6`
- `haiku``claude-haiku-4-5-20251213`
## Authentication
### API key
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
```
### OAuth
```bash
cd rust
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
```
### Which env var goes where
`claw` accepts two Anthropic credential env vars and they are **not interchangeable** — the HTTP header Anthropic expects differs per credential shape. Putting the wrong value in the wrong slot is the most common 401 we see.
| Credential shape | Env var | HTTP header | Typical source |
|---|---|---|---|
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | an Anthropic-compatible proxy or OAuth flow that mints bearer tokens |
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
**If you meant a different provider:** if `claw` reports missing Anthropic credentials but you already have `OPENAI_API_KEY`, `XAI_API_KEY`, or `DASHSCOPE_API_KEY` exported, you most likely forgot to prefix the model name with the provider's routing prefix. Use `--model openai/gpt-4.1-mini` (OpenAI-compat / OpenRouter / Ollama), `--model grok` (xAI), or `--model qwen-plus` (DashScope) and the prefix router will select the right backend regardless of the ambient credentials. The error message now includes a hint that names the detected env var.
## Local Models
`claw` can talk to local servers and provider gateways through either Anthropic-compatible or OpenAI-compatible endpoints. Use `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` for Anthropic-compatible services, or `OPENAI_BASE_URL` with `OPENAI_API_KEY` for OpenAI-compatible services.
### Anthropic-compatible endpoint
```bash
export ANTHROPIC_BASE_URL="http://127.0.0.1:8080"
export ANTHROPIC_AUTH_TOKEN="local-dev-token"
cd rust
./target/debug/claw --model "claude-sonnet-4-6" prompt "reply with the word ready"
```
### OpenAI-compatible endpoint
```bash
export OPENAI_BASE_URL="http://127.0.0.1:8000/v1"
export OPENAI_API_KEY="local-dev-token"
cd rust
./target/debug/claw --model "qwen2.5-coder" prompt "reply with the word ready"
```
### Ollama
```bash
export OPENAI_BASE_URL="http://127.0.0.1:11434/v1"
unset OPENAI_API_KEY
cd rust
./target/debug/claw --model "llama3.2" prompt "summarize this repository in one sentence"
```
### OpenRouter
```bash
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
export OPENAI_API_KEY="sk-or-v1-..."
cd rust
./target/debug/claw --model "openai/gpt-4.1-mini" prompt "summarize this repository in one sentence"
```
### Alibaba DashScope (Qwen)
For Qwen models via Alibaba's native DashScope API (higher rate limits than OpenRouter):
```bash
export DASHSCOPE_API_KEY="sk-..."
cd rust
./target/debug/claw --model "qwen/qwen-max" prompt "hello"
# or bare:
./target/debug/claw --model "qwen-plus" prompt "hello"
```
Model names starting with `qwen/` or `qwen-` are automatically routed to the DashScope compatible-mode endpoint (`https://dashscope.aliyuncs.com/compatible-mode/v1`). You do **not** need to set `OPENAI_BASE_URL` or unset `ANTHROPIC_API_KEY` — the model prefix wins over the ambient credential sniffer.
Reasoning variants (`qwen-qwq-*`, `qwq-*`, `*-thinking`) automatically strip `temperature`/`top_p`/`frequency_penalty`/`presence_penalty` before the request hits the wire (these params are rejected by reasoning models).
## Supported Providers & Models
`claw` has three built-in provider backends. The provider is selected automatically based on the model name, falling back to whichever credential is present in the environment.
### Provider matrix
| Provider | Protocol | Auth env var(s) | Base URL env var | Default base URL |
|---|---|---|---|---|
| **Anthropic** (direct) | Anthropic Messages API | `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` |
| **xAI** | OpenAI-compatible | `XAI_API_KEY` | `XAI_BASE_URL` | `https://api.x.ai/v1` |
| **OpenAI-compatible** | OpenAI Chat Completions | `OPENAI_API_KEY` | `OPENAI_BASE_URL` | `https://api.openai.com/v1` |
| **DashScope** (Alibaba) | OpenAI-compatible | `DASHSCOPE_API_KEY` | `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
The OpenAI-compatible backend also serves as the gateway for **OpenRouter**, **Ollama**, and any other service that speaks the OpenAI `/v1/chat/completions` wire format — just point `OPENAI_BASE_URL` at the service.
**Model-name prefix routing:** If a model name starts with `openai/`, `gpt-`, `qwen/`, or `qwen-`, the provider is selected by the prefix regardless of which env vars are set. This prevents accidental misrouting to Anthropic when multiple credentials exist in the environment.
### Tested models and aliases
These are the models registered in the built-in alias table with known token limits:
| Alias | Resolved model name | Provider | Max output tokens | Context window |
|---|---|---|---|---|
| `opus` | `claude-opus-4-6` | Anthropic | 32 000 | 200 000 |
| `sonnet` | `claude-sonnet-4-6` | Anthropic | 64 000 | 200 000 |
| `haiku` | `claude-haiku-4-5-20251213` | Anthropic | 64 000 | 200 000 |
| `grok` / `grok-3` | `grok-3` | xAI | 64 000 | 131 072 |
| `grok-mini` / `grok-3-mini` | `grok-3-mini` | xAI | 64 000 | 131 072 |
| `grok-2` | `grok-2` | xAI | — | — |
Any model name that does not match an alias is passed through verbatim. This is how you use OpenRouter model slugs (`openai/gpt-4.1-mini`), Ollama tags (`llama3.2`), or full Anthropic model IDs (`claude-sonnet-4-20250514`).
### User-defined aliases
You can add custom aliases in any settings file (`~/.claw/settings.json`, `.claw/settings.json`, or `.claw/settings.local.json`):
```json
{
"aliases": {
"fast": "claude-haiku-4-5-20251213",
"smart": "claude-opus-4-6",
"cheap": "grok-3-mini"
}
}
```
Local project settings override user-level settings. Aliases resolve through the built-in table, so `"fast": "haiku"` also works.
### How provider detection works
1. If the resolved model name starts with `claude` → Anthropic.
2. If it starts with `grok` → xAI.
3. Otherwise, `claw` checks which credential is set: `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` first, then `OPENAI_API_KEY`, then `XAI_API_KEY`.
4. If nothing matches, it defaults to Anthropic.
## FAQ
### What about Codex?
The name "codex" appears in the Claw Code ecosystem but it does **not** refer to OpenAI Codex (the code-generation model). Here is what it means in this project:
- **`oh-my-codex` (OmX)** is the workflow and plugin layer that sits on top of `claw`. It provides planning modes, parallel multi-agent execution, notification routing, and other automation features. See [PHILOSOPHY.md](./PHILOSOPHY.md) and the [oh-my-codex repo](https://github.com/Yeachan-Heo/oh-my-codex).
- **`.codex/` directories** (e.g. `.codex/skills`, `.codex/agents`, `.codex/commands`) are legacy lookup paths that `claw` still scans alongside the primary `.claw/` directories.
- **`CODEX_HOME`** is an optional environment variable that points to a custom root for user-level skill and command lookups.
`claw` does **not** support OpenAI Codex sessions, the Codex CLI, or Codex session import/export. If you need to use OpenAI models (like GPT-4.1), configure the OpenAI-compatible provider as shown above in the [OpenAI-compatible endpoint](#openai-compatible-endpoint) and [OpenRouter](#openrouter) sections.
## HTTP proxy support
`claw` honours the standard `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables (both upper- and lower-case spellings are accepted) when issuing outbound requests to Anthropic, OpenAI-, and xAI-compatible endpoints. Set them before launching the CLI and the underlying `reqwest` client will be configured automatically.
### Environment variables
```bash
export HTTPS_PROXY="http://proxy.corp.example:3128"
export HTTP_PROXY="http://proxy.corp.example:3128"
export NO_PROXY="localhost,127.0.0.1,.corp.example"
cd rust
./target/debug/claw prompt "hello via the corporate proxy"
```
### Programmatic `proxy_url` config option
As an alternative to per-scheme environment variables, the `ProxyConfig` type exposes a `proxy_url` field that acts as a single catch-all proxy for both HTTP and HTTPS traffic. When `proxy_url` is set it takes precedence over the separate `http_proxy` and `https_proxy` fields.
```rust
use api::{build_http_client_with, ProxyConfig};
// From a single unified URL (config file, CLI flag, etc.)
let config = ProxyConfig::from_proxy_url("http://proxy.corp.example:3128");
let client = build_http_client_with(&config).expect("proxy client");
// Or set the field directly alongside NO_PROXY
let config = ProxyConfig {
proxy_url: Some("http://proxy.corp.example:3128".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()),
..ProxyConfig::default()
};
let client = build_http_client_with(&config).expect("proxy client");
```
### Notes
- When both `HTTPS_PROXY` and `HTTP_PROXY` are set, the secure proxy applies to `https://` URLs and the plain proxy applies to `http://` URLs.
- `proxy_url` is a unified alternative: when set, it applies to both `http://` and `https://` destinations, overriding the per-scheme fields.
- `NO_PROXY` accepts a comma-separated list of host suffixes (for example `.corp.example`) and IP literals.
- Empty values are treated as unset, so leaving `HTTPS_PROXY=""` in your shell will not enable a proxy.
- If a proxy URL cannot be parsed, `claw` falls back to a direct (no-proxy) client so existing workflows keep working; double-check the URL if you expected the request to be tunnelled.
## Common operational commands
```bash
cd rust
./target/debug/claw status
./target/debug/claw sandbox
./target/debug/claw agents
./target/debug/claw mcp
./target/debug/claw skills
./target/debug/claw system-prompt --cwd .. --date 2026-04-04
```
### `dump-manifests` — Export upstream plugin/MCP manifests
**Purpose:** Dump built-in tool and plugin manifests to stdout as JSON, for parity comparison against the upstream Claude Code TypeScript implementation.
**Prerequisite:** This command requires access to upstream source files (`src/commands.ts`, `src/tools.ts`, `src/entrypoints/cli.tsx`). Set `CLAUDE_CODE_UPSTREAM` env var or pass `--manifests-dir`.
```bash
# Via env var
CLAUDE_CODE_UPSTREAM=/path/to/upstream claw dump-manifests
# Via flag
claw dump-manifests --manifests-dir /path/to/upstream
```
**When to use:** Parity work (comparing the Rust port's tool/plugin surface against the canonical TypeScript implementation). Not needed for normal operation.
**Error mode:** If upstream sources are missing, exits with `error-kind: missing_manifests` and a hint about how to provide them.
### `bootstrap-plan` — Show startup component graph
**Purpose:** Print the ordered list of startup components that are initialized when `claw` begins a session. Useful for debugging startup issues or verifying that fast-path optimizations are in place.
```bash
claw bootstrap-plan
```
**Sample output:**
```
- CliEntry
- FastPathVersion
- StartupProfiler
- SystemPromptFastPath
- ChromeMcpFastPath
```
**When to use:**
- Debugging why startup is slow (compare your plan to the expected one)
- Verifying that fast-path components are registered
- Understanding the load order before customizing hooks or plugins
**Related:** See `claw doctor` for health checks against these startup components.
### `acp` — Agent Context Protocol / Zed editor integration status
**Purpose:** Report the current state of the ACP (Agent Context Protocol) / Zed editor integration. Currently **discoverability only** — no editor daemon is available yet.
```bash
claw acp
claw acp serve # same output; `serve` is accepted but not yet launchable
claw --acp # alias
claw -acp # alias
```
**Sample output:**
```
ACP / Zed
Status discoverability only
Launch `claw acp serve` / `claw --acp` / `claw -acp` report status only; no editor daemon is available yet
Today use `claw prompt`, the REPL, or `claw doctor` for local verification
Tracking ROADMAP #76
```
**When to use:** Check whether ACP/Zed integration is ready in your current build. Plan around its availability (track ROADMAP #76 for status).
**Today's alternatives:** Use `claw prompt` for one-shot runs, the interactive REPL for iterative work, or `claw doctor` for local verification.
### `export` — Export session transcript
**Purpose:** Export a managed session's transcript to a file or stdout. Operates on the currently-resumed session (requires `--resume`).
```bash
# Export latest session
claw --resume latest export
# Export specific session
claw --resume <session-id> export
```
**Prerequisite:** A managed session must exist under `.claw/sessions/<workspace-fingerprint>/`. If no sessions exist, the command exits with `error-kind: no_managed_sessions` and a hint to start a session first.
**When to use:**
- Archive session transcripts for review
- Share session context with teammates
- Feed session history into downstream tooling
**Related:** Inside the REPL, `/export` is also available as a slash command for the active session.
## Session management
REPL turns are persisted under `.claw/sessions/` in the current workspace.
```bash
cd rust
./target/debug/claw --resume latest
./target/debug/claw --resume latest /status /diff
```
### Interactive slash commands (inside the REPL)
Useful interactive commands include:
- `/help` — Show help for all available commands
- `/status` — Display current session and workspace status
- `/cost` — Show token usage and cost estimates for the session
- `/config` — Display current configuration and environment state
- `/session` — Show session ID, creation time, and persisted metadata
- `/model` — Display or switch the active model
- `/permissions` — Check sandbox permissions and capability grants
- `/export [file]` — Export the current conversation to a file (or resume from backup)
- `/ultraplan [task]` — Run a deep planning prompt with multi-step reasoning (good for complex refactoring tasks)
- `/teleport <symbol-or-path>` — Jump to a file or symbol by searching the workspace (IDE-like navigation)
- `/bughunter [scope]` — Inspect the codebase for likely bugs in an optional scope (e.g., `src/runtime`)
- `/commit` — Generate a commit message and create a git commit from the conversation
- `/pr [context]` — Draft or create a pull request from the conversation
- `/issue [context]` — Draft or create a GitHub issue from the conversation
- `/diff` — Show unified diff of changes made in the current session
- `/plugin [list|install|enable|disable|uninstall|update]` — Manage Claw Code plugins
- `/agents [list|help]` — List configured agents or get help on agent commands
## Config file resolution order
Runtime config is loaded in this order, with later entries overriding earlier ones:
1. `~/.claw.json`
2. `~/.config/claw/settings.json`
3. `<repo>/.claw.json`
4. `<repo>/.claw/settings.json`
5. `<repo>/.claw/settings.local.json`
## Mock parity harness
The workspace includes a deterministic Anthropic-compatible mock service and parity harness.
```bash
cd rust
./scripts/run_mock_parity_harness.sh
```
Manual mock service startup:
```bash
cd rust
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
## Verification
```bash
cd rust
cargo test --workspace
```
## Workspace overview
Current Rust crates:
- `api`
- `commands`
- `compat-harness`
- `mock-anthropic-service`
- `plugins`
- `runtime`
- `rusty-claude-cli`
- `telemetry`
- `tools`
## Documentation
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System overview, crate layout, request flow
- [CONFIGURATION.md](docs/CONFIGURATION.md) — Env vars, settings.json, provider config
- [SUPPORTED_PROVIDERS.md](docs/SUPPORTED_PROVIDERS.md) — Provider/model matrix
- [API_REFERENCE.md](docs/API_REFERENCE.md) — JSON output envelope, error format
- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — Common failure modes and mitigation
- [ROADMAP.md](ROADMAP.md) — Pinpoint-driven development roadmap
- [CONTRIBUTING.md](CONTRIBUTING.md) — How to contribute, pinpoint format
- [PINPOINT_FILING_GUIDE.md](docs/PINPOINT_FILING_GUIDE.md) — Step-by-step pinpoint workflow
- [CHANGELOG.md](CHANGELOG.md) — Recent changes
- [SECURITY.md](SECURITY.md) — Responsible disclosure
- [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) — Community standards

View File

Before

Width:  |  Height:  |  Size: 233 KiB

After

Width:  |  Height:  |  Size: 233 KiB

BIN
assets/instructkr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -1,174 +0,0 @@
# API Reference — JSON Output Envelope Contract
This document describes the machine-readable JSON output emitted by `claw` when
`--output-format json` is passed. All JSON envelopes are written to **stdout**.
Stderr is reserved for non-contractual diagnostics only (see pinpoint #168c).
---
## Output Format Flag
```
claw [command] --output-format json
claw [command] --output-format text # default
```
When `json` is active, **all** output (success and error) is emitted as a single
JSON object on stdout. Consumers must not parse stderr for errors.
---
## Success Envelope — `claw -p <prompt>`
Full non-compact run (default):
```json
{
"message": "<final assistant text>",
"model": "claude-opus-4-5",
"iterations": 3,
"auto_compaction": null,
"tool_uses": [...],
"tool_results": [...],
"prompt_cache_events": [...],
"usage": {
"input_tokens": 1234,
"output_tokens": 567,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
},
"estimated_cost": "$0.0123"
}
```
Compact run (`--compact`):
```json
{
"message": "<final assistant text>",
"compact": true,
"model": "claude-opus-4-5",
"usage": {
"input_tokens": 1234,
"output_tokens": 567,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0
}
}
```
### Field Reference
| Field | Type | Description |
|---|---|---|
| `message` | string | Final assistant reply text |
| `model` | string | Model identifier used for the turn |
| `iterations` | integer | Number of tool-use / re-prompt iterations |
| `compact` | boolean | Present and `true` when `--compact` mode was active |
| `auto_compaction` | object\|null | Non-null when auto-compaction fired (see below) |
| `tool_uses` | array | Tool calls made during the turn (TODO: verify schema) |
| `tool_results` | array | Results returned to the model (TODO: verify schema) |
| `prompt_cache_events` | array | Cache-hit/miss events (TODO: verify schema) |
| `usage.input_tokens` | integer | Input tokens billed |
| `usage.output_tokens` | integer | Output tokens billed |
| `usage.cache_creation_input_tokens` | integer | Tokens written to prompt cache |
| `usage.cache_read_input_tokens` | integer | Tokens served from prompt cache |
| `estimated_cost` | string | Human-readable USD cost estimate (e.g. `"$0.0123"`) |
#### `auto_compaction` sub-object
```json
{
"removed_messages": 12,
"notice": "Auto-compacted: removed 12 messages to free context."
}
```
---
## Error Envelope
When a command fails under `--output-format json`, an error envelope is written
to **stdout** (pinpoint #168c / #288):
```json
{
"type": "error",
"error": "<short human-readable reason>",
"kind": "<snake_case error kind token>",
"hint": "<optional actionable hint>"
}
```
### Error Envelope Fields
| Field | Type | Description |
|---|---|---|
| `type` | string | Always `"error"` |
| `error` | string | Short prose description of the failure |
| `kind` | string | Machine-readable snake_case token (see §Error Kinds) |
| `hint` | string\|null | Optional remediation hint |
### Error Kinds (selected)
`kind` values are classified by `classify_error_kind()`. Common tokens include:
- `not_yet_implemented` — command stub not yet shipped
- `config_error` — configuration file parse / validation failure
- `auth_error` — API key or credential problem
- `permission_denied` — tool-use permission denied
- `model_error` — upstream model API error
See pinpoint #266 (typed-error-kind) for the full taxonomy.
---
## Streaming Behavior
`claw` always uses streaming internally (HTTP chunked transfer to the Anthropic
API) but the **JSON output envelope is emitted once**, after the turn completes.
There is no per-token or per-chunk JSON stream exposed to the caller.
In REPL / interactive mode (`claw` with no `-p`) the JSON format applies only to
structured sub-commands, not to the interactive session itself.
---
## Status Snapshot (`claw status`)
```json
{
"kind": "status",
"status": "ok",
"config_load_error": null,
"model": "claude-opus-4-5",
"model_source": "config",
"model_raw": null,
"permission_mode": "default",
"usage": {
"messages": 42,
"turns": 10,
"latest_total": 5678,
"cumulative_input": 12345,
"cumulative_output": 4567,
"cumulative_total": 16912,
"estimated_tokens": 16912
},
"workspace": {
"cwd": "/Users/you/project",
"project_root": "/Users/you/project",
"git_branch": "main",
"git_state": "clean",
"changed_files": 0
}
}
```
---
## Related Pinpoints
- **#288** — error-envelope stdout emission contract
- **#266** — typed-error-kind taxonomy
- **#168c** — `--output-format json` routes error envelopes to stdout
- **#247** — JSON envelope field preservation (hint / help text)

View File

@@ -1,110 +0,0 @@
# claw-code Architecture
A high-level overview of how claw-code is structured. For implementation details, see source code in `rust/crates/`. For provider details, see [SUPPORTED_PROVIDERS.md](./SUPPORTED_PROVIDERS.md). For pinpoint navigation, see [ROADMAP.md](../ROADMAP.md#pinpoint-cluster-index).
## Overview
claw-code is a Rust-based CLI for interacting with LLM providers (Anthropic, OpenAI-compatible, xAI, DashScope, etc.). It provides:
- Streaming conversation with auto-compaction
- Tool execution (file read/write, bash, MCP)
- Multi-provider routing
- Session persistence
- Parallel agent execution
## Workspace Layout
The Rust workspace is organized in `rust/crates/`:
### Core crates
- **`rusty-claude-cli`** — CLI entry point. Parses args, routes commands, manages TUI/headless modes.
- **`runtime`** — Conversation engine. Manages session state, message history, auto-compaction, tool dispatch, hooks, MCP, and branch/lane events.
- **`api`** — Provider abstraction. Hosts `MODEL_REGISTRY` (provider/model routing), SSE streaming, request/response handling. Providers: `anthropic`, `openai_compat`.
- **`tools`** — Tool definitions. File I/O, bash execution, MCP integration, PDF extraction.
### Support crates
- **`commands`** — Parsed command dispatch layer between CLI and runtime.
- **`plugins`** — Plugin/hook lifecycle (`hooks.rs`).
- **`telemetry`** — Metrics and tracing instrumentation.
- **`compat-harness`** — Parity test harness for Rust-port validation.
- **`mock-anthropic-service`** — Local mock server for offline/test use.
## Request Flow
1. **CLI parse** (`rusty-claude-cli/src/main.rs`) — interprets args, env vars, settings.json
2. **Provider selection** (`api/src/providers/mod.rs`) — routes to provider via `MODEL_REGISTRY` based on model prefix
3. **Conversation execution** (`runtime/src/conversation.rs`) — sends to provider via SSE, receives streamed response
4. **Tool dispatch** (`tools/src/lib.rs`) — if response includes `tool_use`, execute and feed back `tool_result`
5. **Auto-compaction check** (`runtime/src/compact.rs`) — REACTIVE-AFTER-SUCCESS only (see #287 for preflight gap)
6. **Output** — JSON envelope (`--output-format json`) or text (default)
## Key Subsystems
### Auto-compaction
Triggered post-turn when `usage.input_tokens > threshold`. See:
- Threshold via env-only (#283)
- Reactive-not-preflight (#287, CRITICAL)
- Manual `/compact` skip-reasons (#289)
- Failure envelope coverage (#288)
### Provider routing
Hard-coded `MODEL_REGISTRY` + env-var-based auth + model-prefix heuristics. See:
- [SUPPORTED_PROVIDERS.md](./SUPPORTED_PROVIDERS.md) for current providers
- #285 for declarative providers/models/websearch source-of-truth
- #245, #246 for declarative config & backend swap
- #290, #291, #292 for transport resilience (stream-init, circuit-breaker, escalation)
### Parallel agents
Lane-based execution via `runtime/src/lane_events.rs`. Manifest-driven lifecycle. See:
- #286 for detached-thread + no-heartbeat issue (CRITICAL)
### Tool lifecycle / hooks
Tools defined in `tools/src/`. Hook events emitted via `runtime/src/hooks.rs` and `plugins/src/hooks.rs`. See:
- #254 (MCP refresh)
- #268 (tool-rendering parity)
- #274 (hook-execution-event envelope)
- #280 (hook event tap)
### Session persistence
Sessions managed in `runtime/src/session.rs`. See:
- #278 (version-comparison)
- #279 (unknown-field policy)
### CLI dispatch
CLI parsing in `rusty-claude-cli/src/main.rs`. Issues:
- #262 `--max-turns` spec
- #267 `--cwd` runtime fix
- #272 position-independent parsing
- #282 env-vs-config consolidation
## Build & Test
See [CONTRIBUTING.md](../CONTRIBUTING.md) for build commands. Quick reference:
```
cd rust && cargo build # Build all crates
cd rust && cargo test # Run all Rust tests
```
## Tracing & Debugging
- **Session state:** `runtime/src/session.rs` + `~/.claw/sessions/<id>/`
- **Provider responses:** Set `RUST_LOG=trace` for verbose SSE logs
- **Parity checks:** Use `compat-harness` crate for Rust-port validation
## Related Documents
- [ROADMAP.md](../ROADMAP.md) — Pinpoints by cluster
- [TROUBLESHOOTING.md](../TROUBLESHOOTING.md) — User-facing failure mitigation
- [SUPPORTED_PROVIDERS.md](./SUPPORTED_PROVIDERS.md) — Provider/model details
- [CONTRIBUTING.md](../CONTRIBUTING.md) — Pinpoint filing format
- [PINPOINT_FILING_GUIDE.md](./PINPOINT_FILING_GUIDE.md) — Filing workflow
- [CHANGELOG.md](../CHANGELOG.md) — Recent changes

View File

@@ -1,96 +0,0 @@
# Configuration
claw-code configuration reference. For provider details, see [SUPPORTED_PROVIDERS.md](./SUPPORTED_PROVIDERS.md). For architecture, see [ARCHITECTURE.md](./ARCHITECTURE.md).
## Configuration Sources
claw-code reads configuration from multiple sources (in priority order):
1. **CLI flags** — highest priority (e.g., `--model`, `--max-turns`, `--cwd`)
2. **Environment variables**`ANTHROPIC_*`, `OPENAI_*`, `XAI_*`, `DASHSCOPE_*`, `CLAW_*`, etc.
3. **settings.json**`.claw/settings.json` in the project directory, or `~/.claw/settings.json` as a user-level default
4. **Hardcoded defaults** — lowest priority
> **Known issue (#283):** Auto-compaction threshold (`CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS`) is env-var-only; no `settings.json` key exists yet.
> **Known issue (#282):** env-vs-config consolidation is incomplete; some settings only work in one source.
## Environment Variables
### Provider Authentication
| Variable | Provider | Notes |
|----------|----------|-------|
| `ANTHROPIC_API_KEY` | Anthropic (Claude models) | Primary credential for Claude |
| `ANTHROPIC_AUTH_TOKEN` | Anthropic | Alternative to `ANTHROPIC_API_KEY` |
| `ANTHROPIC_BASE_URL` | Anthropic | Custom endpoint (e.g., proxy) |
| `OPENAI_API_KEY` | OpenAI-compatible | Required for `gpt-*` / `openai/` models |
| `OPENAI_BASE_URL` | OpenAI-compatible | Custom endpoint (OpenRouter, Ollama, etc.) |
| `XAI_API_KEY` | xAI (Grok models) | Required for `grok-*` models |
| `XAI_BASE_URL` | xAI | Custom endpoint |
| `DASHSCOPE_API_KEY` | DashScope (Qwen/Kimi models) | Required for `qwen-*` / `kimi-*` models |
| `DASHSCOPE_BASE_URL` | DashScope | Custom endpoint |
### Model Selection
| Variable | Default | Description |
|----------|---------|-------------|
| `ANTHROPIC_MODEL` | `claude-sonnet-4-6` | Default model when `--model` flag is not passed |
### Runtime Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS` | provider-specific | Auto-compaction trigger threshold (see #283) |
| `CLAW_CONFIG_HOME` | `~/.claw` | Override config directory location |
| `CLAWD_WEB_SEARCH_BASE_URL` | (built-in) | Custom base URL for web search tool |
| `CLAWD_TODO_STORE` | `~/.claw/todos` | Override todo storage path |
| `CLAWD_AGENT_STORE` | `~/.claw/agents` | Override agent store path |
| `RUST_LOG` | `info` | Log verbosity (`trace`/`debug`/`info`/`warn`/`error`) |
**Related paths also respected:** `CODEX_HOME`, `CLAUDE_CONFIG_DIR` (legacy compatibility).
## settings.json
Located at `.claw/settings.json` (project-local) or `~/.claw/settings.json` (user-level). Project-local takes precedence over user-level.
Example:
```json
{
"model": "claude-sonnet-4-6"
}
```
`claw /config` shows the merged, resolved configuration from all sources.
> **Known gap (#285):** No declarative `providers` or `models` block in `settings.json`. Provider selection is currently model-prefix-based via a hardcoded `MODEL_REGISTRY`. See [SUPPORTED_PROVIDERS.md](./SUPPORTED_PROVIDERS.md) for the full provider/model matrix.
## Provider Selection
Provider is auto-selected from model name prefix or the `openai/` namespace prefix:
| Model pattern | Provider | Auth env |
|--------------|----------|----------|
| `claude-*` | Anthropic | `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` |
| `gpt-*`, `openai/*` | OpenAI-compatible | `OPENAI_API_KEY` |
| `grok-*` | xAI | `XAI_API_KEY` |
| `qwen-*`, `kimi-*` | DashScope | `DASHSCOPE_API_KEY` |
When `OPENAI_BASE_URL` is set, the OpenAI-compatible provider is preferred for unrecognised model names — useful for Ollama or OpenRouter.
## Session Storage
Sessions are stored in `~/.claw/sessions/<session-id>/` (or under `CLAW_CONFIG_HOME`). Each session contains:
- Conversation history (messages)
- Session metadata (model, created_at, etc.)
- Tool execution state
See pinpoints #278 (version-comparison) and #279 (unknown-field policy) for known session persistence caveats.
## Related Documents
- [SUPPORTED_PROVIDERS.md](./SUPPORTED_PROVIDERS.md) — Provider/model matrix and auth details
- [ARCHITECTURE.md](./ARCHITECTURE.md) — Crate layout and request flow
- [TROUBLESHOOTING.md](../TROUBLESHOOTING.md) — Failure mitigation
- [ROADMAP.md](../ROADMAP.md) — Pinpoints by cluster

View File

@@ -1,236 +0,0 @@
# Model Compatibility Guide
This document describes model-specific handling in the OpenAI-compatible provider. When adding new models or providers, review this guide to ensure proper compatibility.
## Table of Contents
- [Overview](#overview)
- [Model-Specific Handling](#model-specific-handling)
- [Kimi Models (is_error Exclusion)](#kimi-models-is_error-exclusion)
- [Reasoning Models (Tuning Parameter Stripping)](#reasoning-models-tuning-parameter-stripping)
- [GPT-5 (max_completion_tokens)](#gpt-5-max_completion_tokens)
- [Qwen Models (DashScope Routing)](#qwen-models-dashscope-routing)
- [Implementation Details](#implementation-details)
- [Adding New Models](#adding-new-models)
- [Testing](#testing)
## Overview
The `openai_compat.rs` provider translates Claude Code's internal message format to OpenAI-compatible chat completion requests. Different models have varying requirements for:
- Tool result message fields (`is_error`)
- Sampling parameters (temperature, top_p, etc.)
- Token limit fields (`max_tokens` vs `max_completion_tokens`)
- Base URL routing
## Model-Specific Handling
### Kimi Models (is_error Exclusion)
**Affected models:** `kimi-k2.5`, `kimi-k1.5`, `kimi-moonshot`, and any model with `kimi` in the name (case-insensitive)
**Behavior:** The `is_error` field is **excluded** from tool result messages.
**Rationale:** Kimi models (via Moonshot AI and DashScope) reject the `is_error` field with a 400 Bad Request error:
```json
{
"error": {
"type": "invalid_request_error",
"message": "Unknown field: is_error"
}
}
```
**Detection:**
```rust
fn model_rejects_is_error_field(model: &str) -> bool {
let lowered = model.to_ascii_lowercase();
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
canonical.starts_with("kimi-")
}
```
**Testing:** See `model_rejects_is_error_field_detects_kimi_models` and related tests in `openai_compat.rs`.
---
### Reasoning Models (Tuning Parameter Stripping)
**Affected models:**
- OpenAI: `o1`, `o1-*`, `o3`, `o3-*`, `o4`, `o4-*`
- xAI: `grok-3-mini`
- Alibaba DashScope: `qwen-qwq-*`, `qwq-*`, `qwen3-*-thinking`
**Behavior:** The following tuning parameters are **stripped** from requests:
- `temperature`
- `top_p`
- `frequency_penalty`
- `presence_penalty`
**Rationale:** Reasoning/chain-of-thought models use fixed sampling strategies and reject these parameters with 400 errors.
**Exception:** `reasoning_effort` is included for compatible models when explicitly set.
**Detection:**
```rust
fn is_reasoning_model(model: &str) -> bool {
let canonical = model.to_ascii_lowercase()
.rsplit('/')
.next()
.unwrap_or(model);
canonical.starts_with("o1")
|| canonical.starts_with("o3")
|| canonical.starts_with("o4")
|| canonical == "grok-3-mini"
|| canonical.starts_with("qwen-qwq")
|| canonical.starts_with("qwq")
|| (canonical.starts_with("qwen3") && canonical.contains("-thinking"))
}
```
**Testing:** See `reasoning_model_strips_tuning_params`, `grok_3_mini_is_reasoning_model`, and `qwen_reasoning_variants_are_detected` tests.
---
### GPT-5 (max_completion_tokens)
**Affected models:** All models starting with `gpt-5`
**Behavior:** Uses `max_completion_tokens` instead of `max_tokens` in the request payload.
**Rationale:** GPT-5 models require the `max_completion_tokens` field. Legacy `max_tokens` causes request validation failures:
```json
{
"error": {
"message": "Unknown field: max_tokens"
}
}
```
**Implementation:**
```rust
let max_tokens_key = if wire_model.starts_with("gpt-5") {
"max_completion_tokens"
} else {
"max_tokens"
};
```
**Testing:** See `gpt5_uses_max_completion_tokens_not_max_tokens` and `non_gpt5_uses_max_tokens` tests.
---
### Qwen Models (DashScope Routing)
**Affected models:** All models with `qwen` prefix
**Behavior:** Routed to DashScope (`https://dashscope.aliyuncs.com/compatible-mode/v1`) rather than default providers.
**Rationale:** Qwen models are hosted by Alibaba Cloud's DashScope service, not OpenAI or Anthropic.
**Configuration:**
```rust
pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
```
**Authentication:** Uses `DASHSCOPE_API_KEY` environment variable.
**Note:** Some Qwen models are also reasoning models (see [Reasoning Models](#reasoning-models-tuning-parameter-stripping) above) and receive both treatments.
## Implementation Details
### File Location
All model-specific logic is in:
```
rust/crates/api/src/providers/openai_compat.rs
```
### Key Functions
| Function | Purpose |
|----------|---------|
| `model_rejects_is_error_field()` | Detects models that don't support `is_error` in tool results |
| `is_reasoning_model()` | Detects reasoning models that need tuning param stripping |
| `translate_message()` | Converts internal messages to OpenAI format (applies `is_error` logic) |
| `build_chat_completion_request()` | Constructs full request payload (applies all model-specific logic) |
### Provider Prefix Handling
All model detection functions strip provider prefixes (e.g., `dashscope/kimi-k2.5``kimi-k2.5`) before matching:
```rust
let canonical = model.to_ascii_lowercase()
.rsplit('/')
.next()
.unwrap_or(model);
```
This ensures consistent detection regardless of whether models are referenced with or without provider prefixes.
## Adding New Models
When adding support for new models:
1. **Check if the model is a reasoning model**
- Does it reject temperature/top_p parameters?
- Add to `is_reasoning_model()` detection
2. **Check tool result compatibility**
- Does it reject the `is_error` field?
- Add to `model_rejects_is_error_field()` detection
3. **Check token limit field**
- Does it require `max_completion_tokens` instead of `max_tokens`?
- Update the `max_tokens_key` logic
4. **Add tests**
- Unit test for detection function
- Integration test in `build_chat_completion_request`
5. **Update this documentation**
- Add the model to the affected lists
- Document any special behavior
## Testing
### Running Model-Specific Tests
```bash
# All OpenAI compatibility tests
cargo test --package api providers::openai_compat
# Specific test categories
cargo test --package api model_rejects_is_error_field
cargo test --package api reasoning_model
cargo test --package api gpt5
cargo test --package api qwen
```
### Test Files
- Unit tests: `rust/crates/api/src/providers/openai_compat.rs` (in `mod tests`)
- Integration tests: `rust/crates/api/tests/openai_compat_integration.rs`
### Verifying Model Detection
To verify a model is detected correctly without making API calls:
```rust
#[test]
fn my_new_model_is_detected() {
// is_error handling
assert!(model_rejects_is_error_field("my-model"));
// Reasoning model detection
assert!(is_reasoning_model("my-model"));
// Provider prefix handling
assert!(model_rejects_is_error_field("provider/my-model"));
}
```
---
*Last updated: 2026-04-16*
For questions or updates, see the implementation in `rust/crates/api/src/providers/openai_compat.rs`.

View File

@@ -1,101 +0,0 @@
# Pinpoint Filing Guide
This guide walks through the workflow for filing a new claw-code pinpoint, from initial friction to merged ROADMAP entry. For format details, see [CONTRIBUTING.md](../CONTRIBUTING.md). For issue template, see [.github/ISSUE_TEMPLATE/pinpoint.md](../.github/ISSUE_TEMPLATE/pinpoint.md).
## What is a Pinpoint?
A pinpoint is a precise, distinct claw-code clawability gap captured in ROADMAP.md format. Pinpoints differ from generic issues by:
- **Specificity:** Exact file paths, function names, line numbers when available
- **Distinctness:** Verified not already covered by existing pinpoints
- **Live evidence:** Real friction event, not hypothetical
- **Fix shape:** Concrete delta proposal, not vague "should improve X"
## Workflow
### Step 1: Identify friction
Use claw-code in real work. When you hit friction (slow startup, broken behavior, opaque error, missing feature, test brittleness, etc.), STOP and capture:
- What you were trying to do
- What you expected to happen
- What actually happened
- Exact error message / log output (verbatim)
### Step 2: Identify distinct axis
Open ROADMAP.md and search for related existing pinpoints (use the [Cluster Index](../ROADMAP.md#pinpoint-cluster-index)).
For each candidate match:
- Does the existing pinpoint cover this exact symptom?
- Does it cover this exact axis (e.g., timing vs envelope vs config)?
- Is your case a SUBSET, a SUPERSET, or an ORTHOGONAL axis?
If your case is orthogonal, file new. If subset, add live-evidence as additional context to existing pinpoint. If superset, file new + cross-reference existing.
### Step 3: Verify with code
Before filing, look at the relevant source code:
- `rust/crates/api/src/sse.rs` — provider routing
- `rust/crates/runtime/src/conversation.rs` — auto-compaction logic
- `rust/crates/rusty-claude-cli/src/main.rs` — CLI entry
- Search with grep / ripgrep to find the relevant module
If the code clearly does NOT have the feature you expected, file a pinpoint. If the code DOES have the feature but it's broken, file a bug.
### Step 4: Write the entry
Follow the canonical 5-section format (see [CONTRIBUTING.md](../CONTRIBUTING.md)):
1. **Exact pinpoint** — One precise sentence
2. **Live evidence** — Real friction event with timestamps
3. **Why distinct** — Explicit comparison to nearest existing pinpoints
4. **Concrete delta** — What you're filing (e.g., "ROADMAP.md appended")
5. **Fix shape recorded** — Bullet list of suggested implementation steps
### Step 5: Submit
Append to ROADMAP.md and commit:
```
git add ROADMAP.md
git commit -m "roadmap: #<NNN> filed (<short title>)"
git push origin <branch>
git push fork <branch>
```
Verify three-way parity (local == origin == fork) before posting any update.
## Worked Example: #290 (stream-init failure envelope)
This shows how #290 was filed in real-time on 2026-04-26.
### Step 1: Friction identified
gaebal-gajae's session hit `500 empty_stream: upstream stream closed before first payload` repeatedly (4x in 30 min). Bare-string error surfaced; no diagnostics, no retry guidance.
### Step 2: Distinct axis identified
- #266 (typed-error-kind taxonomy) covers single-failure categorization, NOT stream-init specifically
- #287 (auto-compaction reactive) covers session-size failures, NOT transport
- #288 (JSON envelope failure) covers context-window envelope, NOT stream-init
→ Orthogonal: filed new #290 covering typed-stream-init-failure-envelope
### Step 3: Code verified
Inspected `rust/crates/api/src/sse.rs` — confirmed no `failure_class=upstream_stream_init` discriminant, no retry recommendation in JSON envelope.
### Step 4: Entry written
Used canonical 5-section format. Listed 4 live evidence timestamps. Cross-referenced #266, #287, #288 in "Why distinct."
### Step 5: Submitted
Commit `0f38975`, pushed to both origin and fork, parity verified, Discord post under 1500 chars.
**Total time: ~2 minutes from friction identification to merged ROADMAP entry.**
## Tips
- **File while it's fresh.** Wait too long and you'll forget exact symptoms.
- **Check Cluster Index FIRST** — saves time vs scanning full ROADMAP.
- **Write Fix Shape even if you don't implement.** Helps future contributors.
- **Live evidence with timestamps > theoretical examples.** Real-world friction always wins.

View File

@@ -1,81 +0,0 @@
# Supported Providers
claw-code currently supports the following LLM providers. This is a snapshot of the current code state and may change. The canonical source of truth is `MODEL_REGISTRY` and provider routing logic in `rust/crates/api/src/providers/mod.rs`.
> **Note:** A declarative `providers` / `models` / `websearch` config in `settings.json` is tracked as pinpoint #285 and is not yet implemented. Until then, provider/model selection is determined by:
> 1. The model name prefix (e.g., `claude-`, `grok-`, `openai/`, `qwen/`, `kimi-`)
> 2. Environment variables (e.g., `ANTHROPIC_API_KEY`, `XAI_API_KEY`, `DASHSCOPE_API_KEY`, `OPENAI_API_KEY`)
> 3. Hard-coded heuristics in `MODEL_REGISTRY` and `detect_provider_kind()`
## Anthropic
- **Status:** Primary supported provider
- **Models:**
- `claude-opus-4-6` (alias: `opus`) — 200K context, 32K max output
- `claude-sonnet-4-6` (alias: `sonnet`) — 200K context, 64K max output
- `claude-haiku-4-5-20251213` (alias: `haiku`) — 200K context, 64K max output
- **Auth:** `ANTHROPIC_API_KEY` env var, or OAuth bearer via `claw login` (`ANTHROPIC_AUTH_TOKEN`)
- **Base URL:** `https://api.anthropic.com` (override: `ANTHROPIC_BASE_URL`)
- **Known issues:** Subject to upstream stream-init failures (see #290, #291)
## xAI (Grok)
- **Status:** Supported via OpenAI-compatible client
- **Models:**
- `grok-3` (aliases: `grok`, `grok-3`) — 131K context, 64K max output
- `grok-3-mini` (aliases: `grok-mini`, `grok-3-mini`) — 131K context, 64K max output
- `grok-2` — context/output limits not yet registered in token metadata
- **Auth:** `XAI_API_KEY`
- **Base URL:** `https://api.x.ai/v1` (override: `XAI_BASE_URL`)
- **Known issues:** None currently tracked
## Alibaba DashScope (Qwen / Kimi)
- **Status:** Supported via OpenAI-compatible client pointed at DashScope compatible-mode endpoint
- **Models:**
- `qwen/*` and `qwen-*` prefix — routes to DashScope (e.g., `qwen-plus`, `qwen-max`, `qwen-turbo`, `qwen/qwen3-coder`)
- `kimi-k2.5` (alias: `kimi`) — 256K context, 16K max output
- `kimi-k1.5` — 256K context, 16K max output
- `kimi/*` and `kimi-*` prefix — routes to DashScope
- **Auth:** `DASHSCOPE_API_KEY`
- **Base URL:** `https://dashscope.aliyuncs.com/compatible-mode/v1` (override: `DASHSCOPE_BASE_URL`)
- **Known issues:** None currently tracked
## OpenAI / OpenAI-Compatible Endpoints
- **Status:** Supported via OpenAI-compatible client; also covers local providers (Ollama, LM Studio, vLLM, OpenRouter)
- **Models:** `openai/` prefix (e.g., `openai/gpt-4.1-mini`) or bare `gpt-*` prefix
- **Auth:** `OPENAI_API_KEY`
- **Base URL:** `https://api.openai.com/v1` (override: `OPENAI_BASE_URL` — also used for local providers)
- **Local provider routing:** When `OPENAI_BASE_URL` is set and `OPENAI_API_KEY` is present, unknown model names (e.g., `qwen2.5-coder:7b`) also route here
- **Known issues:** Declarative per-model config tracked in #285
## Web Search
- **Status:** Hard-coded heuristics; declarative `websearch` config tracked in #285
## Provider Selection Order
When the model name has no recognized prefix, `detect_provider_kind()` falls through in this order:
1. Model prefix match (`claude-` → Anthropic, `grok-` → xAI, `openai/` or `gpt-` → OpenAI, `qwen/` or `qwen-` → DashScope, `kimi/` or `kimi-` → DashScope)
2. `OPENAI_BASE_URL` + `OPENAI_API_KEY` set → OpenAI-compat
3. Anthropic credentials found → Anthropic
4. `OPENAI_API_KEY` found → OpenAI
5. `XAI_API_KEY` found → xAI
6. `OPENAI_BASE_URL` set (no key) → OpenAI-compat (for keyless local providers)
7. Default fallback → Anthropic
## Reporting Provider Issues
For provider-specific bugs (e.g., `500 empty_stream` from upstream), see [TROUBLESHOOTING.md](TROUBLESHOOTING.md) for mitigation steps.
For pinpointing a missing provider feature, file via [ISSUE_TEMPLATE/pinpoint.md](../.github/ISSUE_TEMPLATE/pinpoint.md).
## Related Pinpoints
- #245 — Provider declarative config
- #246 — Backend swap
- #285 — Provider/model/websearch source of truth
- #290 — Stream-init failure envelope
- #291 — Repeat-failure circuit-breaker

View File

@@ -1,132 +0,0 @@
# Container-first claw-code workflows
This repo already had **container detection** in the Rust runtime before this document was added:
- `rust/crates/runtime/src/sandbox.rs` detects Docker/Podman/container markers such as `/.dockerenv`, `/run/.containerenv`, matching env vars, and `/proc/1/cgroup` hints.
- `rust/crates/rusty-claude-cli/src/main.rs` exposes that state through the `claw sandbox` / `cargo run -p rusty-claude-cli -- sandbox` report.
- `.github/workflows/rust-ci.yml` runs on `ubuntu-latest`, but it does **not** define a Docker or Podman container job.
- Before this change, the repo did **not** have a checked-in `Dockerfile`, `Containerfile`, or `.devcontainer/` config.
This document adds a small checked-in `Containerfile` so Docker and Podman users have one canonical container workflow.
## What the checked-in container image is for
The root [`../Containerfile`](../Containerfile) gives you a reusable Rust build/test shell with the extra packages this workspace commonly needs (`git`, `pkg-config`, `libssl-dev`, certificates).
It does **not** copy the repository into the image. Instead, the recommended flow is to bind-mount your checkout into `/workspace` so edits stay on the host.
## Build the image
From the repository root:
### Docker
```bash
docker build -t claw-code-dev -f Containerfile .
```
### Podman
```bash
podman build -t claw-code-dev -f Containerfile .
```
## Run `cargo test --workspace` in the container
These commands mount the repo, keep Cargo build artifacts out of the working tree, and run from the Rust workspace at `rust/`.
### Docker
```bash
docker run --rm -it \
-v "$PWD":/workspace \
-e CARGO_TARGET_DIR=/tmp/claw-target \
-w /workspace/rust \
claw-code-dev \
cargo test --workspace
```
### Podman
```bash
podman run --rm -it \
-v "$PWD":/workspace:Z \
-e CARGO_TARGET_DIR=/tmp/claw-target \
-w /workspace/rust \
claw-code-dev \
cargo test --workspace
```
If you want a fully clean rebuild, add `cargo clean &&` before `cargo test --workspace`.
## Open a shell in the container
### Docker
```bash
docker run --rm -it \
-v "$PWD":/workspace \
-e CARGO_TARGET_DIR=/tmp/claw-target \
-w /workspace/rust \
claw-code-dev
```
### Podman
```bash
podman run --rm -it \
-v "$PWD":/workspace:Z \
-e CARGO_TARGET_DIR=/tmp/claw-target \
-w /workspace/rust \
claw-code-dev
```
Inside the shell:
```bash
cargo build --workspace
cargo test --workspace
cargo run -p rusty-claude-cli -- --help
cargo run -p rusty-claude-cli -- sandbox
```
The `sandbox` command is a useful sanity check: inside Docker or Podman it should report `In container true` and list the markers the runtime detected.
## Bind-mount this repo and another repo at the same time
If you want to run `claw` against a second checkout while keeping `claw-code` itself mounted read-write:
### Docker
```bash
docker run --rm -it \
-v "$PWD":/workspace \
-v "$HOME/src/other-repo":/repo \
-e CARGO_TARGET_DIR=/tmp/claw-target \
-w /workspace/rust \
claw-code-dev
```
### Podman
```bash
podman run --rm -it \
-v "$PWD":/workspace:Z \
-v "$HOME/src/other-repo":/repo:Z \
-e CARGO_TARGET_DIR=/tmp/claw-target \
-w /workspace/rust \
claw-code-dev
```
Then, for example:
```bash
cargo run -p rusty-claude-cli -- prompt "summarize /repo"
```
## Notes
- Docker and Podman use the same checked-in `Containerfile`.
- The `:Z` suffix in the Podman examples is for SELinux relabeling; keep it on Fedora/RHEL-class hosts.
- Running with `CARGO_TARGET_DIR=/tmp/claw-target` avoids leaving container-owned `target/` artifacts in your bind-mounted checkout.
- For non-container local development, keep using [`../USAGE.md`](../USAGE.md) and [`../rust/README.md`](../rust/README.md).

View File

@@ -1,394 +0,0 @@
#!/usr/bin/env bash
# Claw Code installer
#
# Detects the host OS, verifies the Rust toolchain (rustc + cargo),
# builds the `claw` binary from the `rust/` workspace, and runs a
# post-install verification step. Supports Linux, macOS, and WSL.
#
# Usage:
# ./install.sh # debug build (fast, default)
# ./install.sh --release # optimized release build
# ./install.sh --no-verify # skip post-install verification
# ./install.sh --help # print usage
#
# Environment overrides:
# CLAW_BUILD_PROFILE=debug|release same as --release toggle
# CLAW_SKIP_VERIFY=1 same as --no-verify
set -euo pipefail
# ---------------------------------------------------------------------------
# Pretty printing
# ---------------------------------------------------------------------------
if [ -t 1 ] && command -v tput >/dev/null 2>&1 && [ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]; then
COLOR_RESET="$(tput sgr0)"
COLOR_BOLD="$(tput bold)"
COLOR_DIM="$(tput dim)"
COLOR_RED="$(tput setaf 1)"
COLOR_GREEN="$(tput setaf 2)"
COLOR_YELLOW="$(tput setaf 3)"
COLOR_BLUE="$(tput setaf 4)"
COLOR_CYAN="$(tput setaf 6)"
else
COLOR_RESET=""
COLOR_BOLD=""
COLOR_DIM=""
COLOR_RED=""
COLOR_GREEN=""
COLOR_YELLOW=""
COLOR_BLUE=""
COLOR_CYAN=""
fi
CURRENT_STEP=0
TOTAL_STEPS=6
step() {
CURRENT_STEP=$((CURRENT_STEP + 1))
printf '\n%s[%d/%d]%s %s%s%s\n' \
"${COLOR_BLUE}" "${CURRENT_STEP}" "${TOTAL_STEPS}" "${COLOR_RESET}" \
"${COLOR_BOLD}" "$1" "${COLOR_RESET}"
}
info() { printf '%s ->%s %s\n' "${COLOR_CYAN}" "${COLOR_RESET}" "$1"; }
ok() { printf '%s ok%s %s\n' "${COLOR_GREEN}" "${COLOR_RESET}" "$1"; }
warn() { printf '%s warn%s %s\n' "${COLOR_YELLOW}" "${COLOR_RESET}" "$1"; }
error() { printf '%s error%s %s\n' "${COLOR_RED}" "${COLOR_RESET}" "$1" 1>&2; }
print_banner() {
printf '%s' "${COLOR_BOLD}"
cat <<'EOF'
____ _ ____ _
/ ___|| | __ _ __ __ / ___|___ __| | ___
| | | | / _` |\ \ /\ / /| | / _ \ / _` |/ _ \
| |___ | || (_| | \ V V / | |__| (_) | (_| | __/
\____||_| \__,_| \_/\_/ \____\___/ \__,_|\___|
EOF
printf '%s\n' "${COLOR_RESET}"
printf '%sClaw Code installer%s\n' "${COLOR_DIM}" "${COLOR_RESET}"
}
print_usage() {
cat <<'EOF'
Usage: ./install.sh [options]
Options:
--release Build the optimized release profile (slower, smaller binary).
--debug Build the debug profile (default, faster compile).
--no-verify Skip the post-install verification step.
-h, --help Show this help text and exit.
Environment overrides:
CLAW_BUILD_PROFILE debug | release
CLAW_SKIP_VERIFY set to 1 to skip verification
EOF
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
BUILD_PROFILE="${CLAW_BUILD_PROFILE:-debug}"
SKIP_VERIFY="${CLAW_SKIP_VERIFY:-0}"
while [ "$#" -gt 0 ]; do
case "$1" in
--release)
BUILD_PROFILE="release"
;;
--debug)
BUILD_PROFILE="debug"
;;
--no-verify)
SKIP_VERIFY="1"
;;
-h|--help)
print_usage
exit 0
;;
*)
error "unknown argument: $1"
print_usage
exit 2
;;
esac
shift
done
case "${BUILD_PROFILE}" in
debug|release) ;;
*)
error "invalid build profile: ${BUILD_PROFILE} (expected debug or release)"
exit 2
;;
esac
# ---------------------------------------------------------------------------
# Troubleshooting hints
# ---------------------------------------------------------------------------
print_troubleshooting() {
cat <<EOF
${COLOR_BOLD}Troubleshooting${COLOR_RESET}
${COLOR_DIM}---------------${COLOR_RESET}
${COLOR_BOLD}1. Rust toolchain missing${COLOR_RESET}
Install Rust via rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Then reload your shell or run:
source "\$HOME/.cargo/env"
${COLOR_BOLD}2. Linux: missing system packages${COLOR_RESET}
The build needs git, pkg-config, and OpenSSL headers.
Debian/Ubuntu:
sudo apt-get update && sudo apt-get install -y \\
git pkg-config libssl-dev ca-certificates build-essential
Fedora/RHEL:
sudo dnf install -y git pkgconf-pkg-config openssl-devel gcc
Arch:
sudo pacman -S --needed git pkgconf openssl base-devel
${COLOR_BOLD}3. macOS: missing Xcode CLT${COLOR_RESET}
Install the command line tools:
xcode-select --install
${COLOR_BOLD}4. Windows users${COLOR_RESET}
Run this script from inside a WSL distro (Ubuntu/Debian recommended).
Native Windows builds are not supported by this installer.
${COLOR_BOLD}5. Build fails partway through${COLOR_RESET}
Try a clean build:
cd rust && cargo clean && cargo build --workspace
If the failure mentions ring/openssl, double check step 2.
${COLOR_BOLD}6. 'claw' not found after install${COLOR_RESET}
The binary lives at:
rust/target/${BUILD_PROFILE}/claw
Add it to your PATH or invoke it with the full path.
EOF
}
trap 'rc=$?; if [ "$rc" -ne 0 ]; then error "installation failed (exit ${rc})"; print_troubleshooting; fi' EXIT
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
require_cmd() {
command -v "$1" >/dev/null 2>&1
}
# ---------------------------------------------------------------------------
# Step 1: detect OS / arch / WSL
# ---------------------------------------------------------------------------
print_banner
step "Detecting host environment"
UNAME_S="$(uname -s 2>/dev/null || echo unknown)"
UNAME_M="$(uname -m 2>/dev/null || echo unknown)"
OS_FAMILY="unknown"
IS_WSL="0"
case "${UNAME_S}" in
Linux*)
OS_FAMILY="linux"
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
IS_WSL="1"
fi
;;
Darwin*)
OS_FAMILY="macos"
;;
MINGW*|MSYS*|CYGWIN*)
OS_FAMILY="windows-shell"
;;
esac
info "uname: ${UNAME_S} ${UNAME_M}"
info "os family: ${OS_FAMILY}"
if [ "${IS_WSL}" = "1" ]; then
info "wsl: yes"
fi
case "${OS_FAMILY}" in
linux|macos)
ok "supported platform detected"
;;
windows-shell)
error "Detected a native Windows shell (MSYS/Cygwin/MinGW)."
error "Please re-run this script from inside a WSL distribution."
exit 1
;;
*)
error "Unsupported or unknown OS: ${UNAME_S}"
error "Supported: Linux, macOS, and Windows via WSL."
exit 1
;;
esac
# ---------------------------------------------------------------------------
# Step 2: locate the Rust workspace
# ---------------------------------------------------------------------------
step "Locating the Rust workspace"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
RUST_DIR="${SCRIPT_DIR}/rust"
if [ ! -d "${RUST_DIR}" ]; then
error "Could not find rust/ workspace next to install.sh"
error "Expected: ${RUST_DIR}"
exit 1
fi
if [ ! -f "${RUST_DIR}/Cargo.toml" ]; then
error "Missing ${RUST_DIR}/Cargo.toml — repository layout looks unexpected."
exit 1
fi
ok "workspace at ${RUST_DIR}"
# ---------------------------------------------------------------------------
# Step 3: prerequisite checks
# ---------------------------------------------------------------------------
step "Checking prerequisites"
MISSING_PREREQS=0
if require_cmd rustc; then
RUSTC_VERSION="$(rustc --version 2>/dev/null || echo 'unknown')"
ok "rustc found: ${RUSTC_VERSION}"
else
error "rustc not found in PATH"
MISSING_PREREQS=1
fi
if require_cmd cargo; then
CARGO_VERSION="$(cargo --version 2>/dev/null || echo 'unknown')"
ok "cargo found: ${CARGO_VERSION}"
else
error "cargo not found in PATH"
MISSING_PREREQS=1
fi
if require_cmd git; then
ok "git found: $(git --version 2>/dev/null || echo 'unknown')"
else
warn "git not found — some workflows (login, session export) may degrade"
fi
if [ "${OS_FAMILY}" = "linux" ]; then
if require_cmd pkg-config; then
ok "pkg-config found"
else
warn "pkg-config not found — may be required for OpenSSL-linked crates"
fi
fi
if [ "${OS_FAMILY}" = "macos" ]; then
if ! require_cmd cc && ! xcode-select -p >/dev/null 2>&1; then
warn "Xcode command line tools not detected — run: xcode-select --install"
fi
fi
if [ "${MISSING_PREREQS}" -ne 0 ]; then
error "Missing required tools. See troubleshooting below."
exit 1
fi
# ---------------------------------------------------------------------------
# Step 4: build the workspace
# ---------------------------------------------------------------------------
step "Building the claw workspace (${BUILD_PROFILE})"
CARGO_FLAGS=("build" "--workspace")
if [ "${BUILD_PROFILE}" = "release" ]; then
CARGO_FLAGS+=("--release")
fi
info "running: cargo ${CARGO_FLAGS[*]}"
info "this may take a few minutes on the first build"
(
cd "${RUST_DIR}"
CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-always}" cargo "${CARGO_FLAGS[@]}"
)
CLAW_BIN="${RUST_DIR}/target/${BUILD_PROFILE}/claw"
if [ ! -x "${CLAW_BIN}" ]; then
error "Expected binary not found at ${CLAW_BIN}"
error "The build reported success but the binary is missing — check cargo output above."
exit 1
fi
ok "built ${CLAW_BIN}"
# ---------------------------------------------------------------------------
# Step 5: post-install verification
# ---------------------------------------------------------------------------
step "Verifying the installed binary"
if [ "${SKIP_VERIFY}" = "1" ]; then
warn "verification skipped (--no-verify or CLAW_SKIP_VERIFY=1)"
else
info "running: claw --version"
if VERSION_OUT="$("${CLAW_BIN}" --version 2>&1)"; then
ok "claw --version -> ${VERSION_OUT}"
else
error "claw --version failed:"
printf '%s\n' "${VERSION_OUT}" 1>&2
exit 1
fi
info "running: claw --help (smoke test)"
if "${CLAW_BIN}" --help >/dev/null 2>&1; then
ok "claw --help responded"
else
error "claw --help failed"
exit 1
fi
fi
# ---------------------------------------------------------------------------
# Step 6: next steps
# ---------------------------------------------------------------------------
step "Next steps"
cat <<EOF
${COLOR_GREEN}Claw Code is built and ready.${COLOR_RESET}
Binary: ${COLOR_BOLD}${CLAW_BIN}${COLOR_RESET}
Profile: ${BUILD_PROFILE}
Try it out:
${COLOR_DIM}# interactive REPL${COLOR_RESET}
${CLAW_BIN}
${COLOR_DIM}# one-shot prompt${COLOR_RESET}
${CLAW_BIN} prompt "summarize this repository"
${COLOR_DIM}# health check (run /doctor inside the REPL)${COLOR_RESET}
${CLAW_BIN}
/doctor
Authentication:
export ANTHROPIC_API_KEY="sk-ant-..."
${COLOR_DIM}# or use OAuth:${COLOR_RESET}
${CLAW_BIN} login
For deeper docs, see USAGE.md and rust/README.md.
EOF
# clear the failure trap on clean exit
trap - EXIT

356
prd.json
View File

@@ -1,356 +0,0 @@
{
"version": "1.0",
"description": "Clawable Coding Harness - Clear roadmap stories and commit each",
"stories": [
{
"id": "US-001",
"title": "Phase 1.6 - startup-no-evidence evidence bundle + classifier",
"description": "When startup times out, emit typed worker.startup_no_evidence event with evidence bundle including last known worker lifecycle state, pane command, prompt-send timestamp, prompt-acceptance state, trust-prompt detection result, and transport/MCP health summary. Classifier should down-rank into specific failure classes.",
"acceptanceCriteria": [
"worker.startup_no_evidence event emitted on startup timeout with evidence bundle",
"Evidence bundle includes: last lifecycle state, pane command, prompt-send timestamp, prompt-acceptance state, trust-prompt detection, transport/MCP health",
"Classifier attempts to categorize into: trust_required, prompt_misdelivery, prompt_acceptance_timeout, transport_dead, worker_crashed, or unknown",
"Tests verify evidence bundle structure and classifier behavior"
],
"passes": true,
"priority": "P0"
},
{
"id": "US-002",
"title": "Phase 2 - Canonical lane event schema (4.x series)",
"description": "Define typed events for lane lifecycle: lane.started, lane.ready, lane.prompt_misdelivery, lane.blocked, lane.red, lane.green, lane.commit.created, lane.pr.opened, lane.merge.ready, lane.finished, lane.failed, branch.stale_against_main. Also implement event ordering, reconciliation, provenance, deduplication, and projection contracts.",
"acceptanceCriteria": [
"LaneEvent enum with all required variants defined",
"Event ordering with monotonic sequence metadata attached",
"Event provenance labels (live_lane, test, healthcheck, replay, transport)",
"Session identity completeness at creation (title, workspace, purpose)",
"Duplicate terminal-event suppression with fingerprinting",
"Lane ownership/scope binding in events",
"Nudge acknowledgment with dedupe contract",
"clawhip consumes typed lane events instead of pane scraping"
],
"passes": true,
"priority": "P0"
},
{
"id": "US-003",
"title": "Phase 3 - Stale-branch detection before broad verification",
"description": "Before broad test runs, compare current branch to main and detect if known fixes are missing. Emit branch.stale_against_main event and suggest/auto-run rebase/merge-forward.",
"acceptanceCriteria": [
"Branch freshness comparison against main implemented",
"branch.stale_against_main event emitted when behind",
"Auto-rebase/merge-forward policy integration",
"Avoid misclassifying stale-branch failures as new regressions"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-004",
"title": "Phase 3 - Recovery recipes with ledger",
"description": "Encode automatic recoveries for common failures (trust prompt, prompt misdelivery, stale branch, compile red, MCP startup). Expose recovery attempt ledger with recipe id, attempt count, state, timestamps, failure summary.",
"acceptanceCriteria": [
"Recovery recipes defined for: trust_prompt_unresolved, prompt_delivered_to_shell, stale_branch, compile_red_after_refactor, MCP_handshake_failure, partial_plugin_startup",
"Recovery attempt ledger with: recipe id, attempt count, state, timestamps, failure summary, escalation reason",
"One automatic recovery attempt before escalation",
"Ledger emitted as structured event data"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-005",
"title": "Phase 4 - Typed task packet format",
"description": "Define structured task packet with fields: objective, scope, repo/worktree, branch policy, acceptance tests, commit policy, reporting contract, escalation policy.",
"acceptanceCriteria": [
"TaskPacket struct with all required fields",
"TaskScope resolution (workspace/module/single-file/custom)",
"Validation and serialization support",
"Integration into tools/src/lib.rs"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-006",
"title": "Phase 4 - Policy engine for autonomous coding",
"description": "Encode automation rules: if green + scoped diff + review passed -> merge to dev; if stale branch -> merge-forward before broad tests; if startup blocked -> recover once, then escalate; if lane completed -> emit closeout and cleanup session.",
"acceptanceCriteria": [
"Policy rules engine implemented",
"Rules: green + scoped diff + review -> merge",
"Rules: stale branch -> merge-forward before tests",
"Rules: startup blocked -> recover once, then escalate",
"Rules: lane completed -> closeout and cleanup"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-007",
"title": "Phase 5 - Plugin/MCP lifecycle maturity",
"description": "First-class plugin/MCP lifecycle contract: config validation, startup healthcheck, discovery result, degraded-mode behavior, shutdown/cleanup. Close gaps in end-to-end lifecycle.",
"acceptanceCriteria": [
"Plugin/MCP config validation contract",
"Startup healthcheck with structured results",
"Discovery result reporting",
"Degraded-mode behavior documented and implemented",
"Shutdown/cleanup contract",
"Partial startup and per-server failures reported structurally"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-008",
"title": "Fix kimi-k2.5 model API compatibility",
"description": "The kimi-k2.5 model (and other kimi models) reject API requests containing the is_error field in tool result messages. The OpenAI-compatible provider currently always includes is_error for all models. Need to make this field conditional based on model support.",
"acceptanceCriteria": [
"translate_message function accepts model parameter",
"is_error field excluded for kimi models (kimi-k2.5, kimi-k1.5, etc.)",
"is_error field included for models that support it (openai, grok, xai, etc.)",
"build_chat_completion_request passes model to translate_message",
"Tests verify is_error presence/absence based on model",
"cargo test passes",
"cargo clippy passes",
"cargo fmt passes"
],
"passes": true,
"priority": "P0"
},
{
"id": "US-009",
"title": "Add unit tests for kimi model compatibility fix",
"description": "During dogfooding we discovered the existing test coverage for model-specific is_error handling is insufficient. Need to add dedicated tests for model_rejects_is_error_field function and translate_message behavior with different models.",
"acceptanceCriteria": [
"Test model_rejects_is_error_field identifies kimi-k2.5, kimi-k1.5, dashscope/kimi-k2.5",
"Test translate_message includes is_error for gpt-4, grok-3, claude models",
"Test translate_message excludes is_error for kimi models",
"Test build_chat_completion_request produces correct payload for kimi vs non-kimi",
"All new tests pass",
"cargo test --package api passes"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-010",
"title": "Add model compatibility documentation",
"description": "Document which models require special handling (is_error exclusion, reasoning model tuning param stripping, etc.) in a MODEL_COMPATIBILITY.md file for operators and contributors.",
"acceptanceCriteria": [
"MODEL_COMPATIBILITY.md created in docs/ or repo root",
"Document kimi models is_error exclusion",
"Document reasoning models (o1, o3, grok-3-mini) tuning param stripping",
"Document gpt-5 max_completion_tokens requirement",
"Document qwen model routing through dashscope",
"Cross-reference with existing code comments"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-011",
"title": "Performance optimization: reduce API request serialization overhead",
"description": "The translate_message function creates intermediate JSON Value objects that could be optimized. Profile and optimize the hot path for API request building, especially for conversations with many tool results.",
"acceptanceCriteria": [
"Profile current request building with criterion or similar",
"Identify bottlenecks in translate_message and build_chat_completion_request",
"Implement optimizations (Vec pre-allocation, reduced cloning, etc.)",
"Benchmark before/after showing improvement",
"No functional changes or API breakage"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-012",
"title": "Trust prompt resolver with allowlist auto-trust",
"description": "Add allowlisted auto-trust behavior for known repos/worktrees. Trust prompts currently block TUI startup and require manual intervention. Implement automatic trust resolution for pre-approved repositories.",
"acceptanceCriteria": [
"TrustAllowlist config structure with repo patterns",
"Auto-trust behavior for allowlisted repos/worktrees",
"trust_required event emitted when trust prompt detected",
"trust_resolved event emitted when trust is granted",
"Non-allowlisted repos remain gated (manual trust required)",
"Integration with worker boot lifecycle",
"Tests for allowlist matching and event emission"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-013",
"title": "Phase 2 - Session event ordering + terminal-state reconciliation",
"description": "When the same session emits contradictory lifecycle events (idle, error, completed, transport/server-down) in close succession, expose deterministic final truth. Attach monotonic sequence/causal ordering metadata, classify terminal vs advisory events, reconcile duplicate/out-of-order terminal events into one canonical lane outcome.",
"acceptanceCriteria": [
"Monotonic sequence / causal ordering metadata attached to session lifecycle events",
"Terminal vs advisory event classification implemented",
"Reconcile duplicate or out-of-order terminal events into one canonical outcome",
"Distinguish 'session terminal state unknown because transport died' from real 'completed'",
"Tests verify reconciliation behavior with out-of-order event bursts"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-014",
"title": "Phase 2 - Event provenance / environment labeling",
"description": "Every emitted event should declare its source (live_lane, test, healthcheck, replay, transport) so claws do not mistake test noise for production truth. Include environment/channel label, emitter identity, and confidence/trust level.",
"acceptanceCriteria": [
"EventProvenance enum with live_lane, test, healthcheck, replay, transport variants",
"Environment/channel label attached to all events",
"Emitter identity field on events",
"Confidence/trust level field for downstream automation",
"Tests verify provenance labeling and filtering"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-015",
"title": "Phase 2 - Session identity completeness at creation time",
"description": "A newly created session should emit stable title, workspace/worktree path, and lane/session purpose at creation time. If any field is not yet known, emit explicit typed placeholder reason rather than bare unknown string.",
"acceptanceCriteria": [
"Session creation emits stable title, workspace/worktree path, purpose immediately",
"Explicit typed placeholder when fields unknown (not bare 'unknown' strings)",
"Later-enriched metadata reconciles onto same session identity without ambiguity",
"Tests verify session identity completeness and placeholder handling"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-016",
"title": "Phase 2 - Duplicate terminal-event suppression",
"description": "When the same session emits repeated completed/failed/terminal notifications, collapse duplicates before they trigger repeated downstream reactions. Attach canonical terminal-event fingerprint per lane/session outcome.",
"acceptanceCriteria": [
"Canonical terminal-event fingerprint attached per lane/session outcome",
"Suppress/coalesce repeated terminal notifications within reconciliation window",
"Preserve raw event history for audit while exposing one actionable outcome downstream",
"Surface when later duplicate materially differs from original terminal payload",
"Tests verify deduplication and material difference detection"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-017",
"title": "Phase 2 - Lane ownership / scope binding",
"description": "Each session and lane event should declare who owns it and what workflow scope it belongs to. Attach owner/assignee identity, workflow scope (claw-code-dogfood, external-git-maintenance, infra-health, manual-operator), and mark whether watcher is expected to act, observe only, or ignore.",
"acceptanceCriteria": [
"Owner/assignee identity attached to sessions and lane events",
"Workflow scope field (claw-code-dogfood, external-git-maintenance, etc.)",
"Watcher action expectation field (act, observe-only, ignore)",
"Preserve scope through session restarts, resumes, and late terminal events",
"Tests verify ownership and scope binding"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-018",
"title": "Phase 2 - Nudge acknowledgment / dedupe contract",
"description": "Periodic clawhip nudges should carry nudge id/cycle id and delivery timestamp. Expose whether claw has already acknowledged or responded for that cycle. Distinguish new nudge, retry nudge, and stale duplicate.",
"acceptanceCriteria": [
"Nudge id / cycle id and delivery timestamp attached",
"Acknowledgment state exposed (already acknowledged or not)",
"Distinguish new nudge vs retry nudge vs stale duplicate",
"Allow downstream summaries to bind reported pinpoint back to triggering nudge id",
"Tests verify nudge deduplication and acknowledgment tracking"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-019",
"title": "Phase 2 - Stable roadmap-id assignment for newly filed pinpoints",
"description": "When a claw records a new pinpoint/follow-up, assign or expose a stable tracking id immediately. Expose that id in structured event/report payload and preserve across edits, reorderings, and summary compression.",
"acceptanceCriteria": [
"Canonical roadmap id assigned at filing time",
"Roadmap id exposed in structured event/report payload",
"Same id preserved across edits, reorderings, summary compression",
"Distinguish 'new roadmap filing' from 'update to existing roadmap item'",
"Tests verify stable id assignment and update detection"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-020",
"title": "Phase 2 - Roadmap item lifecycle state contract",
"description": "Each roadmap pinpoint should carry machine-readable lifecycle state (filed, acknowledged, in_progress, blocked, done, superseded). Attach last state-change timestamp and preserve lineage when one pinpoint supersedes or merges into another.",
"acceptanceCriteria": [
"Lifecycle state enum with filed, acknowledged, in_progress, blocked, done, superseded",
"Last state-change timestamp attached",
"New report can declare first filing, status update, or closure",
"Preserve lineage when one pinpoint supersedes or merges into another",
"Tests verify lifecycle state transitions"
],
"passes": true,
"priority": "P2"
},
{
"id": "US-021",
"title": "Request body size pre-flight check for OpenAI-compatible provider",
"description": "Implement pre-flight request body size estimation to prevent 400 Bad Request errors from API gateways with size limits. Based on dogfood findings with kimi-k2.5 testing, DashScope API has a 6MB request body limit that was exceeded by large system prompts.",
"acceptanceCriteria": [
"Pre-flight size estimation before sending requests to OpenAI-compatible providers",
"Clear error message when request exceeds provider-specific size limit",
"Configuration for different provider limits (6MB DashScope, 100MB OpenAI, etc.)",
"Unit tests for size estimation and limit checking",
"Integration with existing error handling for actionable user messages"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-022",
"title": "Enhanced error context for API failures",
"description": "Add structured error context to API failures including request ID tracking across retries, provider-specific error code mapping, and suggested user actions based on error type (e.g., 'Reduce prompt size' for 413, 'Check API key' for 401).",
"acceptanceCriteria": [
"Request ID tracking across retries with full context in error messages",
"Provider-specific error code mapping with actionable suggestions",
"Suggested user actions for common error types (401, 403, 413, 429, 500, 502-504)",
"Unit tests for error context extraction",
"All existing tests pass and clippy is clean"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-023",
"title": "Add automatic routing for kimi models to DashScope",
"description": "Based on dogfood findings with kimi-k2.5 testing, users must manually prefix with dashscope/kimi-k2.5 instead of just using kimi-k2.5. Add automatic routing for kimi/ and kimi- prefixed models to DashScope (similar to qwen models), and add a 'kimi' alias to the model registry.",
"acceptanceCriteria": [
"kimi/ and kimi- prefix routing to DashScope in metadata_for_model()",
"'kimi' alias in MODEL_REGISTRY that resolves to 'kimi-k2.5'",
"resolve_model_alias() handles the kimi alias correctly",
"Unit tests for kimi routing (similar to qwen routing tests)",
"All tests pass and clippy is clean"
],
"passes": true,
"priority": "P1"
},
{
"id": "US-024",
"title": "Add token limit metadata for kimi models",
"description": "The model_token_limit() function has no entries for kimi-k2.5 or kimi-k1.5, causing preflight context window validation to skip these models. Add token limit metadata to enable preflight checks and accurate max token defaults. Per Moonshot AI documentation, kimi-k2.5 supports 256K context window and 16K max output tokens.",
"acceptanceCriteria": [
"model_token_limit('kimi-k2.5') returns Some(ModelTokenLimit { max_output_tokens: 16384, context_window_tokens: 256000 })",
"model_token_limit('kimi-k1.5') returns appropriate limits",
"model_token_limit('kimi') follows alias chain (kimi → kimi-k2.5) and returns k2.5 limits",
"preflight_message_request() validates context window for kimi models (via generic preflight, no provider-specific code needed)",
"Unit tests verify limits and preflight behavior for kimi models",
"All tests pass and clippy is clean"
],
"passes": true,
"priority": "P1"
}
],
"metadata": {
"lastUpdated": "2026-04-17",
"completedStories": ["US-001", "US-002", "US-003", "US-004", "US-005", "US-006", "US-007", "US-008", "US-009", "US-010", "US-011", "US-012", "US-013", "US-014", "US-015", "US-016", "US-017", "US-018", "US-019", "US-020", "US-021", "US-022", "US-023", "US-024"],
"inProgressStories": [],
"totalStories": 24,
"status": "completed"
}
}

View File

@@ -1,133 +0,0 @@
Ralph Iteration Summary - claw-code Roadmap Implementation
===========================================================
Iteration 1: 2026-04-16
------------------------
US-001 COMPLETED (Phase 1.6 - startup-no-evidence evidence bundle + classifier)
- Files: rust/crates/runtime/src/worker_boot.rs
- Added StartupFailureClassification enum with 6 variants
- Added StartupEvidenceBundle with 8 fields
- Implemented classify_startup_failure() logic
- Added observe_startup_timeout() method to Worker
- Tests: 6 new tests verifying classification logic
US-002 COMPLETED (Phase 2 - Canonical lane event schema)
- Files: rust/crates/runtime/src/lane_events.rs
- Added EventProvenance enum with 5 labels
- Added SessionIdentity, LaneOwnership structs
- Added LaneEventMetadata with sequence/ordering
- Added LaneEventBuilder for construction
- Implemented is_terminal_event(), dedupe_terminal_events()
- Tests: 10 new tests for events and deduplication
US-005 COMPLETED (Phase 4 - Typed task packet format)
- Files:
- rust/crates/runtime/src/task_packet.rs
- rust/crates/runtime/src/task_registry.rs
- rust/crates/tools/src/lib.rs
- Added TaskScope enum (Workspace, Module, SingleFile, Custom)
- Updated TaskPacket with scope_path and worktree fields
- Added validate_scope_requirements() validation logic
- Fixed all test compilation errors in dependent modules
- Tests: Updated existing tests to use new types
PRE-EXISTING IMPLEMENTATIONS (verified working):
------------------------------------------------
US-003 COMPLETE (Phase 3 - Stale-branch detection)
- Files: rust/crates/runtime/src/stale_branch.rs
- BranchFreshness enum (Fresh, Stale, Diverged)
- StaleBranchPolicy (AutoRebase, AutoMergeForward, WarnOnly, Block)
- StaleBranchEvent with structured events
- check_freshness() with git integration
- apply_policy() with policy resolution
- Tests: 12 unit tests + 5 integration tests passing
US-004 COMPLETE (Phase 3 - Recovery recipes with ledger)
- Files: rust/crates/runtime/src/recovery_recipes.rs
- FailureScenario enum with 7 scenarios
- RecoveryStep enum with actionable steps
- RecoveryRecipe with step sequences
- RecoveryLedger for attempt tracking
- RecoveryEvent for structured emission
- attempt_recovery() with escalation logic
- Tests: 15 unit tests + 1 integration test passing
US-006 COMPLETE (Phase 4 - Policy engine for autonomous coding)
- Files: rust/crates/runtime/src/policy_engine.rs
- PolicyRule with condition/action/priority
- PolicyCondition (And, Or, GreenAt, StaleBranch, etc.)
- PolicyAction (MergeToDev, RecoverOnce, Escalate, etc.)
- LaneContext for evaluation context
- evaluate() for rule matching
- Tests: 18 unit tests + 6 integration tests passing
US-007 COMPLETE (Phase 5 - Plugin/MCP lifecycle maturity)
- Files: rust/crates/runtime/src/plugin_lifecycle.rs
- ServerStatus enum (Healthy, Degraded, Failed)
- ServerHealth with capabilities tracking
- PluginState with full lifecycle states
- PluginLifecycle event tracking
- PluginHealthcheck structured results
- DiscoveryResult for capability discovery
- DegradedMode behavior
- Tests: 11 unit tests passing
VERIFICATION STATUS:
------------------
- cargo build --workspace: PASSED
- cargo test --workspace: PASSED (476+ unit tests, 12 integration tests)
- cargo clippy --workspace: PASSED
All 7 stories from prd.json now have passes: true
Iteration 2: 2026-04-16
------------------------
US-009 COMPLETED (Add unit tests for kimi model compatibility fix)
- Files: rust/crates/api/src/providers/openai_compat.rs
- Added 4 comprehensive unit tests:
1. model_rejects_is_error_field_detects_kimi_models - verifies detection of kimi-k2.5, kimi-k1.5, dashscope/kimi-k2.5, case insensitivity
2. translate_message_includes_is_error_for_non_kimi_models - verifies gpt-4o, grok-3, claude include is_error
3. translate_message_excludes_is_error_for_kimi_models - verifies kimi models exclude is_error (prevents 400 Bad Request)
4. build_chat_completion_request_kimi_vs_non_kimi_tool_results - full integration test for request building
- Tests: 4 new tests, 119 unit tests total in api crate (+4), all passing
- Integration tests: 29 passing (no regressions)
US-010 COMPLETED (Add model compatibility documentation)
- Files: docs/MODEL_COMPATIBILITY.md
- Created comprehensive documentation covering:
1. Kimi Models (is_error Exclusion) - documents the 400 Bad Request issue and solution
2. Reasoning Models (Tuning Parameter Stripping) - covers o1, o3, o4, grok-3-mini, qwen-qwq, qwen3-thinking
3. GPT-5 (max_completion_tokens) - documents max_tokens vs max_completion_tokens requirement
4. Qwen Models (DashScope Routing) - explains routing and authentication
- Added implementation details section with key functions
- Added "Adding New Models" guide for future contributors
- Added testing section with example commands
- Cross-referenced with existing code comments in openai_compat.rs
- cargo clippy passes
US-011 COMPLETED (Performance optimization: reduce API request serialization overhead)
- Files:
- rust/crates/api/Cargo.toml (added criterion dev-dependency and bench config)
- rust/crates/api/benches/request_building.rs (new benchmark suite)
- rust/crates/api/src/providers/openai_compat.rs (optimizations)
- rust/crates/api/src/lib.rs (public exports for benchmarks)
- Optimizations implemented:
1. flatten_tool_result_content: Pre-allocate String capacity and avoid intermediate Vec
- Before: collected to Vec<String> then joined
- After: single String with pre-calculated capacity, push directly
2. Made key functions public for benchmarking: translate_message, build_chat_completion_request,
flatten_tool_result_content, is_reasoning_model, model_rejects_is_error_field
- Benchmark results:
- flatten_tool_result_content/single_text: ~17ns
- flatten_tool_result_content/multi_text (10 blocks): ~46ns
- flatten_tool_result_content/large_content (50 blocks): ~11.7µs
- translate_message/text_only: ~200ns
- translate_message/tool_result: ~348ns
- build_chat_completion_request/10 messages: ~16.4µs
- build_chat_completion_request/100 messages: ~209µs
- is_reasoning_model detection: ~26-42ns depending on model
- All tests pass (119 unit tests + 29 integration tests)
- cargo clippy passes

View File

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

View File

@@ -1 +0,0 @@
{"created_at_ms":1775777421902,"session_id":"session-1775777421902-1","type":"session_meta","updated_at_ms":1775777421902,"version":1}

View File

@@ -1,2 +0,0 @@
{"created_at_ms":1775386842352,"session_id":"session-1775386842352-0","type":"session_meta","updated_at_ms":1775386842352,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -1,2 +0,0 @@
{"created_at_ms":1775386852257,"session_id":"session-1775386852257-0","type":"session_meta","updated_at_ms":1775386852257,"version":1}
{"message":{"blocks":[{"text":"doctor --help","type":"text"}],"role":"user"},"type":"message"}

View File

@@ -1,2 +0,0 @@
{"created_at_ms":1775386853666,"session_id":"session-1775386853666-0","type":"session_meta","updated_at_ms":1775386853666,"version":1}
{"message":{"blocks":[{"text":"status --help","type":"text"}],"role":"user"},"type":"message"}

4
rust/.gitignore vendored
View File

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

View File

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

288
rust/Cargo.lock generated
View File

@@ -17,28 +17,14 @@ dependencies = [
"memchr",
]
[[package]]
name = "anes"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "api"
version = "0.1.0"
dependencies = [
"criterion",
"reqwest",
"runtime",
"serde",
"serde_json",
"telemetry",
"tokio",
]
@@ -48,12 +34,6 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.22.1"
@@ -96,12 +76,6 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.58"
@@ -124,58 +98,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "ciborium"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
dependencies = [
"ciborium-io",
"ciborium-ll",
"serde",
]
[[package]]
name = "ciborium-io"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
[[package]]
name = "ciborium-ll"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
dependencies = [
"ciborium-io",
"half",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstyle",
"clap_lex",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -191,7 +113,6 @@ version = "0.1.0"
dependencies = [
"plugins",
"runtime",
"serde_json",
]
[[package]]
@@ -221,67 +142,6 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "criterion"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [
"anes",
"cast",
"ciborium",
"clap",
"criterion-plot",
"is-terminal",
"itertools",
"num-traits",
"once_cell",
"oorandom",
"plotters",
"rayon",
"regex",
"serde",
"serde_derive",
"serde_json",
"tinytemplate",
"walkdir",
]
[[package]]
name = "criterion-plot"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
"itertools",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -307,12 +167,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -353,12 +207,6 @@ dependencies = [
"syn",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "endian-type"
version = "0.1.2"
@@ -395,7 +243,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -530,29 +378,12 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "home"
version = "0.5.12"
@@ -789,26 +620,6 @@ dependencies = [
"serde",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -906,15 +717,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mock-anthropic-service"
version = "0.1.0"
dependencies = [
"api",
"serde_json",
"tokio",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -942,15 +744,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -979,12 +772,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -1039,34 +826,6 @@ dependencies = [
"time",
]
[[package]]
name = "plotters"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
dependencies = [
"num-traits",
"plotters-backend",
"plotters-svg",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "plotters-backend"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
[[package]]
name = "plotters-svg"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
dependencies = [
"plotters-backend",
]
[[package]]
name = "plugins"
version = "0.1.0"
@@ -1245,26 +1004,6 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -1367,7 +1106,6 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"telemetry",
"tokio",
"walkdir",
]
@@ -1388,7 +1126,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -1453,12 +1191,10 @@ dependencies = [
"commands",
"compat-harness",
"crossterm",
"mock-anthropic-service",
"plugins",
"pulldown-cmark",
"runtime",
"rustyline",
"serde",
"serde_json",
"syntect",
"tokio",
@@ -1703,14 +1439,6 @@ dependencies = [
"yaml-rust",
]
[[package]]
name = "telemetry"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "thiserror"
version = "2.0.18"
@@ -1772,16 +1500,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinytemplate"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
@@ -1839,8 +1557,6 @@ name = "tools"
version = "0.1.0"
dependencies = [
"api",
"commands",
"flate2",
"plugins",
"reqwest",
"runtime",

View File

@@ -8,9 +8,6 @@ edition = "2021"
license = "MIT"
publish = false
[workspace.dependencies]
serde_json = "1"
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@@ -1,49 +0,0 @@
# Mock LLM parity harness
This milestone adds a deterministic Anthropic-compatible mock service plus a reproducible CLI harness for the Rust `claw` binary.
## Artifacts
- `crates/mock-anthropic-service/` — mock `/v1/messages` service
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — end-to-end clean-environment harness
- `scripts/run_mock_parity_harness.sh` — convenience wrapper
## Scenarios
The harness runs these scripted scenarios against a fresh workspace and isolated environment variables:
1. `streaming_text`
2. `read_file_roundtrip`
3. `grep_chunk_assembly`
4. `write_file_allowed`
5. `write_file_denied`
6. `multi_tool_turn_roundtrip`
7. `bash_stdout_roundtrip`
8. `bash_permission_prompt_approved`
9. `bash_permission_prompt_denied`
10. `plugin_tool_roundtrip`
## Run
```bash
cd rust/
./scripts/run_mock_parity_harness.sh
```
Behavioral checklist / parity diff:
```bash
cd rust/
python3 scripts/run_mock_parity_diff.py
```
Scenario-to-PARITY mappings live in `mock_parity_scenarios.json`.
## Manual mock server
```bash
cd rust/
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
The server prints `MOCK_ANTHROPIC_BASE_URL=...`; point `ANTHROPIC_BASE_URL` at that URL and use any non-empty `ANTHROPIC_API_KEY`.

View File

@@ -1,148 +0,0 @@
# Parity Status — claw-code Rust Port
Last updated: 2026-04-03
## Mock parity harness — milestone 1
- [x] Deterministic Anthropic-compatible mock service (`rust/crates/mock-anthropic-service`)
- [x] Reproducible clean-environment CLI harness (`rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`)
- [x] Scripted scenarios: `streaming_text`, `read_file_roundtrip`, `grep_chunk_assembly`, `write_file_allowed`, `write_file_denied`
## Mock parity harness — milestone 2 (behavioral expansion)
- [x] Scripted multi-tool turn coverage: `multi_tool_turn_roundtrip`
- [x] Scripted bash coverage: `bash_stdout_roundtrip`
- [x] Scripted permission prompt coverage: `bash_permission_prompt_approved`, `bash_permission_prompt_denied`
- [x] Scripted plugin-path coverage: `plugin_tool_roundtrip`
- [x] Behavioral diff/checklist runner: `rust/scripts/run_mock_parity_diff.py`
## Harness v2 behavioral checklist
Canonical scenario map: `rust/mock_parity_scenarios.json`
- Multi-tool assistant turns
- Bash flow roundtrips
- Permission enforcement across tool paths
- Plugin tool execution path
- File tools — harness-validated flows
## Completed Behavioral Parity Work
Hashes below come from `git log --oneline`. Merge line counts come from `git show --stat <merge>`.
| Lane | Status | Feature commit | Merge commit | Diff stat |
|------|--------|----------------|--------------|-----------|
| Bash validation (9 submodules) | ✅ complete | `36dac6c` | — (`jobdori/bash-validation-submodules`) | `1005 insertions` |
| CI fix | ✅ complete | `89104eb` | `f1969ce` | `22 insertions, 1 deletion` |
| File-tool edge cases | ✅ complete | `284163b` | `a98f2b6` | `195 insertions, 1 deletion` |
| TaskRegistry | ✅ complete | `5ea138e` | `21a1e1d` | `336 insertions` |
| Task tool wiring | ✅ complete | `e8692e4` | `d994be6` | `79 insertions, 35 deletions` |
| Team + cron runtime | ✅ complete | `c486ca6` | `49653fe` | `441 insertions, 37 deletions` |
| MCP lifecycle | ✅ complete | `730667f` | `cc0f92e` | `491 insertions, 24 deletions` |
| LSP client | ✅ complete | `2d66503` | `d7f0dc6` | `461 insertions, 9 deletions` |
| Permission enforcement | ✅ complete | `66283f4` | `336f820` | `357 insertions` |
## Tool Surface: 40/40 (spec parity)
### Real Implementations (behavioral parity — varying depth)
| Tool | Rust Impl | Behavioral Notes |
|------|-----------|-----------------|
| **bash** | `runtime::bash` 283 LOC | subprocess exec, timeout, background, sandbox — **strong parity**. 9/9 requested validation submodules are now tracked as complete via `36dac6c`, with on-main sandbox + permission enforcement runtime support |
| **read_file** | `runtime::file_ops` | offset/limit read — **good parity** |
| **write_file** | `runtime::file_ops` | file create/overwrite — **good parity** |
| **edit_file** | `runtime::file_ops` | old/new string replacement — **good parity**. Missing: replace_all was recently added |
| **glob_search** | `runtime::file_ops` | glob pattern matching — **good parity** |
| **grep_search** | `runtime::file_ops` | ripgrep-style search — **good parity** |
| **WebFetch** | `tools` | URL fetch + content extraction — **moderate parity** (need to verify content truncation, redirect handling vs upstream) |
| **WebSearch** | `tools` | search query execution — **moderate parity** |
| **TodoWrite** | `tools` | todo/note persistence — **moderate parity** |
| **Skill** | `tools` | skill discovery/install — **moderate parity** |
| **Agent** | `tools` | agent delegation — **moderate parity** |
| **TaskCreate** | `runtime::task_registry` + `tools` | in-memory task creation wired into tool dispatch — **good parity** |
| **TaskGet** | `runtime::task_registry` + `tools` | task lookup + metadata payload — **good parity** |
| **TaskList** | `runtime::task_registry` + `tools` | registry-backed task listing — **good parity** |
| **TaskStop** | `runtime::task_registry` + `tools` | terminal-state stop handling — **good parity** |
| **TaskUpdate** | `runtime::task_registry` + `tools` | registry-backed message updates — **good parity** |
| **TaskOutput** | `runtime::task_registry` + `tools` | output capture retrieval — **good parity** |
| **TeamCreate** | `runtime::team_cron_registry` + `tools` | team lifecycle + task assignment — **good parity** |
| **TeamDelete** | `runtime::team_cron_registry` + `tools` | team delete lifecycle — **good parity** |
| **CronCreate** | `runtime::team_cron_registry` + `tools` | cron entry creation — **good parity** |
| **CronDelete** | `runtime::team_cron_registry` + `tools` | cron entry removal — **good parity** |
| **CronList** | `runtime::team_cron_registry` + `tools` | registry-backed cron listing — **good parity** |
| **LSP** | `runtime::lsp_client` + `tools` | registry + dispatch for diagnostics, hover, definition, references, completion, symbols, formatting — **good parity** |
| **ListMcpResources** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource listing — **good parity** |
| **ReadMcpResource** | `runtime::mcp_tool_bridge` + `tools` | connected-server resource reads — **good parity** |
| **MCP** | `runtime::mcp_tool_bridge` + `tools` | stateful MCP tool invocation bridge — **good parity** |
| **ToolSearch** | `tools` | tool discovery — **good parity** |
| **NotebookEdit** | `tools` | jupyter notebook cell editing — **moderate parity** |
| **Sleep** | `tools` | delay execution — **good parity** |
| **SendUserMessage/Brief** | `tools` | user-facing message — **good parity** |
| **Config** | `tools` | config inspection — **moderate parity** |
| **EnterPlanMode** | `tools` | worktree plan mode toggle — **good parity** |
| **ExitPlanMode** | `tools` | worktree plan mode restore — **good parity** |
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
### Stubs Only (surface parity, no behavior)
| Tool | Status | Notes |
|------|--------|-------|
| **AskUserQuestion** | stub | needs live user I/O integration |
| **McpAuth** | stub | needs full auth UX beyond the MCP lifecycle bridge |
| **RemoteTrigger** | stub | needs HTTP client |
| **TestingPermission** | stub | test-only, low priority |
## Slash Commands: 67/141 upstream entries
- 27 original specs (pre-today) — all with real handlers
- 40 new specs — parse + stub handler ("not yet implemented")
- Remaining ~74 upstream entries are internal modules/dialogs/steps, not user `/commands`
### Behavioral Feature Checkpoints (completed work + remaining gaps)
**Bash tool — 9/9 requested validation submodules complete:**
- [x] `sedValidation` — validate sed commands before execution
- [x] `pathValidation` — validate file paths in commands
- [x] `readOnlyValidation` — block writes in read-only mode
- [x] `destructiveCommandWarning` — warn on rm -rf, etc.
- [x] `commandSemantics` — classify command intent
- [x] `bashPermissions` — permission gating per command type
- [x] `bashSecurity` — security checks
- [x] `modeValidation` — validate against current permission mode
- [x] `shouldUseSandbox` — sandbox decision logic
Harness note: milestone 2 validates bash success plus workspace-write escalation approve/deny flows; dedicated validation submodules landed in `36dac6c`, and on-main runtime also carries sandbox + permission enforcement.
**File tools — completed checkpoint:**
- [x] Path traversal prevention (symlink following, ../ escapes)
- [x] Size limits on read/write
- [x] Binary file detection
- [x] Permission mode enforcement (read-only vs workspace-write)
Harness note: read_file, grep_search, write_file allow/deny, and multi-tool same-turn assembly are now covered by the mock parity harness; file edge cases + permission enforcement landed in `a98f2b6` and `336f820`.
**Config/Plugin/MCP flows:**
- [x] Full MCP server lifecycle (connect, list tools, call tool, disconnect)
- [ ] Plugin install/enable/disable/uninstall full flow
- [ ] Config merge precedence (user > project > local)
Harness note: external plugin discovery + execution is now covered via `plugin_tool_roundtrip`; MCP lifecycle landed in `cc0f92e`, while plugin lifecycle + config merge precedence remain open.
## Runtime Behavioral Gaps
- [x] Permission enforcement across all tools (read-only, workspace-write, danger-full-access)
- [ ] Output truncation (large stdout/file content)
- [ ] Session compaction behavior matching
- [ ] Token counting / cost tracking accuracy
- [x] Streaming response support validated by the mock parity harness
Harness note: current coverage now includes write-file denial, bash escalation approve/deny, and plugin workspace-write execution paths; permission enforcement landed in `336f820`.
## Migration Readiness
- [x] `PARITY.md` maintained and honest
- [ ] No `#[ignore]` tests hiding failures (only 1 allowed: `live_stream_smoke_test`)
- [ ] CI green on every commit
- [ ] Codebase shape clean for handoff

View File

@@ -2,26 +2,21 @@
A high-performance Rust rewrite of the Claw Code CLI agent harness. Built for speed, safety, and native tool execution.
For a task-oriented guide with copy/paste examples, see [`../USAGE.md`](../USAGE.md).
## Quick Start
```bash
# Inspect available commands
# Build
cd rust/
cargo run -p rusty-claude-cli -- --help
cargo build --release
# Build the workspace
cargo build --workspace
# Run the interactive REPL
cargo run -p rusty-claude-cli -- --model claude-opus-4-6
# Run interactive REPL
./target/release/claw
# One-shot prompt
cargo run -p rusty-claude-cli -- prompt "explain this codebase"
./target/release/claw prompt "explain this codebase"
# JSON output for automation
cargo run -p rusty-claude-cli -- --output-format json prompt "summarize src/main.rs"
# With specific model
./target/release/claw --model sonnet prompt "fix the bug in main.rs"
```
## Configuration
@@ -34,74 +29,38 @@ export ANTHROPIC_API_KEY="sk-ant-..."
export ANTHROPIC_BASE_URL="https://your-proxy.com"
```
Or provide an OAuth bearer token directly:
Or authenticate via OAuth:
```bash
export ANTHROPIC_AUTH_TOKEN="anthropic-oauth-or-proxy-bearer-token"
claw login
```
## Mock parity harness
The workspace now includes a deterministic Anthropic-compatible mock service and a clean-environment CLI harness for end-to-end parity checks.
```bash
cd rust/
# Run the scripted clean-environment harness
./scripts/run_mock_parity_harness.sh
# Or start the mock service manually for ad hoc CLI runs
cargo run -p mock-anthropic-service -- --bind 127.0.0.1:0
```
Harness coverage:
- `streaming_text`
- `read_file_roundtrip`
- `grep_chunk_assembly`
- `write_file_allowed`
- `write_file_denied`
- `multi_tool_turn_roundtrip`
- `bash_stdout_roundtrip`
- `bash_permission_prompt_approved`
- `bash_permission_prompt_denied`
- `plugin_tool_roundtrip`
Primary artifacts:
- `crates/mock-anthropic-service/` — reusable mock Anthropic-compatible service
- `crates/rusty-claude-cli/tests/mock_parity_harness.rs` — clean-env CLI harness
- `scripts/run_mock_parity_harness.sh` — reproducible wrapper
- `scripts/run_mock_parity_diff.py` — scenario checklist + PARITY mapping runner
- `mock_parity_scenarios.json` — scenario-to-PARITY manifest
## Features
| Feature | Status |
|---------|--------|
| Anthropic / OpenAI-compatible provider flows + streaming | ✅ |
| Direct bearer-token auth via `ANTHROPIC_AUTH_TOKEN` | ✅ |
| Anthropic API + streaming | ✅ |
| OAuth login/logout | ✅ |
| Interactive REPL (rustyline) | ✅ |
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
| Web tools (search, fetch) | ✅ |
| Sub-agent / agent surfaces | ✅ |
| Sub-agent orchestration | ✅ |
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAUDE.md / project memory | ✅ |
| Config file hierarchy (`.claw.json` + merged config sections) | ✅ |
| Config file hierarchy (.claude.json) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle + inspection | ✅ |
| MCP server lifecycle | ✅ |
| Session persistence + resume | ✅ |
| Cost / usage / stats surfaces | ✅ |
| Extended thinking (thinking blocks) | ✅ |
| Cost tracking + usage display | ✅ |
| Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ |
| Direct CLI subcommands (`status`, `sandbox`, `agents`, `mcp`, `skills`, `doctor`) | ✅ |
| Slash commands (including `/skills`, `/agents`, `/mcp`, `/doctor`, `/plugin`, `/subagent`) | ✅ |
| Hooks (`/hooks`, config-backed lifecycle hooks) | ✅ |
| Plugin management surfaces | ✅ |
| Skills inventory / install surfaces | ✅ |
| Machine-readable JSON output across core CLI surfaces | ✅ |
| Slash commands (/status, /compact, /clear, etc.) | ✅ |
| Hooks (PreToolUse/PostToolUse) | 🔧 Config only |
| Plugin system | 📋 Planned |
| Skills registry | 📋 Planned |
## Model Aliases
@@ -113,102 +72,74 @@ Short names resolve to the latest model versions:
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags and Commands
## CLI Flags
Representative current surface:
```text
```
claw [OPTIONS] [COMMAND]
Flags:
--model MODEL
--output-format text|json
--permission-mode MODE
--dangerously-skip-permissions
--allowedTools TOOLS
--resume [SESSION.jsonl|session-id|latest]
--version, -V
Options:
--model MODEL Set the model (alias or full name)
--dangerously-skip-permissions Skip all permission checks
--permission-mode MODE Set read-only, workspace-write, or danger-full-access
--allowedTools TOOLS Restrict enabled tools
--output-format FORMAT Output format (text or json)
--version, -V Print version info
Top-level commands:
prompt <text>
help
version
status
sandbox
acp [serve]
dump-manifests
bootstrap-plan
agents
mcp
skills
system-prompt
init
```
`claw acp` is a local discoverability surface for editor-first users: it reports the current ACP/Zed status without starting the runtime. As of April 16, 2026, claw-code does **not** ship an ACP/Zed daemon entrypoint yet, and `claw acp serve` is only a status alias until the real protocol surface lands.
The command surface is moving quickly. For the canonical live help text, run:
```bash
cargo run -p rusty-claude-cli -- --help
Commands:
prompt <text> One-shot prompt (non-interactive)
login Authenticate via OAuth
logout Clear stored credentials
init Initialize project config
doctor Check environment health
self-update Update to latest version
```
## Slash Commands (REPL)
Tab completion expands slash commands, model aliases, permission modes, and recent session IDs.
The REPL now exposes a much broader surface than the original minimal shell:
- session / visibility: `/help`, `/status`, `/sandbox`, `/cost`, `/resume`, `/session`, `/version`, `/usage`, `/stats`
- workspace / git: `/compact`, `/clear`, `/config`, `/memory`, `/init`, `/diff`, `/commit`, `/pr`, `/issue`, `/export`, `/hooks`, `/files`, `/release-notes`
- discovery / debugging: `/mcp`, `/agents`, `/skills`, `/doctor`, `/tasks`, `/context`, `/desktop`
- automation / analysis: `/review`, `/advisor`, `/insights`, `/security-review`, `/subagent`, `/team`, `/telemetry`, `/providers`, `/cron`, and more
- plugin management: `/plugin` (with aliases `/plugins`, `/marketplace`)
Notable claw-first surfaces now available directly in slash form:
- `/skills [list|install <path>|help]`
- `/agents [list|help]`
- `/mcp [list|show <server>|help]`
- `/doctor`
- `/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]`
- `/subagent [list|steer <target> <msg>|kill <id>]`
See [`../USAGE.md`](../USAGE.md) for usage examples and run `cargo run -p rusty-claude-cli -- --help` for the live canonical command list.
| Command | Description |
|---------|-------------|
| `/help` | Show help |
| `/status` | Show session status (model, tokens, cost) |
| `/cost` | Show cost breakdown |
| `/compact` | Compact conversation history |
| `/clear` | Clear conversation |
| `/model [name]` | Show or switch model |
| `/permissions` | Show or switch permission mode |
| `/config [section]` | Show config (env, hooks, model) |
| `/memory` | Show CLAUDE.md contents |
| `/diff` | Show git diff |
| `/export [path]` | Export conversation |
| `/session [id]` | Resume a previous session |
| `/version` | Show version |
## Workspace Layout
```text
```
rust/
├── Cargo.toml # Workspace root
├── Cargo.lock
└── crates/
├── api/ # Provider clients + streaming + request preflight
├── commands/ # Shared slash-command registry + help rendering
├── api/ # Anthropic API client + SSE streaming
├── commands/ # Shared slash-command registry
├── compat-harness/ # TS manifest extraction harness
├── mock-anthropic-service/ # Deterministic local Anthropic-compatible mock
├── plugins/ # Plugin metadata, manager, install/enable/disable surfaces
├── runtime/ # Session, config, permissions, MCP, prompts, auth/runtime loop
├── runtime/ # Session, config, permissions, MCP, prompts
├── rusty-claude-cli/ # Main CLI binary (`claw`)
── telemetry/ # Session tracing and usage telemetry types
└── tools/ # Built-in tools, skill resolution, tool search, agent runtime surfaces
── tools/ # Built-in tool implementations
```
### Crate Responsibilities
- **api** — provider clients, SSE streaming, request/response types, auth (`ANTHROPIC_API_KEY` + bearer-token support), request-size/context-window preflight
- **commands** — slash command definitions, parsing, help text generation, JSON/text command rendering
- **compat-harness** — extracts tool/prompt manifests from upstream TS source
- **mock-anthropic-service** — deterministic `/v1/messages` mock for CLI parity tests and local harness runs
- **plugins** — plugin metadata, install/enable/disable/update flows, plugin tool definitions, hook integration surfaces
- **runtime** — `ConversationRuntime`, config loading, session persistence, permission policy, MCP client lifecycle, system prompt assembly, usage tracking
- **rusty-claude-cli** — REPL, one-shot prompt, direct CLI subcommands, streaming display, tool call rendering, CLI argument parsing
- **telemetry** — session trace events and supporting telemetry payloads
- **tools** — tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, and runtime-facing tool discovery
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer)
- **commands** — Slash command definitions and help text generation
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
- **rusty-claude-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
- **tools** — Tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, REPL runtimes
## Stats
- **~20K lines** of Rust
- **9 crates** in workspace
- **6 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-6`
- **Default permissions:** `danger-full-access`

View File

@@ -20,14 +20,12 @@ This plan covers a comprehensive analysis of the current terminal user interface
### Current TUI Components
> Note: The legacy prototype files `app.rs` and `args.rs` were removed on 2026-04-05.
> References below describe future extraction targets, not current tracked source files.
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
@@ -58,7 +56,7 @@ This plan covers a comprehensive analysis of the current terminal user interface
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Historical dual app split**the repo previously carried a separate `CliApp` prototype alongside `LiveCli`; the prototype is gone, but the monolithic `main.rs` still needs extraction
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
@@ -75,8 +73,8 @@ This plan covers a comprehensive analysis of the current terminal user interface
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Keep the legacy `CliApp` removed** — The old `CliApp` prototype has already been deleted; if any unique ideas remain valuable (for example stream event handler patterns), reintroduce them intentionally inside the active `LiveCli` extraction rather than restoring the old file wholesale | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is still a hand-rolled parser in `main.rs`. If parsing is extracted later, do it into a newly-introduced module intentionally rather than reviving the removed prototype `args.rs` by accident | S |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
@@ -216,7 +214,7 @@ crates/rusty-claude-cli/src/
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| Historical `app.rs` vs `main.rs` confusion | Keep the legacy prototype removed and avoid reintroducing a second app surface accidentally during extraction |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
---

View File

@@ -1,11 +0,0 @@
# Rust usage guide
The canonical task-oriented usage guide lives at [`../USAGE.md`](../USAGE.md).
Use that guide for:
- workspace build and test commands
- authentication setup
- interactive and one-shot `claw` examples
- session resume workflows
- mock parity harness commands

View File

@@ -9,16 +9,8 @@ publish.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
telemetry = { path = "../telemetry" }
serde_json = "1"
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[lints]
workspace = true
[[bench]]
name = "request_building"
harness = false

View File

@@ -1,329 +0,0 @@
// Benchmarks for API request building performance
// Benchmarks are exempt from strict linting as they are test/performance code
#![allow(
clippy::cognitive_complexity,
clippy::doc_markdown,
clippy::explicit_iter_loop,
clippy::format_in_format_args,
clippy::missing_docs_in_private_items,
clippy::must_use_candidate,
clippy::needless_pass_by_value,
clippy::clone_on_copy,
clippy::too_many_lines,
clippy::uninlined_format_args
)]
use api::{
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
translate_message, InputContentBlock, InputMessage, MessageRequest, OpenAiCompatConfig,
ToolResultContentBlock,
};
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use serde_json::json;
/// Create a sample message request with various content types
fn create_sample_request(message_count: usize) -> MessageRequest {
let mut messages = Vec::with_capacity(message_count);
for i in 0..message_count {
match i % 4 {
0 => messages.push(InputMessage::user_text(format!("Message {}", i))),
1 => messages.push(InputMessage {
role: "assistant".to_string(),
content: vec![
InputContentBlock::Text {
text: format!("Assistant response {}", i),
},
InputContentBlock::ToolUse {
id: format!("call_{}", i),
name: "read_file".to_string(),
input: json!({"path": format!("/tmp/file{}", i)}),
},
],
}),
2 => messages.push(InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: format!("call_{}", i - 1),
content: vec![ToolResultContentBlock::Text {
text: format!("Tool result content {}", i),
}],
is_error: false,
}],
}),
_ => messages.push(InputMessage {
role: "assistant".to_string(),
content: vec![InputContentBlock::ToolUse {
id: format!("call_{}", i),
name: "write_file".to_string(),
input: json!({"path": format!("/tmp/out{}", i), "content": "data"}),
}],
}),
}
}
MessageRequest {
model: "gpt-4o".to_string(),
max_tokens: 1024,
messages,
stream: false,
system: Some("You are a helpful assistant.".to_string()),
temperature: Some(0.7),
top_p: None,
tools: None,
tool_choice: None,
frequency_penalty: None,
presence_penalty: None,
stop: None,
reasoning_effort: None,
}
}
/// Benchmark translate_message with various message types
fn bench_translate_message(c: &mut Criterion) {
let mut group = c.benchmark_group("translate_message");
// Text-only message
let text_message = InputMessage::user_text("Simple text message".to_string());
group.bench_with_input(
BenchmarkId::new("text_only", "single"),
&text_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
// Assistant message with tool calls
let assistant_message = InputMessage {
role: "assistant".to_string(),
content: vec![
InputContentBlock::Text {
text: "I'll help you with that.".to_string(),
},
InputContentBlock::ToolUse {
id: "call_1".to_string(),
name: "read_file".to_string(),
input: json!({"path": "/tmp/test"}),
},
InputContentBlock::ToolUse {
id: "call_2".to_string(),
name: "write_file".to_string(),
input: json!({"path": "/tmp/out", "content": "data"}),
},
],
};
group.bench_with_input(
BenchmarkId::new("assistant_with_tools", "2_tools"),
&assistant_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
// Tool result message
let tool_result_message = InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::ToolResult {
tool_use_id: "call_1".to_string(),
content: vec![ToolResultContentBlock::Text {
text: "File contents here".to_string(),
}],
is_error: false,
}],
};
group.bench_with_input(
BenchmarkId::new("tool_result", "single"),
&tool_result_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
// Tool result for kimi model (is_error excluded)
group.bench_with_input(
BenchmarkId::new("tool_result_kimi", "kimi-k2.5"),
&tool_result_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("kimi-k2.5")));
},
);
// Large content message
let large_content = "x".repeat(10000);
let large_message = InputMessage::user_text(large_content);
group.bench_with_input(
BenchmarkId::new("large_text", "10kb"),
&large_message,
|b, msg| {
b.iter(|| translate_message(black_box(msg), black_box("gpt-4o")));
},
);
group.finish();
}
/// Benchmark build_chat_completion_request with various message counts
fn bench_build_request(c: &mut Criterion) {
let mut group = c.benchmark_group("build_chat_completion_request");
let config = OpenAiCompatConfig::openai();
for message_count in [10, 50, 100].iter() {
let request = create_sample_request(*message_count);
group.bench_with_input(
BenchmarkId::new("message_count", message_count),
&request,
|b, req| {
b.iter(|| build_chat_completion_request(black_box(req), config.clone()));
},
);
}
// Benchmark with reasoning model (tuning params stripped)
let mut reasoning_request = create_sample_request(50);
reasoning_request.model = "o1-mini".to_string();
group.bench_with_input(
BenchmarkId::new("reasoning_model", "o1-mini"),
&reasoning_request,
|b, req| {
b.iter(|| build_chat_completion_request(black_box(req), config.clone()));
},
);
// Benchmark with gpt-5 (max_completion_tokens)
let mut gpt5_request = create_sample_request(50);
gpt5_request.model = "gpt-5".to_string();
group.bench_with_input(
BenchmarkId::new("gpt5", "gpt-5"),
&gpt5_request,
|b, req| {
b.iter(|| build_chat_completion_request(black_box(req), config.clone()));
},
);
group.finish();
}
/// Benchmark flatten_tool_result_content
fn bench_flatten_tool_result(c: &mut Criterion) {
let mut group = c.benchmark_group("flatten_tool_result_content");
// Single text block
let single_text = vec![ToolResultContentBlock::Text {
text: "Simple result".to_string(),
}];
group.bench_with_input(
BenchmarkId::new("single_text", "1_block"),
&single_text,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// Multiple text blocks
let multi_text: Vec<ToolResultContentBlock> = (0..10)
.map(|i| ToolResultContentBlock::Text {
text: format!("Line {}: some content here\n", i),
})
.collect();
group.bench_with_input(
BenchmarkId::new("multi_text", "10_blocks"),
&multi_text,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// JSON content blocks
let json_content: Vec<ToolResultContentBlock> = (0..5)
.map(|i| ToolResultContentBlock::Json {
value: json!({"index": i, "data": "test content", "nested": {"key": "value"}}),
})
.collect();
group.bench_with_input(
BenchmarkId::new("json_content", "5_blocks"),
&json_content,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// Mixed content
let mixed_content = vec![
ToolResultContentBlock::Text {
text: "Here's the result:".to_string(),
},
ToolResultContentBlock::Json {
value: json!({"status": "success", "count": 42}),
},
ToolResultContentBlock::Text {
text: "Processing complete.".to_string(),
},
];
group.bench_with_input(
BenchmarkId::new("mixed_content", "text+json"),
&mixed_content,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
// Large content - simulating typical tool output
let large_content: Vec<ToolResultContentBlock> = (0..50)
.map(|i| {
if i % 3 == 0 {
ToolResultContentBlock::Json {
value: json!({"line": i, "content": "x".repeat(100)}),
}
} else {
ToolResultContentBlock::Text {
text: format!("Line {}: {}", i, "some output content here"),
}
}
})
.collect();
group.bench_with_input(
BenchmarkId::new("large_content", "50_blocks"),
&large_content,
|b, content| {
b.iter(|| flatten_tool_result_content(black_box(content)));
},
);
group.finish();
}
/// Benchmark is_reasoning_model detection
fn bench_is_reasoning_model(c: &mut Criterion) {
let mut group = c.benchmark_group("is_reasoning_model");
let models = vec![
("gpt-4o", false),
("o1-mini", true),
("o3", true),
("grok-3", false),
("grok-3-mini", true),
("qwen/qwen-qwq-32b", true),
("qwen/qwen-plus", false),
];
for (model, expected) in models {
group.bench_with_input(
BenchmarkId::new(model, if expected { "reasoning" } else { "normal" }),
model,
|b, m| {
b.iter(|| is_reasoning_model(black_box(m)));
},
);
}
group.finish();
}
criterion_group!(
benches,
bench_translate_message,
bench_build_request,
bench_flatten_tool_result,
bench_is_reasoning_model
);
criterion_main!(benches);

File diff suppressed because it is too large Load Diff

View File

@@ -2,59 +2,21 @@ use std::env::VarError;
use std::fmt::{Display, Formatter};
use std::time::Duration;
const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
"something went wrong while processing your request",
"please try again, or use /new to start a fresh session",
];
const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"maximum context length",
"context window",
"context length",
"too many tokens",
"prompt is too long",
"input is too long",
"request is too large",
];
#[derive(Debug)]
pub enum ApiError {
MissingCredentials {
provider: &'static str,
env_vars: &'static [&'static str],
/// Optional, runtime-computed hint appended to the error Display
/// output. Populated when the provider resolver can infer what the
/// user probably intended (e.g. an `OpenAI` key is set but Anthropic
/// was selected because no Anthropic credentials exist).
hint: Option<String>,
},
ContextWindowExceeded {
model: String,
estimated_input_tokens: u32,
requested_output_tokens: u32,
estimated_total_tokens: u32,
context_window_tokens: u32,
},
MissingApiKey,
ExpiredOAuthToken,
Auth(String),
InvalidApiKeyEnv(VarError),
Http(reqwest::Error),
Io(std::io::Error),
Json {
provider: String,
model: String,
body_snippet: String,
source: serde_json::Error,
},
Json(serde_json::Error),
Api {
status: reqwest::StatusCode,
error_type: Option<String>,
message: Option<String>,
request_id: Option<String>,
body: String,
retryable: bool,
/// Suggested user action based on error type (e.g., "Reduce prompt size" for 413)
suggested_action: Option<String>,
},
RetriesExhausted {
attempts: u32,
@@ -65,223 +27,36 @@ pub enum ApiError {
attempt: u32,
base_delay: Duration,
},
RequestBodySizeExceeded {
estimated_bytes: usize,
max_bytes: usize,
provider: &'static str,
},
}
impl ApiError {
#[must_use]
pub const fn missing_credentials(
provider: &'static str,
env_vars: &'static [&'static str],
) -> Self {
Self::MissingCredentials {
provider,
env_vars,
hint: None,
}
}
/// Build a `MissingCredentials` error carrying an extra, runtime-computed
/// hint string that the Display impl appends after the canonical "missing
/// <provider> credentials" message. Used by the provider resolver to
/// suggest the likely fix when the user has credentials for a different
/// provider already in the environment.
#[must_use]
pub fn missing_credentials_with_hint(
provider: &'static str,
env_vars: &'static [&'static str],
hint: impl Into<String>,
) -> Self {
Self::MissingCredentials {
provider,
env_vars,
hint: Some(hint.into()),
}
}
/// Build a `Self::Json` enriched with the provider name, the model that
/// was requested, and the first 200 characters of the raw response body so
/// that callers can diagnose deserialization failures without re-running
/// the request.
#[must_use]
pub fn json_deserialize(
provider: impl Into<String>,
model: impl Into<String>,
body: &str,
source: serde_json::Error,
) -> Self {
Self::Json {
provider: provider.into(),
model: model.into(),
body_snippet: truncate_body_snippet(body, 200),
source,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
Self::Api { retryable, .. } => *retryable,
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
Self::MissingApiKey
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Io(_)
| Self::Json { .. }
| Self::Json(_)
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => false,
}
}
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api { request_id, .. } => request_id.as_deref(),
Self::RetriesExhausted { last_error, .. } => last_error.request_id(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => None,
}
}
#[must_use]
pub fn safe_failure_class(&self) -> &'static str {
match self {
Self::RetriesExhausted { .. } if self.is_context_window_failure() => "context_window",
Self::RetriesExhausted { .. } if self.is_generic_fatal_wrapper() => {
"provider_retry_exhausted"
}
Self::RetriesExhausted { last_error, .. } => last_error.safe_failure_class(),
Self::MissingCredentials { .. } | Self::ExpiredOAuthToken | Self::Auth(_) => {
"provider_auth"
}
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
Self::ContextWindowExceeded { .. } => "context_window",
Self::Api { .. } if self.is_context_window_failure() => "context_window",
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api { .. } => "provider_error",
Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => {
"provider_transport"
}
Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io",
Self::RequestBodySizeExceeded { .. } => "request_size",
}
}
#[must_use]
pub fn is_generic_fatal_wrapper(&self) -> bool {
match self {
Self::Api { message, body, .. } => {
message
.as_deref()
.is_some_and(looks_like_generic_fatal_wrapper)
|| looks_like_generic_fatal_wrapper(body)
}
Self::RetriesExhausted { last_error, .. } => last_error.is_generic_fatal_wrapper(),
Self::MissingCredentials { .. }
| Self::ContextWindowExceeded { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => false,
}
}
#[must_use]
pub fn is_context_window_failure(&self) -> bool {
match self {
Self::ContextWindowExceeded { .. } => true,
Self::Api {
status,
message,
body,
..
} => {
matches!(status.as_u16(), 400 | 413 | 422)
&& (message
.as_deref()
.is_some_and(looks_like_context_window_error)
|| looks_like_context_window_error(body))
}
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
Self::MissingCredentials { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json { .. }
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. }
| Self::RequestBodySizeExceeded { .. } => false,
| Self::BackoffOverflow { .. } => false,
}
}
}
impl Display for ApiError {
#[allow(clippy::too_many_lines)]
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingCredentials {
provider,
env_vars,
hint,
} => {
Self::MissingApiKey => {
write!(
f,
"missing {provider} credentials; export {} before calling the {provider} API",
env_vars.join(" or ")
)?;
if cfg!(target_os = "windows") {
if let Some(primary) = env_vars.first() {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx {primary} <value>` to make it permanent, then open a new terminal, or place a `.env` file containing `{primary}=<value>` in the current working directory)"
)?;
} else {
write!(
f,
" (on Windows, environment variables set in PowerShell only persist for the current session; use `setx` to make them permanent, then open a new terminal, or place a `.env` file in the current working directory)"
)?;
}
}
if let Some(hint) = hint {
write!(f, " — hint: {hint}")?;
}
Ok(())
"ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set; export one before calling the Anthropic API"
)
}
Self::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => write!(
f,
"context_window_blocked for {model}: estimated input {estimated_input_tokens} + requested output {requested_output_tokens} = {estimated_total_tokens} tokens exceeds the {context_window_tokens}-token context window; compact the session or reduce request size before retrying"
),
Self::ExpiredOAuthToken => {
write!(
f,
@@ -290,45 +65,36 @@ impl Display for ApiError {
}
Self::Auth(message) => write!(f, "auth error: {message}"),
Self::InvalidApiKeyEnv(error) => {
write!(f, "failed to read credential environment variable: {error}")
write!(
f,
"failed to read ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY: {error}"
)
}
Self::Http(error) => write!(f, "http error: {error}"),
Self::Io(error) => write!(f, "io error: {error}"),
Self::Json {
provider,
model,
body_snippet,
source,
} => write!(
f,
"failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}"
),
Self::Json(error) => write!(f, "json error: {error}"),
Self::Api {
status,
error_type,
message,
request_id,
body,
..
} => {
if let (Some(error_type), Some(message)) = (error_type, message) {
write!(f, "api returned {status} ({error_type})")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {message}")
} else {
write!(f, "api returned {status}")?;
if let Some(request_id) = request_id {
write!(f, " [trace {request_id}]")?;
}
write!(f, ": {body}")
} => match (error_type, message) {
(Some(error_type), Some(message)) => {
write!(
f,
"anthropic api returned {status} ({error_type}): {message}"
)
}
}
_ => write!(f, "anthropic api returned {status}: {body}"),
},
Self::RetriesExhausted {
attempts,
last_error,
} => write!(f, "api failed after {attempts} attempts: {last_error}"),
} => write!(
f,
"anthropic api failed after {attempts} attempts: {last_error}"
),
Self::InvalidSseFrame(message) => write!(f, "invalid sse frame: {message}"),
Self::BackoffOverflow {
attempt,
@@ -337,14 +103,6 @@ impl Display for ApiError {
f,
"retry backoff overflowed on attempt {attempt} with base delay {base_delay:?}"
),
Self::RequestBodySizeExceeded {
estimated_bytes,
max_bytes,
provider,
} => write!(
f,
"request body size ({estimated_bytes} bytes) exceeds {provider} limit ({max_bytes} bytes); reduce prompt length or context before retrying"
),
}
}
}
@@ -365,12 +123,7 @@ impl From<std::io::Error> for ApiError {
impl From<serde_json::Error> for ApiError {
fn from(value: serde_json::Error) -> Self {
Self::Json {
provider: "unknown".to_string(),
model: "unknown".to_string(),
body_snippet: String::new(),
source: value,
}
Self::Json(value)
}
}
@@ -379,218 +132,3 @@ impl From<VarError> for ApiError {
Self::InvalidApiKeyEnv(value)
}
}
fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
let lowered = text.to_ascii_lowercase();
GENERIC_FATAL_WRAPPER_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
fn looks_like_context_window_error(text: &str) -> bool {
let lowered = text.to_ascii_lowercase();
CONTEXT_WINDOW_ERROR_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
/// Truncate `body` so the resulting snippet contains at most `max_chars`
/// characters (counted by Unicode scalar values, not bytes), preserving the
/// leading slice of the body that the caller most often needs to inspect.
fn truncate_body_snippet(body: &str, max_chars: usize) -> String {
let mut taken_chars = 0;
let mut byte_end = 0;
for (offset, character) in body.char_indices() {
if taken_chars >= max_chars {
break;
}
taken_chars += 1;
byte_end = offset + character.len_utf8();
}
if taken_chars >= max_chars && byte_end < body.len() {
format!("{}", &body[..byte_end])
} else {
body[..byte_end].to_string()
}
}
#[cfg(test)]
mod tests {
use super::{truncate_body_snippet, ApiError};
#[test]
fn json_deserialize_error_includes_provider_model_and_truncated_body_snippet() {
let raw_body = format!("{}{}", "x".repeat(190), "_TAIL_PAST_200_CHARS_MARKER_");
let source = serde_json::from_str::<serde_json::Value>("{not json")
.expect_err("invalid json should fail to parse");
let error = ApiError::json_deserialize("Anthropic", "claude-opus-4-6", &raw_body, source);
let rendered = error.to_string();
assert!(
rendered.starts_with("failed to parse Anthropic response for model claude-opus-4-6: "),
"rendered error should lead with provider and model: {rendered}"
);
assert!(
rendered.contains("first 200 chars of body: "),
"rendered error should label the body snippet: {rendered}"
);
let snippet = rendered
.split("first 200 chars of body: ")
.nth(1)
.expect("snippet section should be present");
assert!(
snippet.starts_with(&"x".repeat(190)),
"snippet should preserve the leading characters of the body: {snippet}"
);
assert!(
snippet.ends_with('…'),
"snippet should signal truncation with an ellipsis: {snippet}"
);
assert!(
!snippet.contains("_TAIL_PAST_200_CHARS_MARKER_"),
"snippet should drop characters past the 200-char cap: {snippet}"
);
assert_eq!(error.safe_failure_class(), "runtime_io");
assert_eq!(error.request_id(), None);
assert!(!error.is_retryable());
}
#[test]
fn truncate_body_snippet_keeps_short_bodies_intact() {
assert_eq!(truncate_body_snippet("hello", 200), "hello");
assert_eq!(truncate_body_snippet("", 200), "");
}
#[test]
fn truncate_body_snippet_caps_long_bodies_at_max_chars() {
let body = "a".repeat(250);
let snippet = truncate_body_snippet(&body, 200);
assert_eq!(snippet.chars().count(), 201, "200 chars + ellipsis");
assert!(snippet.ends_with('…'));
assert!(snippet.starts_with(&"a".repeat(200)));
}
#[test]
fn truncate_body_snippet_does_not_split_multibyte_characters() {
let body = "한글한글한글한글한글한글";
let snippet = truncate_body_snippet(body, 4);
assert_eq!(snippet, "한글한글…");
}
#[test]
fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() {
let error = ApiError::Api {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_jobdori_123".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
};
assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_internal");
assert_eq!(error.request_id(), Some("req_jobdori_123"));
assert!(error.to_string().contains("[trace req_jobdori_123]"));
}
#[test]
fn retries_exhausted_preserves_nested_request_id_and_failure_class() {
let error = ApiError::RetriesExhausted {
attempts: 3,
last_error: Box::new(ApiError::Api {
status: reqwest::StatusCode::BAD_GATEWAY,
error_type: Some("api_error".to_string()),
message: Some(
"Something went wrong while processing your request. Please try again, or use /new to start a fresh session."
.to_string(),
),
request_id: Some("req_nested_456".to_string()),
body: String::new(),
retryable: true,
suggested_action: None,
}),
};
assert!(error.is_generic_fatal_wrapper());
assert_eq!(error.safe_failure_class(), "provider_retry_exhausted");
assert_eq!(error.request_id(), Some("req_nested_456"));
}
#[test]
fn classifies_provider_context_window_errors() {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(),
),
request_id: Some("req_ctx_123".to_string()),
body: String::new(),
retryable: false,
suggested_action: None,
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_123"));
}
#[test]
fn missing_credentials_without_hint_renders_the_canonical_message() {
// given
let error = ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
);
// when
let rendered = error.to_string();
// then
assert!(
rendered.starts_with(
"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API"
),
"rendered error should lead with the canonical missing-credential message: {rendered}"
);
assert!(
!rendered.contains(" — hint: "),
"no hint should be appended when none is supplied: {rendered}"
);
}
#[test]
fn missing_credentials_with_hint_appends_the_hint_after_base_message() {
// given
let error = ApiError::missing_credentials_with_hint(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
"I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.",
);
// when
let rendered = error.to_string();
// then
assert!(
rendered.starts_with("missing Anthropic credentials;"),
"hint should be appended, not replace the base message: {rendered}"
);
let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
assert!(
rendered.ends_with(hint_marker),
"rendered error should end with the hint: {rendered}"
);
// Classification semantics are unaffected by the presence of a hint.
assert_eq!(error.safe_failure_class(), "provider_auth");
assert!(!error.is_retryable());
assert_eq!(error.request_id(), None);
}
}

View File

@@ -1,344 +0,0 @@
use crate::error::ApiError;
const HTTP_PROXY_KEYS: [&str; 2] = ["HTTP_PROXY", "http_proxy"];
const HTTPS_PROXY_KEYS: [&str; 2] = ["HTTPS_PROXY", "https_proxy"];
const NO_PROXY_KEYS: [&str; 2] = ["NO_PROXY", "no_proxy"];
/// Snapshot of the proxy-related environment variables that influence the
/// outbound HTTP client. Captured up front so callers can inspect, log, and
/// test the resolved configuration without re-reading the process environment.
///
/// When `proxy_url` is set it acts as a single catch-all proxy for both
/// HTTP and HTTPS traffic, taking precedence over the per-scheme fields.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProxyConfig {
pub http_proxy: Option<String>,
pub https_proxy: Option<String>,
pub no_proxy: Option<String>,
/// Optional unified proxy URL that applies to both HTTP and HTTPS.
/// When set, this takes precedence over `http_proxy` and `https_proxy`.
pub proxy_url: Option<String>,
}
impl ProxyConfig {
/// Read proxy settings from the live process environment, honouring both
/// the upper- and lower-case spellings used by curl, git, and friends.
#[must_use]
pub fn from_env() -> Self {
Self::from_lookup(|key| std::env::var(key).ok())
}
/// Create a proxy configuration from a single URL that applies to both
/// HTTP and HTTPS traffic. This is the config-file alternative to setting
/// `HTTP_PROXY` and `HTTPS_PROXY` environment variables separately.
#[must_use]
pub fn from_proxy_url(url: impl Into<String>) -> Self {
Self {
proxy_url: Some(url.into()),
..Self::default()
}
}
fn from_lookup<F>(mut lookup: F) -> Self
where
F: FnMut(&str) -> Option<String>,
{
Self {
http_proxy: first_non_empty(&HTTP_PROXY_KEYS, &mut lookup),
https_proxy: first_non_empty(&HTTPS_PROXY_KEYS, &mut lookup),
no_proxy: first_non_empty(&NO_PROXY_KEYS, &mut lookup),
proxy_url: None,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.proxy_url.is_none() && self.http_proxy.is_none() && self.https_proxy.is_none()
}
}
/// Build a `reqwest::Client` that honours the standard `HTTP_PROXY`,
/// `HTTPS_PROXY`, and `NO_PROXY` environment variables. When no proxy is
/// configured the client behaves identically to `reqwest::Client::new()`.
pub fn build_http_client() -> Result<reqwest::Client, ApiError> {
build_http_client_with(&ProxyConfig::from_env())
}
/// Infallible counterpart to [`build_http_client`] for constructors that
/// historically returned `Self` rather than `Result<Self, _>`. When the proxy
/// configuration is malformed we fall back to a default client so that
/// callers retain the previous behaviour and the failure surfaces on the
/// first outbound request instead of at construction time.
#[must_use]
pub fn build_http_client_or_default() -> reqwest::Client {
build_http_client().unwrap_or_else(|_| reqwest::Client::new())
}
/// Build a `reqwest::Client` from an explicit [`ProxyConfig`]. Used by tests
/// and by callers that want to override process-level environment lookups.
///
/// When `config.proxy_url` is set it overrides the per-scheme `http_proxy`
/// and `https_proxy` fields and is registered as both an HTTP and HTTPS
/// proxy so a single value can route every outbound request.
pub fn build_http_client_with(config: &ProxyConfig) -> Result<reqwest::Client, ApiError> {
let mut builder = reqwest::Client::builder().no_proxy();
let no_proxy = config
.no_proxy
.as_deref()
.and_then(reqwest::NoProxy::from_string);
let (http_proxy_url, https_url) = match config.proxy_url.as_deref() {
Some(unified) => (Some(unified), Some(unified)),
None => (config.http_proxy.as_deref(), config.https_proxy.as_deref()),
};
if let Some(url) = https_url {
let mut proxy = reqwest::Proxy::https(url)?;
if let Some(filter) = no_proxy.clone() {
proxy = proxy.no_proxy(Some(filter));
}
builder = builder.proxy(proxy);
}
if let Some(url) = http_proxy_url {
let mut proxy = reqwest::Proxy::http(url)?;
if let Some(filter) = no_proxy.clone() {
proxy = proxy.no_proxy(Some(filter));
}
builder = builder.proxy(proxy);
}
Ok(builder.build()?)
}
fn first_non_empty<F>(keys: &[&str], lookup: &mut F) -> Option<String>
where
F: FnMut(&str) -> Option<String>,
{
keys.iter()
.find_map(|key| lookup(key).filter(|value| !value.is_empty()))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{build_http_client_with, ProxyConfig};
fn config_from_map(pairs: &[(&str, &str)]) -> ProxyConfig {
let map: HashMap<String, String> = pairs
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect();
ProxyConfig::from_lookup(|key| map.get(key).cloned())
}
#[test]
fn proxy_config_is_empty_when_no_env_vars_are_set() {
// given
let config = config_from_map(&[]);
// when
let empty = config.is_empty();
// then
assert!(empty);
assert_eq!(config, ProxyConfig::default());
}
#[test]
fn proxy_config_reads_uppercase_http_https_and_no_proxy() {
// given
let pairs = [
("HTTP_PROXY", "http://proxy.internal:3128"),
("HTTPS_PROXY", "http://secure.internal:3129"),
("NO_PROXY", "localhost,127.0.0.1,.corp"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://proxy.internal:3128")
);
assert_eq!(
config.https_proxy.as_deref(),
Some("http://secure.internal:3129")
);
assert_eq!(
config.no_proxy.as_deref(),
Some("localhost,127.0.0.1,.corp")
);
assert!(!config.is_empty());
}
#[test]
fn proxy_config_falls_back_to_lowercase_keys() {
// given
let pairs = [
("http_proxy", "http://lower.internal:3128"),
("https_proxy", "http://lower-secure.internal:3129"),
("no_proxy", ".lower"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://lower.internal:3128")
);
assert_eq!(
config.https_proxy.as_deref(),
Some("http://lower-secure.internal:3129")
);
assert_eq!(config.no_proxy.as_deref(), Some(".lower"));
}
#[test]
fn proxy_config_prefers_uppercase_over_lowercase_when_both_set() {
// given
let pairs = [
("HTTP_PROXY", "http://upper.internal:3128"),
("http_proxy", "http://lower.internal:3128"),
];
// when
let config = config_from_map(&pairs);
// then
assert_eq!(
config.http_proxy.as_deref(),
Some("http://upper.internal:3128")
);
}
#[test]
fn proxy_config_treats_empty_strings_as_unset() {
// given
let pairs = [("HTTP_PROXY", ""), ("http_proxy", "")];
// when
let config = config_from_map(&pairs);
// then
assert!(config.http_proxy.is_none());
}
#[test]
fn build_http_client_succeeds_when_no_proxy_is_configured() {
// given
let config = ProxyConfig::default();
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_succeeds_with_valid_http_and_https_proxies() {
// given
let config = ProxyConfig {
http_proxy: Some("http://proxy.internal:3128".to_string()),
https_proxy: Some("http://secure.internal:3129".to_string()),
no_proxy: Some("localhost,127.0.0.1".to_string()),
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_http_error_for_invalid_proxy_url() {
// given
let config = ProxyConfig {
http_proxy: None,
https_proxy: Some("not a url".to_string()),
no_proxy: None,
proxy_url: None,
};
// when
let result = build_http_client_with(&config);
// then
let error = result.expect_err("invalid proxy URL must be reported as a build failure");
assert!(
matches!(error, crate::error::ApiError::Http(_)),
"expected ApiError::Http for invalid proxy URL, got: {error:?}"
);
}
#[test]
fn from_proxy_url_sets_unified_field_and_leaves_per_scheme_empty() {
// given / when
let config = ProxyConfig::from_proxy_url("http://unified.internal:3128");
// then
assert_eq!(
config.proxy_url.as_deref(),
Some("http://unified.internal:3128")
);
assert!(config.http_proxy.is_none());
assert!(config.https_proxy.is_none());
assert!(!config.is_empty());
}
#[test]
fn build_http_client_succeeds_with_unified_proxy_url() {
// given
let config = ProxyConfig {
proxy_url: Some("http://unified.internal:3128".to_string()),
no_proxy: Some("localhost".to_string()),
..ProxyConfig::default()
};
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn proxy_url_takes_precedence_over_per_scheme_fields() {
// given both per-scheme and unified are set
let config = ProxyConfig {
http_proxy: Some("http://per-scheme.internal:1111".to_string()),
https_proxy: Some("http://per-scheme.internal:2222".to_string()),
no_proxy: None,
proxy_url: Some("http://unified.internal:3128".to_string()),
};
// when building succeeds (the unified URL is valid)
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_http_client_returns_error_for_invalid_unified_proxy_url() {
// given
let config = ProxyConfig::from_proxy_url("not a url");
// when
let result = build_http_client_with(&config);
// then
assert!(
matches!(result, Err(crate::error::ApiError::Http(_))),
"invalid unified proxy URL should fail: {result:?}"
);
}
}

View File

@@ -1,32 +1,13 @@
mod client;
mod error;
mod http_client;
mod prompt_cache;
mod providers;
mod sse;
mod types;
pub use client::{
oauth_token_is_expired, read_base_url, read_xai_base_url, resolve_saved_oauth_token,
resolve_startup_auth_source, MessageStream, OAuthTokenSet, ProviderClient,
oauth_token_is_expired, read_base_url, resolve_saved_oauth_token, resolve_startup_auth_source,
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
};
pub use error::ApiError;
pub use http_client::{
build_http_client, build_http_client_or_default, build_http_client_with, ProxyConfig,
};
pub use prompt_cache::{
CacheBreakEvent, PromptCache, PromptCacheConfig, PromptCachePaths, PromptCacheRecord,
PromptCacheStats,
};
pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource};
pub use providers::openai_compat::{
build_chat_completion_request, flatten_tool_result_content, is_reasoning_model,
model_rejects_is_error_field, translate_message, OpenAiCompatClient, OpenAiCompatConfig,
};
pub use providers::{
detect_provider_kind, max_tokens_for_model, max_tokens_for_model_with_override,
resolve_model_alias, ProviderKind,
};
pub use sse::{parse_frame, SseParser};
pub use types::{
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
@@ -34,9 +15,3 @@ pub use types::{
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
};
pub use telemetry::{
AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink,
MemoryTelemetrySink, SessionTraceRecord, SessionTracer, TelemetryEvent, TelemetrySink,
DEFAULT_ANTHROPIC_VERSION,
};

View File

@@ -1,735 +0,0 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::types::{MessageRequest, MessageResponse, Usage};
const DEFAULT_COMPLETION_TTL_SECS: u64 = 30;
const DEFAULT_PROMPT_TTL_SECS: u64 = 5 * 60;
const DEFAULT_BREAK_MIN_DROP: u32 = 2_000;
const MAX_SANITIZED_LENGTH: usize = 80;
const REQUEST_FINGERPRINT_VERSION: u32 = 1;
const REQUEST_FINGERPRINT_PREFIX: &str = "v1";
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
#[derive(Debug, Clone)]
pub struct PromptCacheConfig {
pub session_id: String,
pub completion_ttl: Duration,
pub prompt_ttl: Duration,
pub cache_break_min_drop: u32,
}
impl PromptCacheConfig {
#[must_use]
pub fn new(session_id: impl Into<String>) -> Self {
Self {
session_id: session_id.into(),
completion_ttl: Duration::from_secs(DEFAULT_COMPLETION_TTL_SECS),
prompt_ttl: Duration::from_secs(DEFAULT_PROMPT_TTL_SECS),
cache_break_min_drop: DEFAULT_BREAK_MIN_DROP,
}
}
}
impl Default for PromptCacheConfig {
fn default() -> Self {
Self::new("default")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptCachePaths {
pub root: PathBuf,
pub session_dir: PathBuf,
pub completion_dir: PathBuf,
pub session_state_path: PathBuf,
pub stats_path: PathBuf,
}
impl PromptCachePaths {
#[must_use]
pub fn for_session(session_id: &str) -> Self {
let root = base_cache_root();
let session_dir = root.join(sanitize_path_segment(session_id));
let completion_dir = session_dir.join("completions");
Self {
root,
session_state_path: session_dir.join("session-state.json"),
stats_path: session_dir.join("stats.json"),
session_dir,
completion_dir,
}
}
#[must_use]
pub fn completion_entry_path(&self, request_hash: &str) -> PathBuf {
self.completion_dir.join(format!("{request_hash}.json"))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromptCacheStats {
pub tracked_requests: u64,
pub completion_cache_hits: u64,
pub completion_cache_misses: u64,
pub completion_cache_writes: u64,
pub expected_invalidations: u64,
pub unexpected_cache_breaks: u64,
pub total_cache_creation_input_tokens: u64,
pub total_cache_read_input_tokens: u64,
pub last_cache_creation_input_tokens: Option<u32>,
pub last_cache_read_input_tokens: Option<u32>,
pub last_request_hash: Option<String>,
pub last_completion_cache_key: Option<String>,
pub last_break_reason: Option<String>,
pub last_cache_source: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheBreakEvent {
pub unexpected: bool,
pub reason: String,
pub previous_cache_read_input_tokens: u32,
pub current_cache_read_input_tokens: u32,
pub token_drop: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptCacheRecord {
pub cache_break: Option<CacheBreakEvent>,
pub stats: PromptCacheStats,
}
#[derive(Debug, Clone)]
pub struct PromptCache {
inner: Arc<Mutex<PromptCacheInner>>,
}
impl PromptCache {
#[must_use]
pub fn new(session_id: impl Into<String>) -> Self {
Self::with_config(PromptCacheConfig::new(session_id))
}
#[must_use]
pub fn with_config(config: PromptCacheConfig) -> Self {
let paths = PromptCachePaths::for_session(&config.session_id);
let stats = read_json::<PromptCacheStats>(&paths.stats_path).unwrap_or_default();
let previous = read_json::<TrackedPromptState>(&paths.session_state_path);
Self {
inner: Arc::new(Mutex::new(PromptCacheInner {
config,
paths,
stats,
previous,
})),
}
}
#[must_use]
pub fn paths(&self) -> PromptCachePaths {
self.lock().paths.clone()
}
#[must_use]
pub fn stats(&self) -> PromptCacheStats {
self.lock().stats.clone()
}
#[must_use]
pub fn lookup_completion(&self, request: &MessageRequest) -> Option<MessageResponse> {
let request_hash = request_hash_hex(request);
let (paths, ttl) = {
let inner = self.lock();
(inner.paths.clone(), inner.config.completion_ttl)
};
let entry_path = paths.completion_entry_path(&request_hash);
let entry = read_json::<CompletionCacheEntry>(&entry_path);
let Some(entry) = entry else {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash);
persist_state(&inner);
return None;
};
if entry.fingerprint_version != current_fingerprint_version() {
let mut inner = self.lock();
inner.stats.completion_cache_misses += 1;
inner.stats.last_completion_cache_key = Some(request_hash.clone());
let _ = fs::remove_file(entry_path);
persist_state(&inner);
return None;
}
let expired = now_unix_secs().saturating_sub(entry.cached_at_unix_secs) >= ttl.as_secs();
let mut inner = self.lock();
inner.stats.last_completion_cache_key = Some(request_hash.clone());
if expired {
inner.stats.completion_cache_misses += 1;
let _ = fs::remove_file(entry_path);
persist_state(&inner);
return None;
}
inner.stats.completion_cache_hits += 1;
apply_usage_to_stats(
&mut inner.stats,
&entry.response.usage,
&request_hash,
"completion-cache",
);
inner.previous = Some(TrackedPromptState::from_usage(
request,
&entry.response.usage,
));
persist_state(&inner);
Some(entry.response)
}
#[must_use]
pub fn record_response(
&self,
request: &MessageRequest,
response: &MessageResponse,
) -> PromptCacheRecord {
self.record_usage_internal(request, &response.usage, Some(response))
}
#[must_use]
pub fn record_usage(&self, request: &MessageRequest, usage: &Usage) -> PromptCacheRecord {
self.record_usage_internal(request, usage, None)
}
fn record_usage_internal(
&self,
request: &MessageRequest,
usage: &Usage,
response: Option<&MessageResponse>,
) -> PromptCacheRecord {
let request_hash = request_hash_hex(request);
let mut inner = self.lock();
let previous = inner.previous.clone();
let current = TrackedPromptState::from_usage(request, usage);
let cache_break = detect_cache_break(&inner.config, previous.as_ref(), &current);
inner.stats.tracked_requests += 1;
apply_usage_to_stats(&mut inner.stats, usage, &request_hash, "api-response");
if let Some(event) = &cache_break {
if event.unexpected {
inner.stats.unexpected_cache_breaks += 1;
} else {
inner.stats.expected_invalidations += 1;
}
inner.stats.last_break_reason = Some(event.reason.clone());
}
inner.previous = Some(current);
if let Some(response) = response {
write_completion_entry(&inner.paths, &request_hash, response);
inner.stats.completion_cache_writes += 1;
}
persist_state(&inner);
PromptCacheRecord {
cache_break,
stats: inner.stats.clone(),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, PromptCacheInner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(Debug)]
struct PromptCacheInner {
config: PromptCacheConfig,
paths: PromptCachePaths,
stats: PromptCacheStats,
previous: Option<TrackedPromptState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CompletionCacheEntry {
cached_at_unix_secs: u64,
#[serde(default = "current_fingerprint_version")]
fingerprint_version: u32,
response: MessageResponse,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct TrackedPromptState {
observed_at_unix_secs: u64,
#[serde(default = "current_fingerprint_version")]
fingerprint_version: u32,
model_hash: u64,
system_hash: u64,
tools_hash: u64,
messages_hash: u64,
cache_read_input_tokens: u32,
}
impl TrackedPromptState {
fn from_usage(request: &MessageRequest, usage: &Usage) -> Self {
let hashes = RequestFingerprints::from_request(request);
Self {
observed_at_unix_secs: now_unix_secs(),
fingerprint_version: current_fingerprint_version(),
model_hash: hashes.model,
system_hash: hashes.system,
tools_hash: hashes.tools,
messages_hash: hashes.messages,
cache_read_input_tokens: usage.cache_read_input_tokens,
}
}
}
#[derive(Debug, Clone, Copy)]
struct RequestFingerprints {
model: u64,
system: u64,
tools: u64,
messages: u64,
}
impl RequestFingerprints {
fn from_request(request: &MessageRequest) -> Self {
Self {
model: hash_serializable(&request.model),
system: hash_serializable(&request.system),
tools: hash_serializable(&request.tools),
messages: hash_serializable(&request.messages),
}
}
}
fn detect_cache_break(
config: &PromptCacheConfig,
previous: Option<&TrackedPromptState>,
current: &TrackedPromptState,
) -> Option<CacheBreakEvent> {
let previous = previous?;
if previous.fingerprint_version != current.fingerprint_version {
return Some(CacheBreakEvent {
unexpected: false,
reason: format!(
"fingerprint version changed (v{} -> v{})",
previous.fingerprint_version, current.fingerprint_version
),
previous_cache_read_input_tokens: previous.cache_read_input_tokens,
current_cache_read_input_tokens: current.cache_read_input_tokens,
token_drop: previous
.cache_read_input_tokens
.saturating_sub(current.cache_read_input_tokens),
});
}
let token_drop = previous
.cache_read_input_tokens
.saturating_sub(current.cache_read_input_tokens);
if token_drop < config.cache_break_min_drop {
return None;
}
let mut reasons = Vec::new();
if previous.model_hash != current.model_hash {
reasons.push("model changed");
}
if previous.system_hash != current.system_hash {
reasons.push("system prompt changed");
}
if previous.tools_hash != current.tools_hash {
reasons.push("tool definitions changed");
}
if previous.messages_hash != current.messages_hash {
reasons.push("message payload changed");
}
let elapsed = current
.observed_at_unix_secs
.saturating_sub(previous.observed_at_unix_secs);
let (unexpected, reason) = if reasons.is_empty() {
if elapsed > config.prompt_ttl.as_secs() {
(
false,
format!("possible prompt cache TTL expiry after {elapsed}s"),
)
} else {
(
true,
"cache read tokens dropped while prompt fingerprint remained stable".to_string(),
)
}
} else {
(false, reasons.join(", "))
};
Some(CacheBreakEvent {
unexpected,
reason,
previous_cache_read_input_tokens: previous.cache_read_input_tokens,
current_cache_read_input_tokens: current.cache_read_input_tokens,
token_drop,
})
}
fn apply_usage_to_stats(
stats: &mut PromptCacheStats,
usage: &Usage,
request_hash: &str,
source: &str,
) {
stats.total_cache_creation_input_tokens += u64::from(usage.cache_creation_input_tokens);
stats.total_cache_read_input_tokens += u64::from(usage.cache_read_input_tokens);
stats.last_cache_creation_input_tokens = Some(usage.cache_creation_input_tokens);
stats.last_cache_read_input_tokens = Some(usage.cache_read_input_tokens);
stats.last_request_hash = Some(request_hash.to_string());
stats.last_cache_source = Some(source.to_string());
}
fn persist_state(inner: &PromptCacheInner) {
let _ = ensure_cache_dirs(&inner.paths);
let _ = write_json(&inner.paths.stats_path, &inner.stats);
if let Some(previous) = &inner.previous {
let _ = write_json(&inner.paths.session_state_path, previous);
}
}
fn write_completion_entry(
paths: &PromptCachePaths,
request_hash: &str,
response: &MessageResponse,
) {
let _ = ensure_cache_dirs(paths);
let entry = CompletionCacheEntry {
cached_at_unix_secs: now_unix_secs(),
fingerprint_version: current_fingerprint_version(),
response: response.clone(),
};
let _ = write_json(&paths.completion_entry_path(request_hash), &entry);
}
fn ensure_cache_dirs(paths: &PromptCachePaths) -> std::io::Result<()> {
fs::create_dir_all(&paths.completion_dir)
}
fn write_json<T: Serialize>(path: &Path, value: &T) -> std::io::Result<()> {
let json = serde_json::to_vec_pretty(value)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
fs::write(path, json)
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Option<T> {
let bytes = fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn request_hash_hex(request: &MessageRequest) -> String {
format!(
"{REQUEST_FINGERPRINT_PREFIX}-{:016x}",
hash_serializable(request)
)
}
fn hash_serializable<T: Serialize>(value: &T) -> u64 {
let json = serde_json::to_vec(value).unwrap_or_default();
stable_hash_bytes(&json)
}
fn sanitize_path_segment(value: &str) -> String {
let sanitized: String = value
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect();
if sanitized.len() <= MAX_SANITIZED_LENGTH {
return sanitized;
}
let suffix = format!("-{:x}", hash_string(value));
format!(
"{}{}",
&sanitized[..MAX_SANITIZED_LENGTH.saturating_sub(suffix.len())],
suffix
)
}
fn hash_string(value: &str) -> u64 {
stable_hash_bytes(value.as_bytes())
}
fn base_cache_root() -> PathBuf {
if let Some(config_home) = std::env::var_os("CLAUDE_CONFIG_HOME") {
return PathBuf::from(config_home)
.join("cache")
.join("prompt-cache");
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home)
.join(".claude")
.join("cache")
.join("prompt-cache");
}
std::env::temp_dir().join("claude-prompt-cache")
}
fn now_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs())
}
const fn current_fingerprint_version() -> u32 {
REQUEST_FINGERPRINT_VERSION
}
fn stable_hash_bytes(bytes: &[u8]) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::{
detect_cache_break, read_json, request_hash_hex, sanitize_path_segment, PromptCache,
PromptCacheConfig, PromptCachePaths, TrackedPromptState, REQUEST_FINGERPRINT_PREFIX,
};
use crate::types::{InputMessage, MessageRequest, MessageResponse, OutputContentBlock, Usage};
fn test_env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn path_builder_sanitizes_session_identifier() {
let paths = PromptCachePaths::for_session("session:/with spaces");
let session_dir = paths
.session_dir
.file_name()
.and_then(|value| value.to_str())
.expect("session dir name");
assert_eq!(session_dir, "session--with-spaces");
assert!(paths.completion_dir.ends_with("completions"));
assert!(paths.stats_path.ends_with("stats.json"));
assert!(paths.session_state_path.ends_with("session-state.json"));
}
#[test]
fn request_fingerprint_drives_unexpected_break_detection() {
let request = sample_request("same");
let previous = TrackedPromptState::from_usage(
&request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 6_000,
output_tokens: 0,
},
);
let current = TrackedPromptState::from_usage(
&request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000,
output_tokens: 0,
},
);
let event = detect_cache_break(&PromptCacheConfig::default(), Some(&previous), &current)
.expect("break should be detected");
assert!(event.unexpected);
assert!(event.reason.contains("stable"));
}
#[test]
fn changed_prompt_marks_break_as_expected() {
let previous_request = sample_request("first");
let current_request = sample_request("second");
let previous = TrackedPromptState::from_usage(
&previous_request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 6_000,
output_tokens: 0,
},
);
let current = TrackedPromptState::from_usage(
&current_request,
&Usage {
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000,
output_tokens: 0,
},
);
let event = detect_cache_break(&PromptCacheConfig::default(), Some(&previous), &current)
.expect("break should be detected");
assert!(!event.unexpected);
assert!(event.reason.contains("message payload changed"));
}
#[test]
fn completion_cache_round_trip_persists_recent_response() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::new("unit-test-session");
let request = sample_request("cache me");
let response = sample_response(42, 12, "cached");
assert!(cache.lookup_completion(&request).is_none());
let record = cache.record_response(&request, &response);
assert!(record.cache_break.is_none());
let cached = cache
.lookup_completion(&request)
.expect("cached response should load");
assert_eq!(cached.content, response.content);
let stats = cache.stats();
assert_eq!(stats.completion_cache_hits, 1);
assert_eq!(stats.completion_cache_misses, 1);
assert_eq!(stats.completion_cache_writes, 1);
let persisted = read_json::<super::PromptCacheStats>(&cache.paths().stats_path)
.expect("stats should persist");
assert_eq!(persisted.completion_cache_hits, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn distinct_requests_do_not_collide_in_completion_cache() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-distinct-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::new("distinct-request-session");
let first_request = sample_request("first");
let second_request = sample_request("second");
let response = sample_response(42, 12, "cached");
let _ = cache.record_response(&first_request, &response);
assert!(cache.lookup_completion(&second_request).is_none());
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn expired_completion_entries_are_not_reused() {
let _guard = test_env_lock();
let temp_root = std::env::temp_dir().join(format!(
"prompt-cache-expired-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let cache = PromptCache::with_config(PromptCacheConfig {
session_id: "expired-session".to_string(),
completion_ttl: Duration::ZERO,
..PromptCacheConfig::default()
});
let request = sample_request("expire me");
let response = sample_response(7, 3, "stale");
let _ = cache.record_response(&request, &response);
assert!(cache.lookup_completion(&request).is_none());
let stats = cache.stats();
assert_eq!(stats.completion_cache_hits, 0);
assert_eq!(stats.completion_cache_misses, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[test]
fn sanitize_path_caps_long_values() {
let long_value = "x".repeat(200);
let sanitized = sanitize_path_segment(&long_value);
assert!(sanitized.len() <= 80);
}
#[test]
fn request_hashes_are_versioned_and_stable() {
let request = sample_request("stable");
let first = request_hash_hex(&request);
let second = request_hash_hex(&request);
assert_eq!(first, second);
assert!(first.starts_with(REQUEST_FINGERPRINT_PREFIX));
}
fn sample_request(text: &str) -> MessageRequest {
MessageRequest {
model: "claude-3-7-sonnet-latest".to_string(),
max_tokens: 64,
messages: vec![InputMessage::user_text(text)],
system: Some("system".to_string()),
tools: None,
tool_choice: None,
stream: false,
..Default::default()
}
}
fn sample_response(
cache_read_input_tokens: u32,
output_tokens: u32,
text: &str,
) -> MessageResponse {
MessageResponse {
id: "msg_test".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::Text {
text: text.to_string(),
}],
model: "claude-3-7-sonnet-latest".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 10,
cache_creation_input_tokens: 5,
cache_read_input_tokens,
output_tokens,
},
request_id: Some("req_test".to_string()),
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,6 @@ use crate::types::StreamEvent;
#[derive(Debug, Default)]
pub struct SseParser {
buffer: Vec<u8>,
provider: Option<String>,
model: Option<String>,
}
impl SseParser {
@@ -14,23 +12,12 @@ impl SseParser {
Self::default()
}
/// Attach the provider name and model to this parser so that JSON
/// deserialization failures within streamed frames carry enough context
/// for callers to understand which upstream produced the unparseable
/// payload.
#[must_use]
pub fn with_context(mut self, provider: impl Into<String>, model: impl Into<String>) -> Self {
self.provider = Some(provider.into());
self.model = Some(model.into());
self
}
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<StreamEvent>, ApiError> {
self.buffer.extend_from_slice(chunk);
let mut events = Vec::new();
while let Some(frame) = self.next_frame() {
if let Some(event) = self.parse_frame_with_context(&frame)? {
if let Some(event) = parse_frame(&frame)? {
events.push(event);
}
}
@@ -44,18 +31,12 @@ impl SseParser {
}
let trailing = std::mem::take(&mut self.buffer);
match self.parse_frame_with_context(&String::from_utf8_lossy(&trailing))? {
match parse_frame(&String::from_utf8_lossy(&trailing))? {
Some(event) => Ok(vec![event]),
None => Ok(Vec::new()),
}
}
fn parse_frame_with_context(&self, frame: &str) -> Result<Option<StreamEvent>, ApiError> {
let provider = self.provider.as_deref().unwrap_or("unknown");
let model = self.model.as_deref().unwrap_or("unknown");
parse_frame_with_provider(frame, provider, model)
}
fn next_frame(&mut self) -> Option<String> {
let separator = self
.buffer
@@ -80,14 +61,6 @@ impl SseParser {
}
pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
parse_frame_with_provider(frame, "unknown", "unknown")
}
pub(crate) fn parse_frame_with_provider(
frame: &str,
provider: &str,
model: &str,
) -> Result<Option<StreamEvent>, ApiError> {
let trimmed = frame.trim();
if trimmed.is_empty() {
return Ok(None);
@@ -124,7 +97,7 @@ pub(crate) fn parse_frame_with_provider(
serde_json::from_str::<StreamEvent>(&payload)
.map(Some)
.map_err(|error| ApiError::json_deserialize(provider, model, &payload, error))
.map_err(ApiError::from)
}
#[cfg(test)]
@@ -303,28 +276,4 @@ mod tests {
))
);
}
#[test]
fn given_message_delta_frame_with_empty_usage_when_parsed_then_usage_defaults_to_zero() {
// given
let frame = concat!(
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{}}\n\n"
);
// when
let event = parse_frame(frame).expect("frame should parse");
// then
assert_eq!(
event,
Some(StreamEvent::MessageDelta(crate::types::MessageDeltaEvent {
delta: MessageDelta {
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
},
usage: Usage::default(),
}))
);
}
}

View File

@@ -1,8 +1,7 @@
use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageRequest {
pub model: String,
pub max_tokens: u32,
@@ -15,22 +14,6 @@ pub struct MessageRequest {
pub tool_choice: Option<ToolChoice>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stream: bool,
/// OpenAI-compatible tuning parameters. Optional — omitted from payload when None.
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
/// Reasoning effort level for OpenAI-compatible reasoning models (e.g. `o4-mini`).
/// Accepted values: `"low"`, `"medium"`, `"high"`. Omitted when `None`.
/// Silently ignored by backends that do not support it.
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
}
impl MessageRequest {
@@ -129,7 +112,6 @@ pub struct MessageResponse {
pub stop_reason: Option<String>,
#[serde(default)]
pub stop_sequence: Option<String>,
#[serde(default)]
pub usage: Usage,
#[serde(default)]
pub request_id: Option<String>,
@@ -164,44 +146,20 @@ pub enum OutputContentBlock {
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub input_tokens: u32,
#[serde(default)]
pub cache_creation_input_tokens: u32,
#[serde(default)]
pub cache_read_input_tokens: u32,
#[serde(default)]
pub output_tokens: u32,
}
impl Usage {
#[must_use]
pub const fn total_tokens(&self) -> u32 {
self.input_tokens
+ self.output_tokens
+ self.cache_creation_input_tokens
+ self.cache_read_input_tokens
}
#[must_use]
pub const fn token_usage(&self) -> TokenUsage {
TokenUsage {
input_tokens: self.input_tokens,
output_tokens: self.output_tokens,
cache_creation_input_tokens: self.cache_creation_input_tokens,
cache_read_input_tokens: self.cache_read_input_tokens,
}
}
#[must_use]
pub fn estimated_cost_usd(&self, model: &str) -> UsageCostEstimate {
let usage = self.token_usage();
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
self.input_tokens + self.output_tokens
}
}
@@ -213,7 +171,6 @@ pub struct MessageStartEvent {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MessageDeltaEvent {
pub delta: MessageDelta,
#[serde(default)]
pub usage: Usage,
}
@@ -264,47 +221,3 @@ pub enum StreamEvent {
ContentBlockStop(ContentBlockStopEvent),
MessageStop(MessageStopEvent),
}
#[cfg(test)]
mod tests {
use runtime::format_usd;
use super::{MessageResponse, Usage};
#[test]
fn usage_total_tokens_includes_cache_tokens() {
let usage = Usage {
input_tokens: 10,
cache_creation_input_tokens: 2,
cache_read_input_tokens: 3,
output_tokens: 4,
};
assert_eq!(usage.total_tokens(), 19);
assert_eq!(usage.token_usage().total_tokens(), 19);
}
#[test]
fn message_response_estimates_cost_from_model_usage() {
let response = MessageResponse {
id: "msg_cost".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: Vec::new(),
model: "claude-sonnet-4-20250514".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1_000_000,
cache_creation_input_tokens: 100_000,
cache_read_input_tokens: 200_000,
output_tokens: 500_000,
},
request_id: None,
};
let cost = response.usage.estimated_cost_usd(&response.model);
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
assert_eq!(response.total_tokens(), 1_800_000);
}
}

View File

@@ -1,27 +1,17 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use std::time::Duration;
use api::{
AnthropicClient, ApiClient, ApiError, AuthSource, ContentBlockDelta, ContentBlockDeltaEvent,
ContentBlockStartEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OutputContentBlock, PromptCache, PromptCacheConfig, ProviderClient, StreamEvent, ToolChoice,
ToolDefinition,
AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
StreamEvent, ToolChoice, ToolDefinition,
};
use serde_json::json;
use telemetry::{ClientIdentity, MemoryTelemetrySink, SessionTracer, TelemetryEvent};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| StdMutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[tokio::test]
async fn send_message_posts_json_and_parses_response() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -44,7 +34,7 @@ async fn send_message_posts_json_and_parses_response() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url());
let response = client
@@ -55,8 +45,6 @@ async fn send_message_posts_json_and_parses_response() {
assert_eq!(response.id, "msg_test");
assert_eq!(response.total_tokens(), 16);
assert_eq!(response.request_id.as_deref(), Some("req_body_123"));
assert_eq!(response.usage.cache_creation_input_tokens, 0);
assert_eq!(response.usage.cache_read_input_tokens, 0);
assert_eq!(
response.content,
vec![OutputContentBlock::Text {
@@ -76,18 +64,6 @@ async fn send_message_posts_json_and_parses_response() {
request.headers.get("authorization").map(String::as_str),
Some("Bearer proxy-token")
);
assert_eq!(
request.headers.get("anthropic-version").map(String::as_str),
Some("2023-06-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.0")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(
@@ -97,162 +73,24 @@ async fn send_message_posts_json_and_parses_response() {
assert!(body.get("stream").is_none());
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
assert_eq!(body["tool_choice"]["type"], json!("auto"));
assert!(
body.get("betas").is_none(),
"betas must travel via the anthropic-beta header, not the request body"
);
}
#[tokio::test]
async fn send_message_blocks_oversized_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "claude-sonnet-4-6".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(600_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
..Default::default()
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
async fn send_message_applies_request_profile_and_records_telemetry() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_profile\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"cache_creation_input_tokens\":2,\"cache_read_input_tokens\":3,\"output_tokens\":1}",
"}"
),
&[("request-id", "req_profile_123")],
)],
)
.await;
let sink = Arc::new(MemoryTelemetrySink::default());
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_client_identity(ClientIdentity::new("claude-code", "9.9.9").with_runtime("rust-cli"))
.with_beta("tools-2026-04-01")
.with_extra_body_param("metadata", json!({"source": "clawd-code"}))
.with_session_tracer(SessionTracer::new("session-telemetry", sink.clone()));
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.request_id.as_deref(), Some("req_profile_123"));
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/9.9.9")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
assert!(
body.get("betas").is_none(),
"betas must travel via the anthropic-beta header, not the request body"
);
let events = sink.events();
assert_eq!(events.len(), 6);
assert!(matches!(
&events[0],
TelemetryEvent::HttpRequestStarted {
session_id,
attempt: 1,
method,
path,
..
} if session_id == "session-telemetry" && method == "POST" && path == "/v1/messages"
));
assert!(matches!(
&events[1],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_started"
));
assert!(matches!(
&events[2],
TelemetryEvent::HttpRequestSucceeded {
request_id,
status: 200,
..
} if request_id.as_deref() == Some("req_profile_123")
));
assert!(matches!(
&events[3],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_succeeded"
));
assert!(matches!(
&events[4],
TelemetryEvent::Analytics(event)
if event.namespace == "api"
&& event.action == "message_usage"
&& event.properties.get("request_id") == Some(&json!("req_profile_123"))
&& event.properties.get("total_tokens") == Some(&json!(7))
&& event.properties.get("estimated_cost_usd") == Some(&json!("$0.0001"))
));
assert!(matches!(
&events[5],
TelemetryEvent::SessionTrace(trace) if trace.name == "analytics"
));
}
#[tokio::test]
async fn send_message_parses_prompt_cache_token_usage_from_response() {
async fn send_message_parses_response_with_thinking_blocks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_cache_tokens\",",
"\"id\":\"msg_thinking\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"Cache tokens\"}],",
"\"content\":[",
"{\"type\":\"thinking\",\"thinking\":\"step 1\",\"signature\":\"sig_123\"},",
"{\"type\":\"text\",\"text\":\"Final answer\"}",
"],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":12,\"cache_creation_input_tokens\":321,\"cache_read_input_tokens\":654,\"output_tokens\":4}",
"\"usage\":{\"input_tokens\":12,\"output_tokens\":4}",
"}"
);
let server = spawn_server(
@@ -267,67 +105,24 @@ async fn send_message_parses_prompt_cache_token_usage_from_response() {
.await
.expect("request should succeed");
assert_eq!(response.usage.input_tokens, 12);
assert_eq!(response.usage.cache_creation_input_tokens, 321);
assert_eq!(response.usage.cache_read_input_tokens, 654);
assert_eq!(response.usage.output_tokens, 4);
assert_eq!(response.content.len(), 2);
assert!(matches!(
&response.content[0],
OutputContentBlock::Thinking { thinking, signature }
if thinking == "step 1" && signature.as_deref() == Some("sig_123")
));
assert!(matches!(
&response.content[1],
OutputContentBlock::Text { text } if text == "Final answer"
));
}
#[tokio::test]
async fn given_empty_usage_object_when_send_message_parses_response_then_usage_defaults_to_zero() {
// given
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"msg_empty_usage\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"Hello from Claude\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{}",
"}"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
// when
let response = client
.send_message(&sample_request(false))
.await
.expect("response with empty usage object should still parse");
// then
assert_eq!(response.id, "msg_empty_usage");
assert_eq!(response.total_tokens(), 0);
assert_eq!(response.usage.input_tokens, 0);
assert_eq!(response.usage.cache_creation_input_tokens, 0);
assert_eq!(response.usage.cache_read_input_tokens, 0);
assert_eq!(response.usage.output_tokens, 0);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn stream_message_parses_sse_events_with_tool_use() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-stream-cache-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"cache_creation_input_tokens\":13,\"cache_read_input_tokens\":21,\"output_tokens\":0}}}\n\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_123\",\"name\":\"get_weather\",\"input\":{}}}\n\n",
"event: content_block_delta\n",
@@ -335,7 +130,7 @@ async fn stream_message_parses_sse_events_with_tool_use() {
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"cache_creation_input_tokens\":34,\"cache_read_input_tokens\":55,\"output_tokens\":1}}\n\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
"data: [DONE]\n\n"
@@ -351,10 +146,9 @@ async fn stream_message_parses_sse_events_with_tool_use() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_auth_token(Some("proxy-token".to_string()))
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("stream-session"));
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
@@ -408,20 +202,85 @@ async fn stream_message_parses_sse_events_with_tool_use() {
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert!(request.body.contains("\"stream\":true"));
}
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.tracked_requests, 1);
assert_eq!(cache_stats.last_cache_creation_input_tokens, Some(34));
assert_eq!(cache_stats.last_cache_read_input_tokens, Some(55));
assert_eq!(
cache_stats.last_cache_source.as_deref(),
Some("api-response")
#[tokio::test]
async fn stream_message_parses_sse_events_with_thinking_blocks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream_thinking\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"step 1\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"sig_123\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"Final answer\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state,
vec![http_response("200 OK", "text/event-stream", sse)],
)
.await;
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
let client = AnthropicClient::new("test-key").with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
let mut events = Vec::new();
while let Some(event) = stream
.next_event()
.await
.expect("stream event should parse")
{
events.push(event);
}
assert_eq!(events.len(), 9);
assert!(matches!(
&events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Thinking { thinking, signature },
..
}) if thinking.is_empty() && signature.is_none()
));
assert!(matches!(
&events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::ThinkingDelta { thinking },
..
}) if thinking == "step 1"
));
assert!(matches!(
&events[3],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::SignatureDelta { signature },
..
}) if signature == "sig_123"
));
assert!(matches!(
&events[5],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { text },
..
}) if text == "Final answer"
));
assert!(matches!(events[6], StreamEvent::ContentBlockStop(_)));
assert!(matches!(events[7], StreamEvent::MessageDelta(_)));
assert!(matches!(events[8], StreamEvent::MessageStop(_)));
}
#[tokio::test]
@@ -444,7 +303,7 @@ async fn retries_retryable_failures_before_succeeding() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(2, Duration::from_millis(1), Duration::from_millis(2));
@@ -457,47 +316,6 @@ async fn retries_retryable_failures_before_succeeding() {
assert_eq!(state.lock().await.len(), 2);
}
#[tokio::test]
async fn provider_client_dispatches_anthropic_requests() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_provider\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Dispatched\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}",
)],
)
.await;
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
Some(AuthSource::ApiKey("test-key".to_string())),
)
.expect("anthropic provider client should be constructed");
let client = match client {
ProviderClient::Anthropic(client) => {
ProviderClient::Anthropic(client.with_base_url(server.base_url()))
}
other => panic!("expected anthropic provider, got {other:?}"),
};
let response = client
.send_message(&sample_request(false))
.await
.expect("provider-dispatched request should succeed");
assert_eq!(response.total_tokens(), 5);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/v1/messages");
assert_eq!(
request.headers.get("x-api-key").map(String::as_str),
Some("test-key")
);
}
#[tokio::test]
async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
@@ -518,7 +336,7 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
)
.await;
let client = ApiClient::new("test-key")
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(1, Duration::from_millis(1), Duration::from_millis(2));
@@ -546,190 +364,10 @@ async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
}
}
#[tokio::test]
async fn retries_multiple_retryable_failures_with_exponential_backoff_and_jitter() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![
http_response(
"429 Too Many Requests",
"application/json",
"{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"slow down\"}}",
),
http_response(
"500 Internal Server Error",
"application/json",
"{\"type\":\"error\",\"error\":{\"type\":\"api_error\",\"message\":\"boom\"}}",
),
http_response(
"503 Service Unavailable",
"application/json",
"{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}",
),
http_response(
"429 Too Many Requests",
"application/json",
"{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"slow down again\"}}",
),
http_response(
"503 Service Unavailable",
"application/json",
"{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"still busy\"}}",
),
http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_exp_retry\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Recovered after 5\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}",
),
],
)
.await;
let client = ApiClient::new("test-key")
.with_base_url(server.base_url())
.with_retry_policy(8, Duration::from_millis(1), Duration::from_millis(4));
let started_at = std::time::Instant::now();
let response = client
.send_message(&sample_request(false))
.await
.expect("8-retry policy should absorb 5 retryable failures");
let elapsed = started_at.elapsed();
assert_eq!(response.total_tokens(), 5);
assert_eq!(
state.lock().await.len(),
6,
"client should issue 1 original + 5 retry requests before the 200"
);
// Jittered sleeps are bounded by 2 * max_backoff per retry (base + jitter),
// so 5 sleeps fit comfortably below this upper bound with generous slack.
assert!(
elapsed < Duration::from_secs(5),
"retries should complete promptly, took {elapsed:?}"
);
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn send_message_reuses_recent_completion_cache_entries() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-prompt-cache-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_cached\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Cached once\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":4000,\"output_tokens\":2}}",
)],
)
.await;
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::new("integration-session"));
let first = client
.send_message(&sample_request(false))
.await
.expect("first request should succeed");
let second = client
.send_message(&sample_request(false))
.await
.expect("second request should reuse cache");
assert_eq!(first.content, second.content);
assert_eq!(state.lock().await.len(), 1);
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.completion_cache_hits, 1);
assert_eq!(cache_stats.completion_cache_misses, 1);
assert_eq!(cache_stats.completion_cache_writes, 1);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn send_message_tracks_unexpected_prompt_cache_breaks() {
let _guard = env_lock();
let temp_root = std::env::temp_dir().join(format!(
"api-prompt-break-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos()
));
std::env::set_var("CLAUDE_CONFIG_HOME", &temp_root);
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state,
vec![
http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_one\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"One\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":5,\"cache_read_input_tokens\":6000,\"output_tokens\":2}}",
),
http_response(
"200 OK",
"application/json",
"{\"id\":\"msg_two\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Two\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":1000,\"output_tokens\":2}}",
),
],
)
.await;
let request = sample_request(false);
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_prompt_cache(PromptCache::with_config(PromptCacheConfig {
session_id: "break-session".to_string(),
completion_ttl: Duration::from_secs(0),
..PromptCacheConfig::default()
}));
client
.send_message(&request)
.await
.expect("first response should succeed");
client
.send_message(&request)
.await
.expect("second response should succeed");
let cache_stats = client
.prompt_cache_stats()
.expect("prompt cache stats should exist");
assert_eq!(cache_stats.unexpected_cache_breaks, 1);
assert_eq!(
cache_stats.last_break_reason.as_deref(),
Some("cache read tokens dropped while prompt fingerprint remained stable")
);
std::fs::remove_dir_all(temp_root).expect("cleanup temp root");
std::env::remove_var("CLAUDE_CONFIG_HOME");
}
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY and network access"]
async fn live_stream_smoke_test() {
let client = ApiClient::from_env().expect("ANTHROPIC_API_KEY must be set");
let client = AnthropicClient::from_env().expect("ANTHROPIC_API_KEY must be set");
let mut stream = client
.stream_message(&MessageRequest {
model: std::env::var("ANTHROPIC_MODEL")
@@ -742,7 +380,6 @@ async fn live_stream_smoke_test() {
tools: None,
tool_choice: None,
stream: false,
..Default::default()
})
.await
.expect("live stream should start");
@@ -923,6 +560,5 @@ fn sample_request(stream: bool) -> MessageRequest {
}]),
tool_choice: Some(ToolChoice::Auto),
stream,
..Default::default()
}
}

View File

@@ -1,531 +0,0 @@
use std::collections::HashMap;
use std::ffi::OsString;
use std::sync::Arc;
use std::sync::{Mutex as StdMutex, OnceLock};
use api::{
ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
ContentBlockStopEvent, InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest,
OpenAiCompatClient, OpenAiCompatConfig, OutputContentBlock, ProviderClient, StreamEvent,
ToolChoice, ToolDefinition,
};
use serde_json::json;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
#[tokio::test]
async fn send_message_uses_openai_compatible_endpoint_and_auth() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_test\",",
"\"model\":\"grok-3\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Hello from Grok\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":5}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.model, "grok-3");
assert_eq!(response.total_tokens(), 16);
assert_eq!(
response.content,
vec![OutputContentBlock::Text {
text: "Hello from Grok".to_string(),
}]
);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/chat/completions");
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer xai-test-key")
);
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["model"], json!("grok-3"));
assert_eq!(body["messages"][0]["role"], json!("system"));
assert_eq!(body["tools"][0]["type"], json!("function"));
}
#[tokio::test]
async fn send_message_blocks_oversized_xai_requests_before_the_http_call() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", "{}")],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let error = client
.send_message(&MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64_000,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "x".repeat(300_000),
}],
}],
system: Some("Keep the answer short.".to_string()),
tools: None,
tool_choice: None,
stream: false,
..Default::default()
})
.await
.expect_err("oversized request should fail local context-window preflight");
assert!(matches!(error, ApiError::ContextWindowExceeded { .. }));
assert!(
state.lock().await.is_empty(),
"preflight failure should avoid any upstream HTTP request"
);
}
#[tokio::test]
async fn send_message_accepts_full_chat_completions_endpoint_override() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let body = concat!(
"{",
"\"id\":\"chatcmpl_full_endpoint\",",
"\"model\":\"grok-3\",",
"\"choices\":[{",
"\"message\":{\"role\":\"assistant\",\"content\":\"Endpoint override works\",\"tool_calls\":[]},",
"\"finish_reason\":\"stop\"",
"}],",
"\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}",
"}"
);
let server = spawn_server(
state.clone(),
vec![http_response("200 OK", "application/json", body)],
)
.await;
let endpoint_url = format!("{}/chat/completions", server.base_url());
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(endpoint_url);
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.total_tokens(), 10);
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(request.path, "/chat/completions");
}
#[tokio::test]
async fn stream_message_normalizes_text_and_multiple_tool_calls() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_stream\",\"model\":\"grok-3\",\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}},{\"index\":1,\"id\":\"call_2\",\"function\":{\"name\":\"clock\",\"arguments\":\"{\\\"zone\\\":\\\"UTC\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_grok_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("xai-test-key", OpenAiCompatConfig::xai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_grok_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { .. },
..
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::TextDelta { .. },
..
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 1,
content_block: OutputContentBlock::ToolUse { .. },
})
));
assert!(matches!(
events[4],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 1,
delta: ContentBlockDelta::InputJsonDelta { .. },
})
));
assert!(matches!(
events[5],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
index: 2,
content_block: OutputContentBlock::ToolUse { .. },
})
));
assert!(matches!(
events[6],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
index: 2,
delta: ContentBlockDelta::InputJsonDelta { .. },
})
));
assert!(matches!(
events[7],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 1 })
));
assert!(matches!(
events[8],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 2 })
));
assert!(matches!(
events[9],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(events[10], StreamEvent::MessageDelta(_)));
assert!(matches!(events[11], StreamEvent::MessageStop(_)));
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
assert!(request.body.contains("\"stream\":true"));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn openai_streaming_requests_opt_into_usage_chunks() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let sse = concat!(
"data: {\"id\":\"chatcmpl_openai_stream\",\"model\":\"gpt-5\",\"choices\":[{\"delta\":{\"content\":\"Hi\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
"data: {\"id\":\"chatcmpl_openai_stream\",\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}\n\n",
"data: [DONE]\n\n"
);
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"text/event-stream",
sse,
&[("x-request-id", "req_openai_stream")],
)],
)
.await;
let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai())
.with_base_url(server.base_url());
let mut stream = client
.stream_message(&sample_request(false))
.await
.expect("stream should start");
assert_eq!(stream.request_id(), Some("req_openai_stream"));
let mut events = Vec::new();
while let Some(event) = stream.next_event().await.expect("event should parse") {
events.push(event);
}
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
assert!(matches!(
events[1],
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
content_block: OutputContentBlock::Text { .. },
..
})
));
assert!(matches!(
events[2],
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
delta: ContentBlockDelta::TextDelta { .. },
..
})
));
assert!(matches!(
events[3],
StreamEvent::ContentBlockStop(ContentBlockStopEvent { index: 0 })
));
assert!(matches!(
events[4],
StreamEvent::MessageDelta(MessageDeltaEvent { .. })
));
assert!(matches!(events[5], StreamEvent::MessageStop(_)));
match &events[4] {
StreamEvent::MessageDelta(MessageDeltaEvent { usage, .. }) => {
assert_eq!(usage.input_tokens, 9);
assert_eq!(usage.output_tokens, 4);
}
other => panic!("expected message delta, got {other:?}"),
}
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body");
assert_eq!(body["stream"], json!(true));
assert_eq!(body["stream_options"], json!({"include_usage": true}));
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn provider_client_dispatches_xai_requests_from_env() {
let _lock = env_lock();
let _api_key = ScopedEnvVar::set("XAI_API_KEY", "xai-test-key");
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response(
"200 OK",
"application/json",
"{\"id\":\"chatcmpl_provider\",\"model\":\"grok-3\",\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\"Through provider client\",\"tool_calls\":[]},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":4}}",
)],
)
.await;
let _base_url = ScopedEnvVar::set("XAI_BASE_URL", server.base_url());
let client =
ProviderClient::from_model("grok").expect("xAI provider client should be constructed");
assert!(matches!(client, ProviderClient::Xai(_)));
let response = client
.send_message(&sample_request(false))
.await
.expect("provider-dispatched request should succeed");
assert_eq!(response.total_tokens(), 13);
let captured = state.lock().await;
let request = captured.first().expect("captured request");
assert_eq!(request.path, "/chat/completions");
assert_eq!(
request.headers.get("authorization").map(String::as_str),
Some("Bearer xai-test-key")
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CapturedRequest {
path: String,
headers: HashMap<String, String>,
body: String,
}
struct TestServer {
base_url: String,
join_handle: tokio::task::JoinHandle<()>,
}
impl TestServer {
fn base_url(&self) -> String {
self.base_url.clone()
}
}
impl Drop for TestServer {
fn drop(&mut self) {
self.join_handle.abort();
}
}
async fn spawn_server(
state: Arc<Mutex<Vec<CapturedRequest>>>,
responses: Vec<String>,
) -> TestServer {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let address = listener.local_addr().expect("listener addr");
let join_handle = tokio::spawn(async move {
for response in responses {
let (mut socket, _) = listener.accept().await.expect("accept");
let mut buffer = Vec::new();
let mut header_end = None;
loop {
let mut chunk = [0_u8; 1024];
let read = socket.read(&mut chunk).await.expect("read request");
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if let Some(position) = find_header_end(&buffer) {
header_end = Some(position);
break;
}
}
let header_end = header_end.expect("headers should exist");
let (header_bytes, remaining) = buffer.split_at(header_end);
let header_text = String::from_utf8(header_bytes.to_vec()).expect("utf8 headers");
let mut lines = header_text.split("\r\n");
let request_line = lines.next().expect("request line");
let path = request_line
.split_whitespace()
.nth(1)
.expect("path")
.to_string();
let mut headers = HashMap::new();
let mut content_length = 0_usize;
for line in lines {
if line.is_empty() {
continue;
}
let (name, value) = line.split_once(':').expect("header");
let value = value.trim().to_string();
if name.eq_ignore_ascii_case("content-length") {
content_length = value.parse().expect("content length");
}
headers.insert(name.to_ascii_lowercase(), value);
}
let mut body = remaining[4..].to_vec();
while body.len() < content_length {
let mut chunk = vec![0_u8; content_length - body.len()];
let read = socket.read(&mut chunk).await.expect("read body");
if read == 0 {
break;
}
body.extend_from_slice(&chunk[..read]);
}
state.lock().await.push(CapturedRequest {
path,
headers,
body: String::from_utf8(body).expect("utf8 body"),
});
socket
.write_all(response.as_bytes())
.await
.expect("write response");
}
});
TestServer {
base_url: format!("http://{address}"),
join_handle,
}
}
fn find_header_end(bytes: &[u8]) -> Option<usize> {
bytes.windows(4).position(|window| window == b"\r\n\r\n")
}
fn http_response(status: &str, content_type: &str, body: &str) -> String {
http_response_with_headers(status, content_type, body, &[])
}
fn http_response_with_headers(
status: &str,
content_type: &str,
body: &str,
headers: &[(&str, &str)],
) -> String {
let mut extra_headers = String::new();
for (name, value) in headers {
use std::fmt::Write as _;
write!(&mut extra_headers, "{name}: {value}\r\n").expect("header write");
}
format!(
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\n{extra_headers}content-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
}
fn sample_request(stream: bool) -> MessageRequest {
MessageRequest {
model: "grok-3".to_string(),
max_tokens: 64,
messages: vec![InputMessage {
role: "user".to_string(),
content: vec![InputContentBlock::Text {
text: "Say hello".to_string(),
}],
}],
system: Some("Use tools when needed".to_string()),
tools: Some(vec![ToolDefinition {
name: "weather".to_string(),
description: Some("Fetches weather".to_string()),
input_schema: json!({
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}),
}]),
tool_choice: Some(ToolChoice::Auto),
stream,
..Default::default()
}
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
LOCK.get_or_init(|| StdMutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct ScopedEnvVar {
key: &'static str,
previous: Option<OsString>,
}
impl ScopedEnvVar {
fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
let previous = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for ScopedEnvVar {
fn drop(&mut self) {
match &self.previous {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}

View File

@@ -1,88 +0,0 @@
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use api::{read_xai_base_url, ApiError, AuthSource, ProviderClient, ProviderKind};
#[test]
fn provider_client_routes_grok_aliases_through_xai() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
let client = ProviderClient::from_model("grok-mini").expect("grok alias should resolve");
assert_eq!(client.provider_kind(), ProviderKind::Xai);
}
#[test]
fn provider_client_reports_missing_xai_credentials_for_grok_models() {
let _lock = env_lock();
let _xai_api_key = EnvVarGuard::set("XAI_API_KEY", None);
let error = ProviderClient::from_model("grok-3")
.expect_err("grok requests without XAI_API_KEY should fail fast");
match error {
ApiError::MissingCredentials {
provider, env_vars, ..
} => {
assert_eq!(provider, "xAI");
assert_eq!(env_vars, &["XAI_API_KEY"]);
}
other => panic!("expected missing xAI credentials, got {other:?}"),
}
}
#[test]
fn provider_client_uses_explicit_anthropic_auth_without_env_lookup() {
let _lock = env_lock();
let _anthropic_api_key = EnvVarGuard::set("ANTHROPIC_API_KEY", None);
let _anthropic_auth_token = EnvVarGuard::set("ANTHROPIC_AUTH_TOKEN", None);
let client = ProviderClient::from_model_with_anthropic_auth(
"claude-sonnet-4-6",
Some(AuthSource::ApiKey("anthropic-test-key".to_string())),
)
.expect("explicit anthropic auth should avoid env lookup");
assert_eq!(client.provider_kind(), ProviderKind::Anthropic);
}
#[test]
fn read_xai_base_url_prefers_env_override() {
let _lock = env_lock();
let _xai_base_url = EnvVarGuard::set("XAI_BASE_URL", Some("https://example.xai.test/v1"));
assert_eq!(read_xai_base_url(), "https://example.xai.test/v1");
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}

View File

@@ -1,173 +0,0 @@
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use api::{build_http_client_with, ProxyConfig};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn proxy_config_from_env_reads_uppercase_proxy_vars() {
// given
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost,127.0.0.1"));
let _http_lower = EnvVarGuard::set("http_proxy", None);
let _https_lower = EnvVarGuard::set("https_proxy", None);
let _no_lower = EnvVarGuard::set("no_proxy", None);
// when
let config = ProxyConfig::from_env();
// then
assert_eq!(config.http_proxy.as_deref(), Some("http://proxy.corp:3128"));
assert_eq!(
config.https_proxy.as_deref(),
Some("http://secure.corp:3129")
);
assert_eq!(config.no_proxy.as_deref(), Some("localhost,127.0.0.1"));
assert!(config.proxy_url.is_none());
assert!(!config.is_empty());
}
#[test]
fn proxy_config_from_env_reads_lowercase_proxy_vars() {
// given
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", None);
let _https = EnvVarGuard::set("HTTPS_PROXY", None);
let _no = EnvVarGuard::set("NO_PROXY", None);
let _http_lower = EnvVarGuard::set("http_proxy", Some("http://lower.corp:3128"));
let _https_lower = EnvVarGuard::set("https_proxy", Some("http://lower-secure.corp:3129"));
let _no_lower = EnvVarGuard::set("no_proxy", Some(".internal"));
// when
let config = ProxyConfig::from_env();
// then
assert_eq!(config.http_proxy.as_deref(), Some("http://lower.corp:3128"));
assert_eq!(
config.https_proxy.as_deref(),
Some("http://lower-secure.corp:3129")
);
assert_eq!(config.no_proxy.as_deref(), Some(".internal"));
assert!(!config.is_empty());
}
#[test]
fn proxy_config_from_env_is_empty_when_no_vars_set() {
// given
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", None);
let _https = EnvVarGuard::set("HTTPS_PROXY", None);
let _no = EnvVarGuard::set("NO_PROXY", None);
let _http_lower = EnvVarGuard::set("http_proxy", None);
let _https_lower = EnvVarGuard::set("https_proxy", None);
let _no_lower = EnvVarGuard::set("no_proxy", None);
// when
let config = ProxyConfig::from_env();
// then
assert!(config.is_empty());
assert!(config.http_proxy.is_none());
assert!(config.https_proxy.is_none());
assert!(config.no_proxy.is_none());
}
#[test]
fn proxy_config_from_env_treats_empty_values_as_unset() {
// given
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", Some(""));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some(""));
let _http_lower = EnvVarGuard::set("http_proxy", Some(""));
let _https_lower = EnvVarGuard::set("https_proxy", Some(""));
let _no = EnvVarGuard::set("NO_PROXY", Some(""));
let _no_lower = EnvVarGuard::set("no_proxy", Some(""));
// when
let config = ProxyConfig::from_env();
// then
assert!(config.is_empty());
}
#[test]
fn build_client_with_env_proxy_config_succeeds() {
// given
let _lock = env_lock();
let _http = EnvVarGuard::set("HTTP_PROXY", Some("http://proxy.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", Some("http://secure.corp:3129"));
let _no = EnvVarGuard::set("NO_PROXY", Some("localhost"));
let _http_lower = EnvVarGuard::set("http_proxy", None);
let _https_lower = EnvVarGuard::set("https_proxy", None);
let _no_lower = EnvVarGuard::set("no_proxy", None);
let config = ProxyConfig::from_env();
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn build_client_with_proxy_url_config_succeeds() {
// given
let config = ProxyConfig::from_proxy_url("http://unified.corp:3128");
// when
let result = build_http_client_with(&config);
// then
assert!(result.is_ok());
}
#[test]
fn proxy_config_from_env_prefers_uppercase_over_lowercase() {
// given
let _lock = env_lock();
let _http_upper = EnvVarGuard::set("HTTP_PROXY", Some("http://upper.corp:3128"));
let _http_lower = EnvVarGuard::set("http_proxy", Some("http://lower.corp:3128"));
let _https = EnvVarGuard::set("HTTPS_PROXY", None);
let _https_lower = EnvVarGuard::set("https_proxy", None);
let _no = EnvVarGuard::set("NO_PROXY", None);
let _no_lower = EnvVarGuard::set("no_proxy", None);
// when
let config = ProxyConfig::from_env();
// then
assert_eq!(config.http_proxy.as_deref(), Some("http://upper.corp:3128"));
}

View File

@@ -11,4 +11,3 @@ workspace = true
[dependencies]
plugins = { path = "../plugins" }
runtime = { path = "../runtime" }
serde_json.workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -18,12 +18,6 @@ impl UpstreamPaths {
}
}
/// Returns the repository root path.
#[must_use]
pub fn repo_root(&self) -> &Path {
&self.repo_root
}
#[must_use]
pub fn from_workspace_dir(workspace_dir: impl AsRef<Path>) -> Self {
let workspace_dir = workspace_dir

View File

@@ -1,18 +0,0 @@
[package]
name = "mock-anthropic-service"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[[bin]]
name = "mock-anthropic-service"
path = "src/main.rs"
[dependencies]
api = { path = "../api" }
serde_json.workspace = true
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync"] }
[lints]
workspace = true

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +0,0 @@
use std::env;
use mock_anthropic_service::MockAnthropicService;
#[tokio::main(flavor = "multi_thread")]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut bind_addr = String::from("127.0.0.1:0");
let mut args = env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--bind" => {
bind_addr = args
.next()
.ok_or_else(|| "missing value for --bind".to_string())?;
}
flag if flag.starts_with("--bind=") => {
bind_addr = flag[7..].to_string();
}
"--help" | "-h" => {
println!("Usage: mock-anthropic-service [--bind HOST:PORT]");
return Ok(());
}
other => {
return Err(format!("unsupported argument: {other}").into());
}
}
}
let server = MockAnthropicService::spawn_on(&bind_addr).await?;
println!("MOCK_ANTHROPIC_BASE_URL={}", server.base_url());
tokio::signal::ctrl_c().await?;
drop(server);
Ok(())
}

View File

@@ -7,7 +7,7 @@ publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
serde_json = "1"
[lints]
workspace = true

View File

@@ -10,7 +10,6 @@ use crate::{PluginError, PluginHooks, PluginRegistry};
pub enum HookEvent {
PreToolUse,
PostToolUse,
PostToolUseFailure,
}
impl HookEvent {
@@ -18,7 +17,6 @@ impl HookEvent {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PostToolUseFailure => "PostToolUseFailure",
}
}
}
@@ -26,7 +24,6 @@ impl HookEvent {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
failed: bool,
messages: Vec<String>,
}
@@ -35,7 +32,6 @@ impl HookRunResult {
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
failed: false,
messages,
}
}
@@ -45,11 +41,6 @@ impl HookRunResult {
self.denied
}
#[must_use]
pub fn is_failed(&self) -> bool {
self.failed
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
@@ -73,7 +64,7 @@ impl HookRunner {
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
Self::run_commands(
self.run_commands(
HookEvent::PreToolUse,
&self.hooks.pre_tool_use,
tool_name,
@@ -91,7 +82,7 @@ impl HookRunner {
tool_output: &str,
is_error: bool,
) -> HookRunResult {
Self::run_commands(
self.run_commands(
HookEvent::PostToolUse,
&self.hooks.post_tool_use,
tool_name,
@@ -101,24 +92,8 @@ impl HookRunner {
)
}
#[must_use]
pub fn run_post_tool_use_failure(
&self,
tool_name: &str,
tool_input: &str,
tool_error: &str,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUseFailure,
&self.hooks.post_tool_use_failure,
tool_name,
tool_input,
Some(tool_error),
true,
)
}
fn run_commands(
&self,
event: HookEvent,
commands: &[String],
tool_name: &str,
@@ -130,12 +105,20 @@ impl HookRunner {
return HookRunResult::allow(Vec::new());
}
let payload = hook_payload(event, tool_name, tool_input, tool_output, is_error).to_string();
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let mut messages = Vec::new();
for command in commands {
match Self::run_command(
match self.run_command(
command,
event,
tool_name,
@@ -155,26 +138,19 @@ impl HookRunner {
}));
return HookRunResult {
denied: true,
failed: false,
messages,
};
}
HookCommandOutcome::Failed { message } => {
messages.push(message);
return HookRunResult {
denied: false,
failed: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_arguments, clippy::unused_self)]
fn run_command(
&self,
command: &str,
event: HookEvent,
tool_name: &str,
@@ -203,7 +179,7 @@ impl HookRunner {
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Failed {
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
@@ -211,7 +187,7 @@ impl HookRunner {
stderr.as_str(),
),
},
None => HookCommandOutcome::Failed {
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
@@ -219,7 +195,7 @@ impl HookRunner {
},
}
}
Err(error) => HookCommandOutcome::Failed {
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
event.as_str()
@@ -232,34 +208,7 @@ impl HookRunner {
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Failed { message: String },
}
fn hook_payload(
event: HookEvent,
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> serde_json::Value {
match event {
HookEvent::PostToolUseFailure => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_error": tool_output,
"tool_result_is_error": true,
}),
_ => json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
}),
}
Warn { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
@@ -267,7 +216,8 @@ fn parse_tool_input(tool_input: &str) -> serde_json::Value {
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message = format!("Hook `{command}` exited with status {code}");
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
@@ -337,28 +287,7 @@ impl CommandWithStdin {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write as _;
// Tolerate BrokenPipe: a hook script that runs to completion
// (or exits early without reading stdin) closes its stdin
// before the parent finishes writing the JSON payload, and
// the kernel raises EPIPE on the parent's write_all. That is
// not a hook failure — the child still exited cleanly and we
// still need to wait_with_output() to capture stdout/stderr
// and the real exit code. Other write errors (e.g. EIO,
// permission, OOM) still propagate.
//
// This was the root cause of the Linux CI flake on
// hooks::tests::collects_and_runs_hooks_from_enabled_plugins
// (ROADMAP #25, runs 24120271422 / 24120538408 / 24121392171
// / 24121776826): the test hook scripts run in microseconds
// and the parent's stdin write races against child exit.
// macOS pipes happen to buffer the small payload before the
// child exits; Linux pipes do not, so the race shows up
// deterministically on ubuntu runners.
match child_stdin.write_all(stdin) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(error) => return Err(error),
}
child_stdin.write_all(stdin)?;
}
child.wait_with_output()
}
@@ -380,55 +309,23 @@ mod tests {
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}
fn make_executable(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(path, perms)
.unwrap_or_else(|e| panic!("chmod +x {}: {e}", path.display()));
}
#[cfg(not(unix))]
let _ = path;
}
fn write_hook_plugin(
root: &Path,
name: &str,
pre_message: &str,
post_message: &str,
failure_message: &str,
) {
fn write_hook_plugin(root: &Path, name: &str, pre_message: &str, post_message: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
let pre_path = root.join("hooks").join("pre.sh");
fs::write(
&pre_path,
root.join("hooks").join("pre.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
make_executable(&pre_path);
let post_path = root.join("hooks").join("post.sh");
fs::write(
&post_path,
root.join("hooks").join("post.sh"),
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
make_executable(&post_path);
let failure_path = root.join("hooks").join("failure.sh");
fs::write(
&failure_path,
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
)
.expect("write failure hook");
make_executable(&failure_path);
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"],\n \"PostToolUseFailure\": [\"./hooks/failure.sh\"]\n }}\n}}"
"{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"description\": \"hook plugin\",\n \"hooks\": {{\n \"PreToolUse\": [\"./hooks/pre.sh\"],\n \"PostToolUse\": [\"./hooks/post.sh\"]\n }}\n}}"
),
)
.expect("write plugin manifest");
@@ -436,7 +333,6 @@ mod tests {
#[test]
fn collects_and_runs_hooks_from_enabled_plugins() {
// given
let config_home = temp_dir("config");
let first_source_root = temp_dir("source-a");
let second_source_root = temp_dir("source-b");
@@ -445,14 +341,12 @@ mod tests {
"first",
"plugin pre one",
"plugin post one",
"plugin failure one",
);
write_hook_plugin(
&second_source_root,
"second",
"plugin pre two",
"plugin post two",
"plugin failure two",
);
let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
@@ -464,10 +358,8 @@ mod tests {
.expect("second plugin install should succeed");
let registry = manager.plugin_registry().expect("registry should build");
// when
let runner = HookRunner::from_registry(&registry).expect("plugin hooks should load");
// then
assert_eq!(
runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#),
HookRunResult::allow(vec![
@@ -482,13 +374,6 @@ mod tests {
"plugin post two".to_string(),
])
);
assert_eq!(
runner.run_post_tool_use_failure("Read", r#"{"path":"README.md"}"#, "tool failed",),
HookRunResult::allow(vec![
"plugin failure one".to_string(),
"plugin failure two".to_string(),
])
);
let _ = fs::remove_dir_all(config_home);
let _ = fs::remove_dir_all(first_source_root);
@@ -497,68 +382,14 @@ mod tests {
#[test]
fn pre_tool_use_denies_when_plugin_hook_exits_two() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec!["printf 'blocked by plugin'; exit 2".to_string()],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
// when
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
// then
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by plugin".to_string()]);
}
#[test]
fn propagates_plugin_hook_failures() {
// given
let runner = HookRunner::new(crate::PluginHooks {
pre_tool_use: vec![
"printf 'broken plugin hook'; exit 1".to_string(),
"printf 'later plugin hook'".to_string(),
],
post_tool_use: Vec::new(),
post_tool_use_failure: Vec::new(),
});
// when
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
// then
assert!(result.is_failed());
assert!(result
.messages()
.iter()
.any(|message| message.contains("broken plugin hook")));
assert!(!result
.messages()
.iter()
.any(|message| message == "later plugin hook"));
}
#[test]
#[cfg(unix)]
fn generated_hook_scripts_are_executable() {
use std::os::unix::fs::PermissionsExt;
// given
let root = temp_dir("exec-guard");
write_hook_plugin(&root, "exec-check", "pre", "post", "fail");
// then
for script in ["pre.sh", "post.sh", "failure.sh"] {
let path = root.join("hooks").join(script);
let mode = fs::metadata(&path)
.unwrap_or_else(|e| panic!("{script} metadata: {e}"))
.permissions()
.mode();
assert!(
mode & 0o111 != 0,
"{script} must have at least one execute bit set, got mode {mode:#o}"
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
// Test isolation utilities for plugin tests
// ROADMAP #41: Stop ambient plugin state from skewing CLI regression checks
use std::env;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Mutex;
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Lock for test environment isolation
pub struct EnvLock {
_guard: std::sync::MutexGuard<'static, ()>,
temp_home: PathBuf,
}
impl EnvLock {
/// Acquire environment lock for test isolation
pub fn lock() -> Self {
let guard = ENV_LOCK.lock().unwrap();
let count = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let temp_home = std::env::temp_dir().join(format!("plugin-test-{count}"));
// Set up isolated environment
std::fs::create_dir_all(&temp_home).ok();
std::fs::create_dir_all(temp_home.join(".claude/plugins/installed")).ok();
std::fs::create_dir_all(temp_home.join(".config")).ok();
// Redirect HOME and XDG_CONFIG_HOME to temp directory
env::set_var("HOME", &temp_home);
env::set_var("XDG_CONFIG_HOME", temp_home.join(".config"));
env::set_var("XDG_DATA_HOME", temp_home.join(".local/share"));
EnvLock {
_guard: guard,
temp_home,
}
}
/// Get the temporary home directory for this test
#[must_use]
pub fn temp_home(&self) -> &PathBuf {
&self.temp_home
}
}
impl Drop for EnvLock {
fn drop(&mut self) {
// Cleanup temp directory
std::fs::remove_dir_all(&self.temp_home).ok();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_env_lock_creates_isolated_home() {
let lock = EnvLock::lock();
let home = env::var("HOME").unwrap();
assert!(home.contains("plugin-test-"));
assert_eq!(home, lock.temp_home().to_str().unwrap());
}
#[test]
fn test_env_lock_creates_plugin_directories() {
let lock = EnvLock::lock();
let plugins_dir = lock.temp_home().join(".claude/plugins/installed");
assert!(plugins_dir.exists());
}
}

View File

@@ -11,9 +11,8 @@ glob = "0.3"
plugins = { path = "../plugins" }
regex = "1"
serde = { version = "1", features = ["derive"] }
serde_json.workspace = true
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-std", "io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
serde_json = "1"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
walkdir = "2"
[lints]

View File

@@ -8,14 +8,12 @@ use tokio::process::Command as TokioCommand;
use tokio::runtime::Builder;
use tokio::time::timeout;
use crate::lane_events::{LaneEvent, ShipMergeMethod, ShipProvenance};
use crate::sandbox::{
build_linux_sandbox_command, resolve_sandbox_status_for_request, FilesystemIsolationMode,
SandboxConfig, SandboxStatus,
};
use crate::ConfigLoader;
/// Input schema for the built-in bash execution tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BashCommandInput {
pub command: String,
@@ -35,7 +33,6 @@ pub struct BashCommandInput {
pub allowed_mounts: Option<Vec<String>>,
}
/// Output returned from a bash tool invocation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct BashCommandOutput {
pub stdout: String,
@@ -67,7 +64,6 @@ pub struct BashCommandOutput {
pub sandbox_status: Option<SandboxStatus>,
}
/// Executes a shell command with the requested sandbox settings.
pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
let cwd = env::current_dir()?;
let sandbox_status = sandbox_status_for_input(&input, &cwd);
@@ -103,76 +99,11 @@ pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
runtime.block_on(execute_bash_async(input, sandbox_status, cwd))
}
/// Detect git push to main and emit ship provenance event
fn detect_and_emit_ship_prepared(command: &str) {
let trimmed = command.trim();
// Simple detection: git push with main/master
if trimmed.contains("git push") && (trimmed.contains("main") || trimmed.contains("master")) {
// Emit ship.prepared event
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let provenance = ShipProvenance {
source_branch: get_current_branch().unwrap_or_else(|| "unknown".to_string()),
base_commit: get_head_commit().unwrap_or_default(),
commit_count: 0, // Would need to calculate from range
commit_range: "unknown..HEAD".to_string(),
merge_method: ShipMergeMethod::DirectPush,
actor: get_git_actor().unwrap_or_else(|| "unknown".to_string()),
pr_number: None,
};
let _event = LaneEvent::ship_prepared(format!("{}", now), &provenance);
// Log to stderr as interim routing before event stream integration
eprintln!(
"[ship.prepared] branch={} -> main, commits={}, actor={}",
provenance.source_branch, provenance.commit_count, provenance.actor
);
}
}
fn get_current_branch() -> Option<String> {
let output = Command::new("git")
.args(["branch", "--show-current"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_head_commit() -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()?;
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
}
fn get_git_actor() -> Option<String> {
let name = Command::new("git")
.args(["config", "user.name"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())?;
Some(name)
}
async fn execute_bash_async(
input: BashCommandInput,
sandbox_status: SandboxStatus,
cwd: std::path::PathBuf,
) -> io::Result<BashCommandOutput> {
// Detect and emit ship provenance for git push operations
detect_and_emit_ship_prepared(&input.command);
let mut command = prepare_tokio_command(&input.command, &cwd, &sandbox_status, true);
let output_result = if let Some(timeout_ms) = input.timeout {
@@ -203,8 +134,8 @@ async fn execute_bash_async(
};
let (output, interrupted) = output_result;
let stdout = truncate_output(&String::from_utf8_lossy(&output.stdout));
let stderr = truncate_output(&String::from_utf8_lossy(&output.stderr));
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
let return_code_interpretation = output.status.code().and_then(|code| {
if code == 0 {
@@ -350,53 +281,3 @@ mod tests {
assert!(!output.sandbox_status.expect("sandbox status").enabled);
}
}
/// Maximum output bytes before truncation (16 KiB, matching upstream).
const MAX_OUTPUT_BYTES: usize = 16_384;
/// Truncate output to `MAX_OUTPUT_BYTES`, appending a marker when trimmed.
fn truncate_output(s: &str) -> String {
if s.len() <= MAX_OUTPUT_BYTES {
return s.to_string();
}
// Find the last valid UTF-8 boundary at or before MAX_OUTPUT_BYTES
let mut end = MAX_OUTPUT_BYTES;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let mut truncated = s[..end].to_string();
truncated.push_str("\n\n[output truncated — exceeded 16384 bytes]");
truncated
}
#[cfg(test)]
mod truncation_tests {
use super::*;
#[test]
fn short_output_unchanged() {
let s = "hello world";
assert_eq!(truncate_output(s), s);
}
#[test]
fn long_output_truncated() {
let s = "x".repeat(20_000);
let result = truncate_output(&s);
assert!(result.len() < 20_000);
assert!(result.ends_with("[output truncated — exceeded 16384 bytes]"));
}
#[test]
fn exact_boundary_unchanged() {
let s = "a".repeat(MAX_OUTPUT_BYTES);
assert_eq!(truncate_output(&s), s);
}
#[test]
fn one_over_boundary_truncated() {
let s = "a".repeat(MAX_OUTPUT_BYTES + 1);
let result = truncate_output(&s);
assert!(result.contains("[output truncated"));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -54,58 +54,3 @@ impl BootstrapPlan {
&self.phases
}
}
#[cfg(test)]
mod tests {
use super::{BootstrapPhase, BootstrapPlan};
#[test]
fn from_phases_deduplicates_while_preserving_order() {
// given
let phases = vec![
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::CliEntry,
BootstrapPhase::MainRuntime,
BootstrapPhase::FastPathVersion,
];
// when
let plan = BootstrapPlan::from_phases(phases);
// then
assert_eq!(
plan.phases(),
&[
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::MainRuntime,
]
);
}
#[test]
fn claude_code_default_covers_each_phase_once() {
// given
let expected = [
BootstrapPhase::CliEntry,
BootstrapPhase::FastPathVersion,
BootstrapPhase::StartupProfiler,
BootstrapPhase::SystemPromptFastPath,
BootstrapPhase::ChromeMcpFastPath,
BootstrapPhase::DaemonWorkerFastPath,
BootstrapPhase::BridgeFastPath,
BootstrapPhase::DaemonFastPath,
BootstrapPhase::BackgroundSessionFastPath,
BootstrapPhase::TemplateFastPath,
BootstrapPhase::EnvironmentRunnerFastPath,
BootstrapPhase::MainRuntime,
];
// when
let plan = BootstrapPlan::claude_code_default();
// then
assert_eq!(plan.phases(), &expected);
}
}

View File

@@ -1,144 +0,0 @@
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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modules: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BranchLockCollision {
pub branch: String,
pub module: String,
#[serde(rename = "laneIds")]
pub lane_ids: Vec<String>,
}
#[must_use]
pub fn detect_branch_lock_collisions(intents: &[BranchLockIntent]) -> Vec<BranchLockCollision> {
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<String> {
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());
}
}

View File

@@ -5,7 +5,6 @@ const COMPACT_CONTINUATION_PREAMBLE: &str =
const COMPACT_RECENT_MESSAGES_NOTE: &str = "Recent messages are preserved verbatim.";
const COMPACT_DIRECT_RESUME_INSTRUCTION: &str = "Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.";
/// Thresholds controlling when and how a session is compacted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompactionConfig {
pub preserve_recent_messages: usize,
@@ -21,7 +20,6 @@ impl Default for CompactionConfig {
}
}
/// Result of compacting a session into a summary plus preserved tail messages.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompactionResult {
pub summary: String,
@@ -30,13 +28,11 @@ pub struct CompactionResult {
pub removed_message_count: usize,
}
/// Roughly estimates the token footprint of the current session transcript.
#[must_use]
pub fn estimate_session_tokens(session: &Session) -> usize {
session.messages.iter().map(estimate_message_tokens).sum()
}
/// Returns `true` when the session exceeds the configured compaction budget.
#[must_use]
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
let start = compacted_summary_prefix_len(session);
@@ -50,7 +46,6 @@ pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
>= config.max_estimated_tokens
}
/// Normalizes a compaction summary into user-facing continuation text.
#[must_use]
pub fn format_compact_summary(summary: &str) -> String {
let without_analysis = strip_tag_block(summary, "analysis");
@@ -66,7 +61,6 @@ pub fn format_compact_summary(summary: &str) -> String {
collapse_blank_lines(&formatted).trim().to_string()
}
/// Builds the synthetic system message used after session compaction.
#[must_use]
pub fn get_compact_continuation_message(
summary: &str,
@@ -91,7 +85,6 @@ pub fn get_compact_continuation_message(
base
}
/// Compacts a session by summarizing older messages and preserving the recent tail.
#[must_use]
pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
if !should_compact(session, config) {
@@ -108,54 +101,10 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
.first()
.and_then(extract_existing_compacted_summary);
let compacted_prefix_len = usize::from(existing_summary.is_some());
let raw_keep_from = session
let keep_from = session
.messages
.len()
.saturating_sub(config.preserve_recent_messages);
// Ensure we do not split a tool-use / tool-result pair at the compaction
// boundary. If the first preserved message is a user message whose first
// block is a ToolResult, the assistant message with the matching ToolUse
// was slated for removal — that produces an orphaned tool role message on
// the OpenAI-compat path (400: tool message must follow assistant with
// tool_calls). Walk the boundary back until we start at a safe point.
let keep_from = {
let mut k = raw_keep_from;
// If the first preserved message is a tool-result turn, ensure its
// paired assistant tool-use turn is preserved too. Without this fix,
// the OpenAI-compat adapter sends an orphaned 'tool' role message
// with no preceding assistant 'tool_calls', which providers reject
// with a 400. We walk back only if the immediately preceding message
// is NOT an assistant message that contains a ToolUse block (i.e. the
// pair is actually broken at the boundary).
loop {
if k == 0 || k <= compacted_prefix_len {
break;
}
let first_preserved = &session.messages[k];
let starts_with_tool_result = first_preserved
.blocks
.first()
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
if !starts_with_tool_result {
break;
}
// Check the message just before the current boundary.
let preceding = &session.messages[k - 1];
let preceding_has_tool_use = preceding
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
if preceding_has_tool_use {
// Pair is intact — walk back one more to include the assistant turn.
k = k.saturating_sub(1);
break;
}
// Preceding message has no ToolUse but we have a ToolResult —
// this is already an orphaned pair; walk back to try to fix it.
k = k.saturating_sub(1);
}
k
};
let removed = &session.messages[compacted_prefix_len..keep_from];
let preserved = session.messages[keep_from..].to_vec();
let summary =
@@ -170,14 +119,13 @@ pub fn compact_session(session: &Session, config: CompactionConfig) -> Compactio
}];
compacted_messages.extend(preserved);
let mut compacted_session = session.clone();
compacted_session.messages = compacted_messages;
compacted_session.record_compaction(summary.clone(), removed.len());
CompactionResult {
summary,
formatted_summary,
compacted_session,
compacted_session: Session {
version: session.version,
messages: compacted_messages,
},
removed_message_count: removed.len(),
}
}
@@ -554,7 +502,7 @@ fn extract_summary_timeline(summary: &str) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
collect_key_files, compact_session, format_compact_summary,
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
get_compact_continuation_message, infer_pending_work, should_compact, CompactionConfig,
};
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
@@ -567,8 +515,10 @@ mod tests {
#[test]
fn leaves_small_sessions_unchanged() {
let mut session = Session::new();
session.messages = vec![ConversationMessage::user_text("hello")];
let session = Session {
version: 1,
messages: vec![ConversationMessage::user_text("hello")],
};
let result = compact_session(&session, CompactionConfig::default());
assert_eq!(result.removed_message_count, 0);
@@ -579,21 +529,23 @@ mod tests {
#[test]
fn compacts_older_messages_into_a_system_summary() {
let mut session = Session::new();
session.messages = vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
];
let session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("one ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "two ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage {
role: MessageRole::Assistant,
blocks: vec![ContentBlock::Text {
text: "recent".to_string(),
}],
usage: None,
},
],
};
let result = compact_session(
&session,
@@ -603,14 +555,7 @@ mod tests {
},
);
// With the tool-use/tool-result boundary fix, the compaction preserves
// one extra message to avoid an orphaned tool result at the boundary.
// messages[1] (assistant) must be kept along with messages[2] (tool result).
assert!(
result.removed_message_count <= 2,
"expected at most 2 removed, got {}",
result.removed_message_count
);
assert_eq!(result.removed_message_count, 2);
assert_eq!(
result.compacted_session.messages[0].role,
MessageRole::System
@@ -628,29 +573,28 @@ mod tests {
max_estimated_tokens: 1,
}
));
// Note: with the tool-use/tool-result boundary guard the compacted session
// may preserve one extra message at the boundary, so token reduction is
// not guaranteed for small sessions. The invariant that matters is that
// the removed_message_count is non-zero (something was compacted).
assert!(
result.removed_message_count > 0,
"compaction must remove at least one message"
estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
);
}
#[test]
fn keeps_previous_compacted_context_when_compacting_again() {
let mut initial_session = Session::new();
initial_session.messages = vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text("Also update rust/crates/runtime/src/conversation.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
];
let initial_session = Session {
version: 1,
messages: vec![
ConversationMessage::user_text("Investigate rust/crates/runtime/src/compact.rs"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "I will inspect the compact flow.".to_string(),
}]),
ConversationMessage::user_text(
"Also update rust/crates/runtime/src/conversation.rs",
),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Next: preserve prior summary context during auto compact.".to_string(),
}]),
],
};
let config = CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
@@ -665,9 +609,13 @@ mod tests {
}]),
]);
let mut second_session = Session::new();
second_session.messages = follow_up_messages;
let second = compact_session(&second_session, config);
let second = compact_session(
&Session {
version: 1,
messages: follow_up_messages,
},
config,
);
assert!(second
.formatted_summary
@@ -696,20 +644,22 @@ mod tests {
#[test]
fn ignores_existing_compacted_summary_when_deciding_to_recompact() {
let summary = "<summary>Conversation summary:\n- Scope: earlier work preserved.\n- Key timeline:\n - user: large preserved context\n</summary>";
let mut session = Session::new();
session.messages = vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
];
let session = Session {
version: 1,
messages: vec![
ConversationMessage {
role: MessageRole::System,
blocks: vec![ContentBlock::Text {
text: get_compact_continuation_message(summary, true, true),
}],
usage: None,
},
ConversationMessage::user_text("tiny"),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
],
};
assert!(!should_compact(
&session,
@@ -738,79 +688,6 @@ mod tests {
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
}
/// Regression: compaction must not split an assistant(ToolUse) /
/// user(ToolResult) pair at the boundary. An orphaned tool-result message
/// without the preceding assistant `tool_calls` causes a 400 on the
/// OpenAI-compat path (gaebal-gajae repro 2026-04-09).
#[test]
fn compaction_does_not_split_tool_use_tool_result_pair() {
use crate::session::{ContentBlock, Session};
let tool_id = "call_abc";
let mut session = Session::default();
// Turn 1: user prompt
session
.push_message(ConversationMessage::user_text("Search for files"))
.unwrap();
// Turn 2: assistant calls a tool
session
.push_message(ConversationMessage::assistant(vec![
ContentBlock::ToolUse {
id: tool_id.to_string(),
name: "search".to_string(),
input: "{\"q\":\"*.rs\"}".to_string(),
},
]))
.unwrap();
// Turn 3: tool result
session
.push_message(ConversationMessage::tool_result(
tool_id,
"search",
"found 5 files",
false,
))
.unwrap();
// Turn 4: assistant final response
session
.push_message(ConversationMessage::assistant(vec![ContentBlock::Text {
text: "Done.".to_string(),
}]))
.unwrap();
// Compact preserving only 1 recent message — without the fix this
// would cut the boundary so that the tool result (turn 3) is first,
// without its preceding assistant tool_calls (turn 2).
let config = CompactionConfig {
preserve_recent_messages: 1,
..CompactionConfig::default()
};
let result = compact_session(&session, config);
// After compaction, no two consecutive messages should have the pattern
// tool_result immediately following a non-assistant message (i.e. an
// orphaned tool result without a preceding assistant ToolUse).
let messages = &result.compacted_session.messages;
for i in 1..messages.len() {
let curr_is_tool_result = messages[i]
.blocks
.first()
.is_some_and(|b| matches!(b, ContentBlock::ToolResult { .. }));
if curr_is_tool_result {
let prev_has_tool_use = messages[i - 1]
.blocks
.iter()
.any(|b| matches!(b, ContentBlock::ToolUse { .. }));
assert!(
prev_has_tool_use,
"message[{}] is a ToolResult but message[{}] has no ToolUse: {:?}",
i,
i - 1,
&messages[i - 1].blocks
);
}
}
}
#[test]
fn infers_pending_work_from_recent_messages() {
let pending = infer_pending_work(&[

File diff suppressed because it is too large Load Diff

View File

@@ -1,901 +0,0 @@
use std::collections::BTreeMap;
use std::path::Path;
use crate::config::ConfigError;
use crate::json::JsonValue;
/// Diagnostic emitted when a config file contains a suspect field.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiagnostic {
pub path: String,
pub field: String,
pub line: Option<usize>,
pub kind: DiagnosticKind,
}
/// Classification of the diagnostic.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiagnosticKind {
UnknownKey {
suggestion: Option<String>,
},
WrongType {
expected: &'static str,
got: &'static str,
},
Deprecated {
replacement: &'static str,
},
}
impl std::fmt::Display for ConfigDiagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let location = self
.line
.map_or_else(String::new, |line| format!(" (line {line})"));
match &self.kind {
DiagnosticKind::UnknownKey { suggestion: None } => {
write!(f, "{}: unknown key \"{}\"{location}", self.path, self.field)
}
DiagnosticKind::UnknownKey {
suggestion: Some(hint),
} => {
write!(
f,
"{}: unknown key \"{}\"{location}. Did you mean \"{}\"?",
self.path, self.field, hint
)
}
DiagnosticKind::WrongType { expected, got } => {
write!(
f,
"{}: field \"{}\" must be {expected}, got {got}{location}",
self.path, self.field
)
}
DiagnosticKind::Deprecated { replacement } => {
write!(
f,
"{}: field \"{}\" is deprecated{location}. Use \"{replacement}\" instead",
self.path, self.field
)
}
}
}
}
/// Result of validating a single config file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationResult {
pub errors: Vec<ConfigDiagnostic>,
pub warnings: Vec<ConfigDiagnostic>,
}
impl ValidationResult {
#[must_use]
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
fn merge(&mut self, other: Self) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
}
}
// ---- known-key schema ----
/// Expected type for a config field.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FieldType {
String,
Bool,
Object,
StringArray,
Number,
}
impl FieldType {
fn label(self) -> &'static str {
match self {
Self::String => "a string",
Self::Bool => "a boolean",
Self::Object => "an object",
Self::StringArray => "an array of strings",
Self::Number => "a number",
}
}
fn matches(self, value: &JsonValue) -> bool {
match self {
Self::String => value.as_str().is_some(),
Self::Bool => value.as_bool().is_some(),
Self::Object => value.as_object().is_some(),
Self::StringArray => value
.as_array()
.is_some_and(|arr| arr.iter().all(|v| v.as_str().is_some())),
Self::Number => value.as_i64().is_some(),
}
}
}
fn json_type_label(value: &JsonValue) -> &'static str {
match value {
JsonValue::Null => "null",
JsonValue::Bool(_) => "a boolean",
JsonValue::Number(_) => "a number",
JsonValue::String(_) => "a string",
JsonValue::Array(_) => "an array",
JsonValue::Object(_) => "an object",
}
}
struct FieldSpec {
name: &'static str,
expected: FieldType,
}
struct DeprecatedField {
name: &'static str,
replacement: &'static str,
}
const TOP_LEVEL_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "$schema",
expected: FieldType::String,
},
FieldSpec {
name: "model",
expected: FieldType::String,
},
FieldSpec {
name: "hooks",
expected: FieldType::Object,
},
FieldSpec {
name: "permissions",
expected: FieldType::Object,
},
FieldSpec {
name: "permissionMode",
expected: FieldType::String,
},
FieldSpec {
name: "mcpServers",
expected: FieldType::Object,
},
FieldSpec {
name: "oauth",
expected: FieldType::Object,
},
FieldSpec {
name: "enabledPlugins",
expected: FieldType::Object,
},
FieldSpec {
name: "plugins",
expected: FieldType::Object,
},
FieldSpec {
name: "sandbox",
expected: FieldType::Object,
},
FieldSpec {
name: "env",
expected: FieldType::Object,
},
FieldSpec {
name: "aliases",
expected: FieldType::Object,
},
FieldSpec {
name: "providerFallbacks",
expected: FieldType::Object,
},
FieldSpec {
name: "trustedRoots",
expected: FieldType::StringArray,
},
];
const HOOKS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "PreToolUse",
expected: FieldType::StringArray,
},
FieldSpec {
name: "PostToolUse",
expected: FieldType::StringArray,
},
FieldSpec {
name: "PostToolUseFailure",
expected: FieldType::StringArray,
},
];
const PERMISSIONS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "defaultMode",
expected: FieldType::String,
},
FieldSpec {
name: "allow",
expected: FieldType::StringArray,
},
FieldSpec {
name: "deny",
expected: FieldType::StringArray,
},
FieldSpec {
name: "ask",
expected: FieldType::StringArray,
},
];
const PLUGINS_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "enabled",
expected: FieldType::Object,
},
FieldSpec {
name: "externalDirectories",
expected: FieldType::StringArray,
},
FieldSpec {
name: "installRoot",
expected: FieldType::String,
},
FieldSpec {
name: "registryPath",
expected: FieldType::String,
},
FieldSpec {
name: "bundledRoot",
expected: FieldType::String,
},
FieldSpec {
name: "maxOutputTokens",
expected: FieldType::Number,
},
];
const SANDBOX_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "enabled",
expected: FieldType::Bool,
},
FieldSpec {
name: "namespaceRestrictions",
expected: FieldType::Bool,
},
FieldSpec {
name: "networkIsolation",
expected: FieldType::Bool,
},
FieldSpec {
name: "filesystemMode",
expected: FieldType::String,
},
FieldSpec {
name: "allowedMounts",
expected: FieldType::StringArray,
},
];
const OAUTH_FIELDS: &[FieldSpec] = &[
FieldSpec {
name: "clientId",
expected: FieldType::String,
},
FieldSpec {
name: "authorizeUrl",
expected: FieldType::String,
},
FieldSpec {
name: "tokenUrl",
expected: FieldType::String,
},
FieldSpec {
name: "callbackPort",
expected: FieldType::Number,
},
FieldSpec {
name: "manualRedirectUrl",
expected: FieldType::String,
},
FieldSpec {
name: "scopes",
expected: FieldType::StringArray,
},
];
const DEPRECATED_FIELDS: &[DeprecatedField] = &[
DeprecatedField {
name: "permissionMode",
replacement: "permissions.defaultMode",
},
DeprecatedField {
name: "enabledPlugins",
replacement: "plugins.enabled",
},
];
// ---- line-number resolution ----
/// Find the 1-based line number where a JSON key first appears in the raw source.
fn find_key_line(source: &str, key: &str) -> Option<usize> {
// Search for `"key"` followed by optional whitespace and a colon.
let needle = format!("\"{key}\"");
let mut search_start = 0;
while let Some(offset) = source[search_start..].find(&needle) {
let absolute = search_start + offset;
let after = absolute + needle.len();
// Verify the next non-whitespace char is `:` to confirm this is a key, not a value.
if source[after..].chars().find(|ch| !ch.is_ascii_whitespace()) == Some(':') {
return Some(source[..absolute].chars().filter(|&ch| ch == '\n').count() + 1);
}
search_start = after;
}
None
}
// ---- core validation ----
fn validate_object_keys(
object: &BTreeMap<String, JsonValue>,
known_fields: &[FieldSpec],
prefix: &str,
source: &str,
path_display: &str,
) -> ValidationResult {
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
};
let known_names: Vec<&str> = known_fields.iter().map(|f| f.name).collect();
for (key, value) in object {
let field_path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
if let Some(spec) = known_fields.iter().find(|f| f.name == key) {
// Type check.
if !spec.expected.matches(value) {
result.errors.push(ConfigDiagnostic {
path: path_display.to_string(),
field: field_path,
line: find_key_line(source, key),
kind: DiagnosticKind::WrongType {
expected: spec.expected.label(),
got: json_type_label(value),
},
});
}
} else if DEPRECATED_FIELDS.iter().any(|d| d.name == key) {
// Deprecated key — handled separately, not an unknown-key error.
} else {
// Unknown key.
let suggestion = suggest_field(key, &known_names);
result.errors.push(ConfigDiagnostic {
path: path_display.to_string(),
field: field_path,
line: find_key_line(source, key),
kind: DiagnosticKind::UnknownKey { suggestion },
});
}
}
result
}
fn suggest_field(input: &str, candidates: &[&str]) -> Option<String> {
let input_lower = input.to_ascii_lowercase();
candidates
.iter()
.filter_map(|candidate| {
let distance = simple_edit_distance(&input_lower, &candidate.to_ascii_lowercase());
(distance <= 3).then_some((distance, *candidate))
})
.min_by_key(|(distance, _)| *distance)
.map(|(_, name)| name.to_string())
}
fn simple_edit_distance(left: &str, right: &str) -> usize {
if left.is_empty() {
return right.len();
}
if right.is_empty() {
return left.len();
}
let right_chars: Vec<char> = right.chars().collect();
let mut previous: Vec<usize> = (0..=right_chars.len()).collect();
let mut current = vec![0; right_chars.len() + 1];
for (left_index, left_char) in left.chars().enumerate() {
current[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let cost = usize::from(left_char != *right_char);
current[right_index + 1] = (previous[right_index + 1] + 1)
.min(current[right_index] + 1)
.min(previous[right_index] + cost);
}
previous.clone_from(&current);
}
previous[right_chars.len()]
}
/// Validate a parsed config file's keys and types against the known schema.
///
/// Returns diagnostics (errors and deprecation warnings) without blocking the load.
pub fn validate_config_file(
object: &BTreeMap<String, JsonValue>,
source: &str,
file_path: &Path,
) -> ValidationResult {
let path_display = file_path.display().to_string();
let mut result = validate_object_keys(object, TOP_LEVEL_FIELDS, "", source, &path_display);
// Check deprecated fields.
for deprecated in DEPRECATED_FIELDS {
if object.contains_key(deprecated.name) {
result.warnings.push(ConfigDiagnostic {
path: path_display.clone(),
field: deprecated.name.to_string(),
line: find_key_line(source, deprecated.name),
kind: DiagnosticKind::Deprecated {
replacement: deprecated.replacement,
},
});
}
}
// Validate known nested objects.
if let Some(hooks) = object.get("hooks").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
hooks,
HOOKS_FIELDS,
"hooks",
source,
&path_display,
));
}
if let Some(permissions) = object.get("permissions").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
permissions,
PERMISSIONS_FIELDS,
"permissions",
source,
&path_display,
));
}
if let Some(plugins) = object.get("plugins").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
plugins,
PLUGINS_FIELDS,
"plugins",
source,
&path_display,
));
}
if let Some(sandbox) = object.get("sandbox").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
sandbox,
SANDBOX_FIELDS,
"sandbox",
source,
&path_display,
));
}
if let Some(oauth) = object.get("oauth").and_then(JsonValue::as_object) {
result.merge(validate_object_keys(
oauth,
OAUTH_FIELDS,
"oauth",
source,
&path_display,
));
}
result
}
/// Check whether a file path uses an unsupported config format (e.g. TOML).
pub fn check_unsupported_format(file_path: &Path) -> Result<(), ConfigError> {
if let Some(ext) = file_path.extension().and_then(|e| e.to_str()) {
if ext.eq_ignore_ascii_case("toml") {
return Err(ConfigError::Parse(format!(
"{}: TOML config files are not supported. Use JSON (settings.json) instead",
file_path.display()
)));
}
}
Ok(())
}
/// Format all diagnostics into a human-readable report.
#[must_use]
pub fn format_diagnostics(result: &ValidationResult) -> String {
let mut lines = Vec::new();
for warning in &result.warnings {
lines.push(format!("warning: {warning}"));
}
for error in &result.errors {
lines.push(format!("error: {error}"));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_path() -> PathBuf {
PathBuf::from("/test/settings.json")
}
#[test]
fn detects_unknown_top_level_key() {
// given
let source = r#"{"model": "opus", "unknownField": true}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "unknownField");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::UnknownKey { .. }
));
}
#[test]
fn detects_wrong_type_for_model() {
// given
let source = r#"{"model": 123}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "model");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType {
expected: "a string",
got: "a number"
}
));
}
#[test]
fn detects_deprecated_permission_mode() {
// given
let source = r#"{"permissionMode": "plan"}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "permissionMode");
assert!(matches!(
result.warnings[0].kind,
DiagnosticKind::Deprecated {
replacement: "permissions.defaultMode"
}
));
}
#[test]
fn detects_deprecated_enabled_plugins() {
// given
let source = r#"{"enabledPlugins": {"tool-guard@builtin": true}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.warnings.len(), 1);
assert_eq!(result.warnings[0].field, "enabledPlugins");
assert!(matches!(
result.warnings[0].kind,
DiagnosticKind::Deprecated {
replacement: "plugins.enabled"
}
));
}
#[test]
fn reports_line_number_for_unknown_key() {
// given
let source = "{\n \"model\": \"opus\",\n \"badKey\": true\n}";
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].line, Some(3));
assert_eq!(result.errors[0].field, "badKey");
}
#[test]
fn reports_line_number_for_wrong_type() {
// given
let source = "{\n \"model\": 42\n}";
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].line, Some(2));
}
#[test]
fn validates_nested_hooks_keys() {
// given
let source = r#"{"hooks": {"PreToolUse": ["cmd"], "BadHook": ["x"]}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "hooks.BadHook");
}
#[test]
fn validates_nested_permissions_keys() {
// given
let source = r#"{"permissions": {"allow": ["Read"], "denyAll": true}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "permissions.denyAll");
}
#[test]
fn validates_nested_sandbox_keys() {
// given
let source = r#"{"sandbox": {"enabled": true, "containerMode": "strict"}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "sandbox.containerMode");
}
#[test]
fn validates_nested_plugins_keys() {
// given
let source = r#"{"plugins": {"installRoot": "/tmp", "autoUpdate": true}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "plugins.autoUpdate");
}
#[test]
fn validates_nested_oauth_keys() {
// given
let source = r#"{"oauth": {"clientId": "abc", "secret": "hidden"}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "oauth.secret");
}
#[test]
fn valid_config_produces_no_diagnostics() {
// given
let source = r#"{
"model": "opus",
"hooks": {"PreToolUse": ["guard"]},
"permissions": {"defaultMode": "plan", "allow": ["Read"]},
"mcpServers": {},
"sandbox": {"enabled": false}
}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert!(result.is_ok());
assert!(result.warnings.is_empty());
}
#[test]
fn suggests_close_field_name() {
// given
let source = r#"{"modle": "opus"}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
match &result.errors[0].kind {
DiagnosticKind::UnknownKey {
suggestion: Some(s),
} => assert_eq!(s, "model"),
other => panic!("expected suggestion, got {other:?}"),
}
}
#[test]
fn format_diagnostics_includes_all_entries() {
// given
let source = r#"{"permissionMode": "plan", "badKey": 1}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
let result = validate_config_file(object, source, &test_path());
// when
let output = format_diagnostics(&result);
// then
assert!(output.contains("warning:"));
assert!(output.contains("error:"));
assert!(output.contains("badKey"));
assert!(output.contains("permissionMode"));
}
#[test]
fn check_unsupported_format_rejects_toml() {
// given
let path = PathBuf::from("/home/.claw/settings.toml");
// when
let result = check_unsupported_format(&path);
// then
assert!(result.is_err());
let message = result.unwrap_err().to_string();
assert!(message.contains("TOML"));
assert!(message.contains("settings.toml"));
}
#[test]
fn check_unsupported_format_allows_json() {
// given
let path = PathBuf::from("/home/.claw/settings.json");
// when / then
assert!(check_unsupported_format(&path).is_ok());
}
#[test]
fn wrong_type_in_nested_sandbox_field() {
// given
let source = r#"{"sandbox": {"enabled": "yes"}}"#;
let parsed = JsonValue::parse(source).expect("valid json");
let object = parsed.as_object().expect("object");
// when
let result = validate_config_file(object, source, &test_path());
// then
assert_eq!(result.errors.len(), 1);
assert_eq!(result.errors[0].field, "sandbox.enabled");
assert!(matches!(
result.errors[0].kind,
DiagnosticKind::WrongType {
expected: "a boolean",
got: "a string"
}
));
}
#[test]
fn display_format_unknown_key_with_line() {
// given
let diag = ConfigDiagnostic {
path: "/test/settings.json".to_string(),
field: "badKey".to_string(),
line: Some(5),
kind: DiagnosticKind::UnknownKey { suggestion: None },
};
// when
let output = diag.to_string();
// then
assert_eq!(
output,
r#"/test/settings.json: unknown key "badKey" (line 5)"#
);
}
#[test]
fn display_format_wrong_type_with_line() {
// given
let diag = ConfigDiagnostic {
path: "/test/settings.json".to_string(),
field: "model".to_string(),
line: Some(2),
kind: DiagnosticKind::WrongType {
expected: "a string",
got: "a number",
},
};
// when
let output = diag.to_string();
// then
assert_eq!(
output,
r#"/test/settings.json: field "model" must be a string, got a number (line 2)"#
);
}
#[test]
fn display_format_deprecated_with_line() {
// given
let diag = ConfigDiagnostic {
path: "/test/settings.json".to_string(),
field: "permissionMode".to_string(),
line: Some(3),
kind: DiagnosticKind::Deprecated {
replacement: "permissions.defaultMode",
},
};
// when
let output = diag.to_string();
// then
assert_eq!(
output,
r#"/test/settings.json: field "permissionMode" is deprecated (line 3). Use "permissions.defaultMode" instead"#
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,41 +9,6 @@ use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
/// Maximum file size that can be read (10 MB).
const MAX_READ_SIZE: u64 = 10 * 1024 * 1024;
/// Maximum file size that can be written (10 MB).
const MAX_WRITE_SIZE: usize = 10 * 1024 * 1024;
/// Check whether a file appears to contain binary content by examining
/// the first chunk for NUL bytes.
fn is_binary_file(path: &Path) -> io::Result<bool> {
use std::io::Read;
let mut file = fs::File::open(path)?;
let mut buffer = [0u8; 8192];
let bytes_read = file.read(&mut buffer)?;
Ok(buffer[..bytes_read].contains(&0))
}
/// Validate that a resolved path stays within the given workspace root.
/// Returns the canonical path on success, or an error if the path escapes
/// the workspace boundary (e.g. via `../` traversal or symlink).
#[allow(dead_code)]
fn validate_workspace_boundary(resolved: &Path, workspace_root: &Path) -> io::Result<()> {
if !resolved.starts_with(workspace_root) {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"path {} escapes workspace boundary {}",
resolved.display(),
workspace_root.display()
),
));
}
Ok(())
}
/// Text payload returned by file-reading operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TextFilePayload {
#[serde(rename = "filePath")]
@@ -57,7 +22,6 @@ pub struct TextFilePayload {
pub total_lines: usize,
}
/// Output envelope for the `read_file` tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ReadFileOutput {
#[serde(rename = "type")]
@@ -65,7 +29,6 @@ pub struct ReadFileOutput {
pub file: TextFilePayload,
}
/// Structured patch hunk emitted by write and edit operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StructuredPatchHunk {
#[serde(rename = "oldStart")]
@@ -79,7 +42,6 @@ pub struct StructuredPatchHunk {
pub lines: Vec<String>,
}
/// Output envelope for full-file write operations.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WriteFileOutput {
#[serde(rename = "type")]
@@ -95,7 +57,6 @@ pub struct WriteFileOutput {
pub git_diff: Option<serde_json::Value>,
}
/// Output envelope for targeted string-replacement edits.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EditFileOutput {
#[serde(rename = "filePath")]
@@ -116,7 +77,6 @@ pub struct EditFileOutput {
pub git_diff: Option<serde_json::Value>,
}
/// Result of a glob-based filename search.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobSearchOutput {
#[serde(rename = "durationMs")]
@@ -127,7 +87,6 @@ pub struct GlobSearchOutput {
pub truncated: bool,
}
/// Parameters accepted by the grep-style search tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrepSearchInput {
pub pattern: String,
@@ -153,7 +112,6 @@ pub struct GrepSearchInput {
pub multiline: Option<bool>,
}
/// Result payload returned by the grep-style search tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GrepSearchOutput {
pub mode: Option<String>,
@@ -171,35 +129,12 @@ pub struct GrepSearchOutput {
pub applied_offset: Option<usize>,
}
/// Reads a text file and returns a line-windowed payload.
pub fn read_file(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
// Check file size before reading
let metadata = fs::metadata(&absolute_path)?;
if metadata.len() > MAX_READ_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"file is too large ({} bytes, max {} bytes)",
metadata.len(),
MAX_READ_SIZE
),
));
}
// Detect binary files
if is_binary_file(&absolute_path)? {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"file appears to be binary",
));
}
let content = fs::read_to_string(&absolute_path)?;
let lines: Vec<&str> = content.lines().collect();
let start_index = offset.unwrap_or(0).min(lines.len());
@@ -220,19 +155,7 @@ pub fn read_file(
})
}
/// Replaces a file's contents and returns patch metadata.
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
if content.len() > MAX_WRITE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"content is too large ({} bytes, max {} bytes)",
content.len(),
MAX_WRITE_SIZE
),
));
}
let absolute_path = normalize_path_allow_missing(path)?;
let original_file = fs::read_to_string(&absolute_path).ok();
if let Some(parent) = absolute_path.parent() {
@@ -254,7 +177,6 @@ pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
})
}
/// Performs an in-file string replacement and returns patch metadata.
pub fn edit_file(
path: &str,
old_string: &str,
@@ -295,7 +217,6 @@ pub fn edit_file(
})
}
/// Expands a glob pattern and returns matching filenames.
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
let started = Instant::now();
let base_dir = path
@@ -308,20 +229,12 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
base_dir.join(pattern).to_string_lossy().into_owned()
};
// The `glob` crate does not support brace expansion ({a,b,c}).
// Expand braces into multiple patterns so patterns like
// `Assets/**/*.{cs,uxml,uss}` work correctly.
let expanded = expand_braces(&search_pattern);
let mut seen = std::collections::HashSet::new();
let mut matches = Vec::new();
for pat in &expanded {
let entries = glob::glob(pat)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
for entry in entries.flatten() {
if entry.is_file() && seen.insert(entry.clone()) {
matches.push(entry);
}
let entries = glob::glob(&search_pattern)
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
for entry in entries.flatten() {
if entry.is_file() {
matches.push(entry);
}
}
@@ -347,7 +260,6 @@ pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOu
})
}
/// Runs a regex search over workspace files with optional context lines.
pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
let base_path = input
.path
@@ -565,98 +477,11 @@ fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
Ok(candidate)
}
/// Read a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn read_file_in_workspace(
path: &str,
offset: Option<usize>,
limit: Option<usize>,
workspace_root: &Path,
) -> io::Result<ReadFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
read_file(path, offset, limit)
}
/// Write a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn write_file_in_workspace(
path: &str,
content: &str,
workspace_root: &Path,
) -> io::Result<WriteFileOutput> {
let absolute_path = normalize_path_allow_missing(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
write_file(path, content)
}
/// Edit a file with workspace boundary enforcement.
#[allow(dead_code)]
pub fn edit_file_in_workspace(
path: &str,
old_string: &str,
new_string: &str,
replace_all: bool,
workspace_root: &Path,
) -> io::Result<EditFileOutput> {
let absolute_path = normalize_path(path)?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
validate_workspace_boundary(&absolute_path, &canonical_root)?;
edit_file(path, old_string, new_string, replace_all)
}
/// Check whether a path is a symlink that resolves outside the workspace.
#[allow(dead_code)]
pub fn is_symlink_escape(path: &Path, workspace_root: &Path) -> io::Result<bool> {
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_symlink() {
return Ok(false);
}
let resolved = path.canonicalize()?;
let canonical_root = workspace_root
.canonicalize()
.unwrap_or_else(|_| workspace_root.to_path_buf());
Ok(!resolved.starts_with(&canonical_root))
}
/// Expand shell-style brace groups in a glob pattern.
///
/// Handles one level of braces: `foo.{a,b,c}` → `["foo.a", "foo.b", "foo.c"]`.
/// Nested braces are not expanded (uncommon in practice).
/// Patterns without braces pass through unchanged.
fn expand_braces(pattern: &str) -> Vec<String> {
let Some(open) = pattern.find('{') else {
return vec![pattern.to_owned()];
};
let Some(close) = pattern[open..].find('}').map(|i| open + i) else {
// Unmatched brace — treat as literal.
return vec![pattern.to_owned()];
};
let prefix = &pattern[..open];
let suffix = &pattern[close + 1..];
let alternatives = &pattern[open + 1..close];
alternatives
.split(',')
.flat_map(|alt| expand_braces(&format!("{prefix}{alt}{suffix}")))
.collect()
}
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use super::{
edit_file, expand_braces, glob_search, grep_search, is_symlink_escape, read_file,
read_file_in_workspace, write_file, GrepSearchInput, MAX_WRITE_SIZE,
};
use super::{edit_file, glob_search, grep_search, read_file, write_file, GrepSearchInput};
fn temp_path(name: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
@@ -688,73 +513,6 @@ mod tests {
assert!(output.replace_all);
}
#[test]
fn rejects_binary_files() {
let path = temp_path("binary-test.bin");
std::fs::write(&path, b"\x00\x01\x02\x03binary content").expect("write should succeed");
let result = read_file(path.to_string_lossy().as_ref(), None, None);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("binary"));
}
#[test]
fn rejects_oversized_writes() {
let path = temp_path("oversize-write.txt");
let huge = "x".repeat(MAX_WRITE_SIZE + 1);
let result = write_file(path.to_string_lossy().as_ref(), &huge);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("too large"));
}
#[test]
fn enforces_workspace_boundary() {
let workspace = temp_path("workspace-boundary");
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
let inside = workspace.join("inside.txt");
write_file(inside.to_string_lossy().as_ref(), "safe content")
.expect("write inside workspace should succeed");
// Reading inside workspace should succeed
let result =
read_file_in_workspace(inside.to_string_lossy().as_ref(), None, None, &workspace);
assert!(result.is_ok());
// Reading outside workspace should fail
let outside = temp_path("outside-boundary.txt");
write_file(outside.to_string_lossy().as_ref(), "unsafe content")
.expect("write outside should succeed");
let result =
read_file_in_workspace(outside.to_string_lossy().as_ref(), None, None, &workspace);
assert!(result.is_err());
let error = result.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
assert!(error.to_string().contains("escapes workspace"));
}
#[test]
fn detects_symlink_escape() {
let workspace = temp_path("symlink-workspace");
std::fs::create_dir_all(&workspace).expect("workspace dir should be created");
let outside = temp_path("symlink-target.txt");
std::fs::write(&outside, "target content").expect("target should write");
let link_path = workspace.join("escape-link.txt");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, &link_path).expect("symlink should create");
assert!(is_symlink_escape(&link_path, &workspace).expect("check should succeed"));
}
// Non-symlink file should not be an escape
let normal = workspace.join("normal.txt");
std::fs::write(&normal, "normal content").expect("normal file should write");
assert!(!is_symlink_escape(&normal, &workspace).expect("check should succeed"));
}
#[test]
fn globs_and_greps_directory() {
let dir = temp_path("search-dir");
@@ -789,51 +547,4 @@ mod tests {
.expect("grep should succeed");
assert!(grep_output.content.unwrap_or_default().contains("hello"));
}
#[test]
fn expand_braces_no_braces() {
assert_eq!(expand_braces("*.rs"), vec!["*.rs"]);
}
#[test]
fn expand_braces_single_group() {
let mut result = expand_braces("Assets/**/*.{cs,uxml,uss}");
result.sort();
assert_eq!(
result,
vec!["Assets/**/*.cs", "Assets/**/*.uss", "Assets/**/*.uxml",]
);
}
#[test]
fn expand_braces_nested() {
let mut result = expand_braces("src/{a,b}.{rs,toml}");
result.sort();
assert_eq!(
result,
vec!["src/a.rs", "src/a.toml", "src/b.rs", "src/b.toml"]
);
}
#[test]
fn expand_braces_unmatched() {
assert_eq!(expand_braces("foo.{bar"), vec!["foo.{bar"]);
}
#[test]
fn glob_search_with_braces_finds_files() {
let dir = temp_path("glob-braces");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.rs"), "fn main() {}").unwrap();
std::fs::write(dir.join("b.toml"), "[package]").unwrap();
std::fs::write(dir.join("c.txt"), "hello").unwrap();
let result =
glob_search("*.{rs,toml}", Some(dir.to_str().unwrap())).expect("glob should succeed");
assert_eq!(
result.num_files, 2,
"should match .rs and .toml but not .txt"
);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@@ -1,324 +0,0 @@
use std::path::Path;
use std::process::Command;
/// A single git commit entry from the log.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitCommitEntry {
pub hash: String,
pub subject: String,
}
/// Git-aware context gathered at startup for injection into the system prompt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitContext {
pub branch: Option<String>,
pub recent_commits: Vec<GitCommitEntry>,
pub staged_files: Vec<String>,
}
const MAX_RECENT_COMMITS: usize = 5;
impl GitContext {
/// Detect the git context from the given working directory.
///
/// Returns `None` when the directory is not inside a git repository.
#[must_use]
pub fn detect(cwd: &Path) -> Option<Self> {
// Quick gate: is this a git repo at all?
let rev_parse = Command::new("git")
.args(["rev-parse", "--is-inside-work-tree"])
.current_dir(cwd)
.output()
.ok()?;
if !rev_parse.status.success() {
return None;
}
Some(Self {
branch: read_branch(cwd),
recent_commits: read_recent_commits(cwd),
staged_files: read_staged_files(cwd),
})
}
/// Render a human-readable summary suitable for system-prompt injection.
#[must_use]
pub fn render(&self) -> String {
let mut lines = Vec::new();
if let Some(branch) = &self.branch {
lines.push(format!("Git branch: {branch}"));
}
if !self.recent_commits.is_empty() {
lines.push(String::new());
lines.push("Recent commits:".to_string());
for entry in &self.recent_commits {
lines.push(format!(" {} {}", entry.hash, entry.subject));
}
}
if !self.staged_files.is_empty() {
lines.push(String::new());
lines.push("Staged files:".to_string());
for file in &self.staged_files {
lines.push(format!(" {file}"));
}
}
lines.join("\n")
}
}
fn read_branch(cwd: &Path) -> Option<String> {
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let branch = String::from_utf8(output.stdout).ok()?;
let trimmed = branch.trim();
if trimmed.is_empty() || trimmed == "HEAD" {
None
} else {
Some(trimmed.to_string())
}
}
fn read_recent_commits(cwd: &Path) -> Vec<GitCommitEntry> {
let output = Command::new("git")
.args([
"--no-optional-locks",
"log",
"--oneline",
"-n",
&MAX_RECENT_COMMITS.to_string(),
"--no-decorate",
])
.current_dir(cwd)
.output()
.ok();
let Some(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8(output.stdout).unwrap_or_default();
stdout
.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() {
return None;
}
let (hash, subject) = line.split_once(' ')?;
Some(GitCommitEntry {
hash: hash.to_string(),
subject: subject.to_string(),
})
})
.collect()
}
fn read_staged_files(cwd: &Path) -> Vec<String> {
let output = Command::new("git")
.args(["--no-optional-locks", "diff", "--cached", "--name-only"])
.current_dir(cwd)
.output()
.ok();
let Some(output) = output else {
return Vec::new();
};
if !output.status.success() {
return Vec::new();
}
let stdout = String::from_utf8(output.stdout).unwrap_or_default();
stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::{GitCommitEntry, GitContext};
use std::fs;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("runtime-git-context-{label}-{nanos}"))
}
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_env_lock()
}
fn ensure_valid_cwd() {
if std::env::current_dir().is_err() {
std::env::set_current_dir(env!("CARGO_MANIFEST_DIR"))
.expect("test cwd should be recoverable");
}
}
#[test]
fn returns_none_for_non_git_directory() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("non-git");
fs::create_dir_all(&root).expect("create dir");
// when
let context = GitContext::detect(&root);
// then
assert!(context.is_none());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn detects_branch_name_and_commits() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("branch-commits");
fs::create_dir_all(&root).expect("create dir");
git(&root, &["init", "--quiet", "--initial-branch=main"]);
git(&root, &["config", "user.email", "tests@example.com"]);
git(&root, &["config", "user.name", "Git Context Tests"]);
fs::write(root.join("a.txt"), "a\n").expect("write a");
git(&root, &["add", "a.txt"]);
git(&root, &["commit", "-m", "first commit", "--quiet"]);
fs::write(root.join("b.txt"), "b\n").expect("write b");
git(&root, &["add", "b.txt"]);
git(&root, &["commit", "-m", "second commit", "--quiet"]);
// when
let context = GitContext::detect(&root).expect("should detect git repo");
// then
assert_eq!(context.branch.as_deref(), Some("main"));
assert_eq!(context.recent_commits.len(), 2);
assert_eq!(context.recent_commits[0].subject, "second commit");
assert_eq!(context.recent_commits[1].subject, "first commit");
assert!(context.staged_files.is_empty());
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn detects_staged_files() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("staged");
fs::create_dir_all(&root).expect("create dir");
git(&root, &["init", "--quiet", "--initial-branch=main"]);
git(&root, &["config", "user.email", "tests@example.com"]);
git(&root, &["config", "user.name", "Git Context Tests"]);
fs::write(root.join("init.txt"), "init\n").expect("write init");
git(&root, &["add", "init.txt"]);
git(&root, &["commit", "-m", "initial", "--quiet"]);
fs::write(root.join("staged.txt"), "staged\n").expect("write staged");
git(&root, &["add", "staged.txt"]);
// when
let context = GitContext::detect(&root).expect("should detect git repo");
// then
assert_eq!(context.staged_files, vec!["staged.txt"]);
fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn render_formats_all_sections() {
// given
let context = GitContext {
branch: Some("feat/test".to_string()),
recent_commits: vec![
GitCommitEntry {
hash: "abc1234".to_string(),
subject: "add feature".to_string(),
},
GitCommitEntry {
hash: "def5678".to_string(),
subject: "fix bug".to_string(),
},
],
staged_files: vec!["src/main.rs".to_string()],
};
// when
let rendered = context.render();
// then
assert!(rendered.contains("Git branch: feat/test"));
assert!(rendered.contains("abc1234 add feature"));
assert!(rendered.contains("def5678 fix bug"));
assert!(rendered.contains("src/main.rs"));
}
#[test]
fn render_omits_empty_sections() {
// given
let context = GitContext {
branch: Some("main".to_string()),
recent_commits: Vec::new(),
staged_files: Vec::new(),
};
// when
let rendered = context.render();
// then
assert!(rendered.contains("Git branch: main"));
assert!(!rendered.contains("Recent commits:"));
assert!(!rendered.contains("Staged files:"));
}
#[test]
fn limits_to_five_recent_commits() {
// given
let _guard = env_lock();
ensure_valid_cwd();
let root = temp_dir("five-commits");
fs::create_dir_all(&root).expect("create dir");
git(&root, &["init", "--quiet", "--initial-branch=main"]);
git(&root, &["config", "user.email", "tests@example.com"]);
git(&root, &["config", "user.name", "Git Context Tests"]);
for i in 1..=8 {
let name = format!("file{i}.txt");
fs::write(root.join(&name), format!("{i}\n")).expect("write file");
git(&root, &["add", &name]);
git(&root, &["commit", "-m", &format!("commit {i}"), "--quiet"]);
}
// when
let context = GitContext::detect(&root).expect("should detect git repo");
// then
assert_eq!(context.recent_commits.len(), 5);
assert_eq!(context.recent_commits[0].subject, "commit 8");
assert_eq!(context.recent_commits[4].subject, "commit 4");
fs::remove_dir_all(root).expect("cleanup");
}
fn git(cwd: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap_or_else(|_| panic!("git {args:?} should run"))
.status;
assert!(status.success(), "git {args:?} failed");
}
}

View File

@@ -1,152 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GreenLevel {
TargetedTests,
Package,
Workspace,
MergeReady,
}
impl GreenLevel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::TargetedTests => "targeted_tests",
Self::Package => "package",
Self::Workspace => "workspace",
Self::MergeReady => "merge_ready",
}
}
}
impl std::fmt::Display for GreenLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct GreenContract {
pub required_level: GreenLevel,
}
impl GreenContract {
#[must_use]
pub fn new(required_level: GreenLevel) -> Self {
Self { required_level }
}
#[must_use]
pub fn evaluate(self, observed_level: Option<GreenLevel>) -> GreenContractOutcome {
match observed_level {
Some(level) if level >= self.required_level => GreenContractOutcome::Satisfied {
required_level: self.required_level,
observed_level: level,
},
_ => GreenContractOutcome::Unsatisfied {
required_level: self.required_level,
observed_level,
},
}
}
#[must_use]
pub fn is_satisfied_by(self, observed_level: GreenLevel) -> bool {
observed_level >= self.required_level
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum GreenContractOutcome {
Satisfied {
required_level: GreenLevel,
observed_level: GreenLevel,
},
Unsatisfied {
required_level: GreenLevel,
observed_level: Option<GreenLevel>,
},
}
impl GreenContractOutcome {
#[must_use]
pub fn is_satisfied(&self) -> bool {
matches!(self, Self::Satisfied { .. })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn given_matching_level_when_evaluating_contract_then_it_is_satisfied() {
// given
let contract = GreenContract::new(GreenLevel::Package);
// when
let outcome = contract.evaluate(Some(GreenLevel::Package));
// then
assert_eq!(
outcome,
GreenContractOutcome::Satisfied {
required_level: GreenLevel::Package,
observed_level: GreenLevel::Package,
}
);
assert!(outcome.is_satisfied());
}
#[test]
fn given_higher_level_when_checking_requirement_then_it_still_satisfies_contract() {
// given
let contract = GreenContract::new(GreenLevel::TargetedTests);
// when
let is_satisfied = contract.is_satisfied_by(GreenLevel::Workspace);
// then
assert!(is_satisfied);
}
#[test]
fn given_lower_level_when_evaluating_contract_then_it_is_unsatisfied() {
// given
let contract = GreenContract::new(GreenLevel::Workspace);
// when
let outcome = contract.evaluate(Some(GreenLevel::Package));
// then
assert_eq!(
outcome,
GreenContractOutcome::Unsatisfied {
required_level: GreenLevel::Workspace,
observed_level: Some(GreenLevel::Package),
}
);
assert!(!outcome.is_satisfied());
}
#[test]
fn given_no_green_level_when_evaluating_contract_then_contract_is_unsatisfied() {
// given
let contract = GreenContract::new(GreenLevel::MergeReady);
// when
let outcome = contract.evaluate(None);
// then
assert_eq!(
outcome,
GreenContractOutcome::Unsatisfied {
required_level: GreenLevel::MergeReady,
observed_level: None,
}
);
}
}

View File

@@ -1,6 +1,6 @@
use std::ffi::OsStr;
use std::fmt::Write as FmtWrite;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::{
atomic::{AtomicBool, Ordering},
@@ -14,8 +14,6 @@ use serde_json::{json, Value};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::PermissionOverride;
const HOOK_PREVIEW_CHAR_LIMIT: usize = 160;
pub type HookPermissionDecision = PermissionOverride;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -83,7 +81,6 @@ impl HookAbortSignal {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
failed: bool,
cancelled: bool,
messages: Vec<String>,
permission_override: Option<PermissionOverride>,
@@ -96,7 +93,6 @@ impl HookRunResult {
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
failed: false,
cancelled: false,
messages,
permission_override: None,
@@ -110,11 +106,6 @@ impl HookRunResult {
self.denied
}
#[must_use]
pub fn is_failed(&self) -> bool {
self.failed
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.cancelled
@@ -327,7 +318,6 @@ impl HookRunner {
if abort_signal.is_some_and(HookAbortSignal::is_aborted) {
return HookRunResult {
denied: false,
failed: false,
cancelled: true,
messages: vec![format!(
"{} hook cancelled before execution",
@@ -383,7 +373,7 @@ impl HookRunner {
result.denied = true;
return result;
}
HookCommandOutcome::Failed { parsed } => {
HookCommandOutcome::Warn { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
reporter.on_event(&HookProgressEvent::Completed {
event,
@@ -391,9 +381,7 @@ impl HookRunner {
command: command.clone(),
});
}
merge_parsed_hook_output(&mut result, parsed);
result.failed = true;
return result;
result.messages.push(message);
}
HookCommandOutcome::Cancelled { message } => {
if let Some(reporter) = reporter.as_deref_mut() {
@@ -440,8 +428,7 @@ impl HookRunner {
Ok(CommandExecution::Finished(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let parsed = parse_hook_output(event, tool_name, command, &stdout, &stderr);
let primary_message = parsed.primary_message().map(ToOwned::to_owned);
let parsed = parse_hook_output(&stdout);
match output.status.code() {
Some(0) => {
if parsed.deny {
@@ -456,20 +443,19 @@ impl HookRunner {
event.as_str()
)),
},
Some(code) => HookCommandOutcome::Failed {
parsed: parsed.with_fallback_message(format_hook_failure(
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
primary_message.as_deref(),
parsed.primary_message(),
stderr.as_str(),
)),
),
},
None => HookCommandOutcome::Failed {
parsed: parsed.with_fallback_message(format!(
"{} hook `{command}` terminated by signal while handling `{}`",
event.as_str(),
tool_name
)),
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{tool_name}`",
event.as_str()
),
},
}
}
@@ -479,15 +465,11 @@ impl HookRunner {
event.as_str()
),
},
Err(error) => HookCommandOutcome::Failed {
parsed: ParsedHookOutput {
messages: vec![format!(
"{} hook `{command}` failed to start for `{}`: {error}",
event.as_str(),
tool_name
)],
..ParsedHookOutput::default()
},
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
event.as_str()
),
},
}
}
@@ -496,7 +478,7 @@ impl HookRunner {
enum HookCommandOutcome {
Allow { parsed: ParsedHookOutput },
Deny { parsed: ParsedHookOutput },
Failed { parsed: ParsedHookOutput },
Warn { message: String },
Cancelled { message: String },
}
@@ -535,54 +517,16 @@ fn merge_parsed_hook_output(target: &mut HookRunResult, parsed: ParsedHookOutput
}
}
fn parse_hook_output(
event: HookEvent,
tool_name: &str,
command: &str,
stdout: &str,
stderr: &str,
) -> ParsedHookOutput {
fn parse_hook_output(stdout: &str) -> ParsedHookOutput {
if stdout.is_empty() {
return ParsedHookOutput::default();
}
let root = match serde_json::from_str::<Value>(stdout) {
Ok(Value::Object(root)) => root,
Ok(value) => {
return ParsedHookOutput {
messages: vec![format_invalid_hook_output(
event,
tool_name,
command,
&format!(
"expected top-level JSON object, got {}",
json_type_name(&value)
),
stdout,
stderr,
)],
..ParsedHookOutput::default()
};
}
Err(error) if looks_like_json_attempt(stdout) => {
return ParsedHookOutput {
messages: vec![format_invalid_hook_output(
event,
tool_name,
command,
&error.to_string(),
stdout,
stderr,
)],
..ParsedHookOutput::default()
};
}
Err(_) => {
return ParsedHookOutput {
messages: vec![stdout.to_string()],
..ParsedHookOutput::default()
};
}
let Ok(Value::Object(root)) = serde_json::from_str::<Value>(stdout) else {
return ParsedHookOutput {
messages: vec![stdout.to_string()],
..ParsedHookOutput::default()
};
};
let mut parsed = ParsedHookOutput::default();
@@ -660,71 +604,9 @@ fn parse_tool_input(tool_input: &str) -> Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
fn format_invalid_hook_output(
event: HookEvent,
tool_name: &str,
command: &str,
detail: &str,
stdout: &str,
stderr: &str,
) -> String {
let stdout_preview = bounded_hook_preview(stdout).unwrap_or_else(|| "<empty>".to_string());
let stderr_preview = bounded_hook_preview(stderr).unwrap_or_else(|| "<empty>".to_string());
let command_preview = bounded_hook_preview(command).unwrap_or_else(|| "<empty>".to_string());
format!(
"hook_invalid_json: phase={} tool={} command={} detail={} stdout_preview={} stderr_preview={}",
event.as_str(),
tool_name,
command_preview,
detail,
stdout_preview,
stderr_preview
)
}
fn bounded_hook_preview(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
let mut preview = String::new();
for (count, ch) in trimmed.chars().enumerate() {
if count == HOOK_PREVIEW_CHAR_LIMIT {
preview.push('…');
break;
}
match ch {
'\n' => preview.push_str("\\n"),
'\r' => preview.push_str("\\r"),
'\t' => preview.push_str("\\t"),
control if control.is_control() => {
let _ = write!(&mut preview, "\\u{{{:x}}}", control as u32);
}
_ => preview.push(ch),
}
}
Some(preview)
}
fn json_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
fn looks_like_json_attempt(value: &str) -> bool {
matches!(value.trim_start().chars().next(), Some('{' | '['))
}
fn format_hook_failure(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message = format!("Hook `{command}` exited with status {code}");
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
@@ -744,7 +626,11 @@ fn shell_command(command: &str) -> CommandWithStdin {
};
#[cfg(not(windows))]
let command_builder = {
let command_builder = if Path::new(command).exists() {
let mut command_builder = Command::new("sh");
command_builder.arg(command);
CommandWithStdin::new(command_builder)
} else {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
@@ -866,7 +752,7 @@ mod tests {
}
#[test]
fn propagates_other_non_zero_statuses_as_failures() {
fn warns_for_other_non_zero_statuses() {
let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
RuntimeHookConfig::new(
vec![shell_snippet("printf 'warning hook'; exit 1")],
@@ -875,16 +761,13 @@ mod tests {
),
));
// given
// when
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
// then
assert!(result.is_failed());
assert!(!result.is_denied());
assert!(result
.messages()
.iter()
.any(|message| message.contains("warning hook")));
.any(|message| message.contains("allowing tool execution to continue")));
}
#[test]
@@ -910,160 +793,19 @@ mod tests {
#[test]
fn runs_post_tool_use_failure_hooks() {
// given
let runner = HookRunner::new(RuntimeHookConfig::new(
Vec::new(),
Vec::new(),
vec![shell_snippet("printf 'failure hook ran'")],
));
// when
let result =
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
// then
assert!(!result.is_denied());
assert_eq!(result.messages(), &["failure hook ran".to_string()]);
}
#[test]
fn stops_running_failure_hooks_after_failure() {
// given
let runner = HookRunner::new(RuntimeHookConfig::new(
Vec::new(),
Vec::new(),
vec![
shell_snippet("printf 'broken failure hook'; exit 1"),
shell_snippet("printf 'later failure hook'"),
],
));
// when
let result =
runner.run_post_tool_use_failure("bash", r#"{"command":"false"}"#, "command failed");
// then
assert!(result.is_failed());
assert!(result
.messages()
.iter()
.any(|message| message.contains("broken failure hook")));
assert!(!result
.messages()
.iter()
.any(|message| message == "later failure hook"));
}
#[test]
fn executes_hooks_in_configured_order() {
// given
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![
shell_snippet("printf 'first'"),
shell_snippet("printf 'second'"),
],
Vec::new(),
Vec::new(),
));
let mut reporter = RecordingReporter { events: Vec::new() };
// when
let result = runner.run_pre_tool_use_with_context(
"Read",
r#"{"path":"README.md"}"#,
None,
Some(&mut reporter),
);
// then
assert_eq!(
result,
HookRunResult::allow(vec!["first".to_string(), "second".to_string()])
);
assert_eq!(reporter.events.len(), 4);
assert!(matches!(
&reporter.events[0],
HookProgressEvent::Started {
event: HookEvent::PreToolUse,
command,
..
} if command == "printf 'first'"
));
assert!(matches!(
&reporter.events[1],
HookProgressEvent::Completed {
event: HookEvent::PreToolUse,
command,
..
} if command == "printf 'first'"
));
assert!(matches!(
&reporter.events[2],
HookProgressEvent::Started {
event: HookEvent::PreToolUse,
command,
..
} if command == "printf 'second'"
));
assert!(matches!(
&reporter.events[3],
HookProgressEvent::Completed {
event: HookEvent::PreToolUse,
command,
..
} if command == "printf 'second'"
));
}
#[test]
fn stops_running_hooks_after_failure() {
// given
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![
shell_snippet("printf 'broken'; exit 1"),
shell_snippet("printf 'later'"),
],
Vec::new(),
Vec::new(),
));
// when
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
// then
assert!(result.is_failed());
assert!(result
.messages()
.iter()
.any(|message| message.contains("broken")));
assert!(!result.messages().iter().any(|message| message == "later"));
}
#[test]
fn malformed_nonempty_hook_output_reports_explicit_diagnostic_with_previews() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet(
"printf '{not-json\nsecond line'; printf 'stderr warning' >&2; exit 1",
)],
Vec::new(),
Vec::new(),
));
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
assert!(result.is_failed());
let rendered = result.messages().join("\n");
assert!(rendered.contains("hook_invalid_json:"));
assert!(rendered.contains("phase=PreToolUse"));
assert!(rendered.contains("tool=Edit"));
assert!(rendered.contains("command=printf '{not-json"));
assert!(rendered.contains("printf 'stderr warning' >&2; exit 1"));
assert!(rendered.contains("detail=key must be a string"));
assert!(rendered.contains("stdout_preview={not-json"));
assert!(rendered.contains("second line stderr_preview=stderr warning"));
assert!(rendered.contains("stderr_preview=stderr warning"));
}
#[test]
fn abort_signal_cancels_long_running_hook_and_reports_progress() {
let runner = HookRunner::new(RuntimeHookConfig::new(

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More