diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index bd34026..19d526e 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.7.3", + "version": "1.7.4", "description": "Claude Code Plugin for Codex. Run reviews, tracked tasks, and independent Codex-Claude design or research workflows.", "author": { "name": "CBEPX", diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e2997..b775fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## [Unreleased] +## v1.7.4 + +### Added + +- Freeze the audited annotationless MCP registry so only the reviewed public tool IDs are eligible (#26). + +### Changed + +- Classify structured Claude terminal failures at the boundary, keep trusted failures ahead of text heuristics, and retain actionable failure categories in rendered results (#32, #47, #48). +- Generate complete stdin-safe continuation and activation recipes and consume delayed or large piped input without `EAGAIN` (#41). +- Treat empty optional continuation feedback as no feedback (#42). +- Return bounded mutation receipts for peer workflow commands (#43). +- Preflight deterministic Claude attempts before dispatch to avoid junk jobs (#44). +- Document exact-ref qualification steps (#35). + +### Fixed + +- Isolate inherited Claude host markers in the regression environment (#33). +- Refresh dependency lock data to clear transitive audit findings (#40). + ## v1.7.3 ### Fixed diff --git a/README.md b/README.md index e7f1023..e952c12 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex Install the fork release from the CBEPX marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.3 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.4 codex plugin add cc@cbepx ``` @@ -61,8 +61,8 @@ The optional `npx` helper can install this fork release and enable the required ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.3 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.3/cc-plugin-codex-1.7.3.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.4 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.4/cc-plugin-codex-1.7.4.tgz install ``` On Windows, prefer the marketplace path or the `npx` helper. The shell-script helper below is POSIX-only. @@ -152,7 +152,7 @@ In foreground, review returns the result directly. In background, the plugin use If the diff is too large to inline safely, the review prompt falls back to concise status/stat context and tells Claude to inspect the diff directly with read-only `git diff` commands instead of failing the run. -By default, review runs with only the bundled read-only git MCP. Repeat `--user-mcp-tool ` to opt in specific Claude MCP tools from your user-scope Claude config for a run. Opted-in user MCP tools run as external Claude MCP processes and are auto-approved for that review, so use only trusted tools when reviewing untrusted diffs. Eligibility is based on the server's `readOnlyHint` declaration or the plugin's audited read-only registry; it is not an OS-enforced sandbox. A `destructiveHint` declaration is always vetoed. Project `.mcp.json` server definitions are ignored unless you also pass `--allow-project-mcp-servers`. +By default, review runs with only the bundled read-only git MCP. Repeat `--user-mcp-tool ` to opt in specific Claude MCP tools from your user-scope Claude config for a run. Opted-in user MCP tools run as external Claude MCP processes and are auto-approved for that review, so use only trusted tools when reviewing untrusted diffs. Eligibility is based on the server's `readOnlyHint` declaration or the immutable four-ID audited annotationless registry: Context7 `mcp__context7__query-docs` and `mcp__context7__resolve-library-id`, plus Brave `mcp__brave-search__brave_web_search` and `mcp__brave-search__brave_llm_context`. It is not an OS-enforced sandbox. A `destructiveHint` declaration is always vetoed. Project `.mcp.json` server definitions are ignored unless you also pass `--allow-project-mcp-servers`. ### `$cc:mcp-diagnose` @@ -180,7 +180,7 @@ $cc:design --retry New workflows default to Claude `fable` with `opus` fallback and inherited Codex model at `xhigh` effort. Use `--model`, `--fallback-model`, `--effort`, `--codex-model`, or `--codex-effort` to override them. Repeat `--user-mcp-tool ` for explicitly trusted eligible tools; automatic selection is limited to the smallest relevant eligible set exposed to the active Codex turn. Eligibility records whether trust came from `readOnlyHint` or the audited registry, but does not independently enforce server behavior. Project MCP servers still require `--allow-project-mcp-servers`. -The audited annotationless Brave allowlist contains exactly `mcp__brave-search__brave_web_search` and `mcp__brave-search__brave_llm_context`; other Brave IDs are not eligible through it. A Brave event counts as Claude web evidence only when that exact ID is also in the workflow's frozen selected-tool manifest. The manifest records the selected ID and trust basis, not the provider's behavior: an `@latest` Brave server can still change a tool behind the same ID. Pin a server version when that drift is unacceptable. +Only the two Brave IDs in that four-ID registry — `mcp__brave-search__brave_web_search` and `mcp__brave-search__brave_llm_context` — are Brave web-evidence tools; other Brave IDs are not eligible through the registry. A Brave event counts as Claude web evidence only when that exact ID is also in the workflow's frozen selected-tool manifest. The manifest records the selected ID and trust basis, not the provider's behavior: an `@latest` Brave server can still change a tool behind the same ID. Pin a server version when that drift is unacceptable. Selected Brave MCP servers are external trusted processes/services, not an OS sandbox. Their query or context input can disclose the brief and research terms to the provider; do not send secrets or sensitive material through either tool. The workflow never persists raw MCP configuration or credentials, but that does not remove this upstream disclosure risk. @@ -373,7 +373,7 @@ The review gate is an **optional** stop-time hook. When enabled, pressing Ctrl+C Install from the fork's marketplace snapshot: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.3 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.4 codex plugin add cc@cbepx ``` @@ -394,8 +394,8 @@ This fork does not install from the upstream Sendbird marketplace. Use the CBEPX ```bash CC_PLUGIN_CODEX_MARKETPLACE_NAME=cbepx \ CC_PLUGIN_CODEX_MARKETPLACE_SOURCE=CBEPX/cc-plugin-codex \ -CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.3 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.3/cc-plugin-codex-1.7.3.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.4 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.4/cc-plugin-codex-1.7.4.tgz install ``` After install, run: @@ -422,13 +422,32 @@ $cc:setup ### Update -Re-run the fork marketplace install flow, pinned to the release you want: +Codex rejects re-adding an existing marketplace name when the pinned source/ref changes. Replace the existing marketplace and plugin, then install the exact release ref: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.3 +codex plugin remove cc@cbepx +codex plugin marketplace remove cbepx +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.4 codex plugin add cc@cbepx ``` +Restart Codex so the newly installed plugin is loaded, then run `$cc:setup` and `$cc:setup --check`. The latter is the existing read-only doctor-equivalent; there is no `$cc:doctor` command. + +### Maintainer release qualification (#27) + +This is an opt-in, credential-gated manual check against the installed exact `v1.7.4` tag and artifact. It is separate from hermetic CI and was not run as part of this documentation task. Record each result without printing secrets: + +- [ ] Record the exact tag, commit, artifact basename, byte size, and SHA-256; confirm the installed marketplace/plugin ref and cache metadata match. +- [ ] Restart Codex, run `$cc:setup`, then run `$cc:setup --check`; record the read-only readiness result and confirm no `$cc:doctor` command is required. +- [ ] With authenticated Claude and the approved model setup, run real `$cc:design`, `$cc:research`, and critique/continuation flows; verify each returns its mode-specific structured schema, non-empty content, and required repository evidence. +- [ ] Exercise both exact Brave tools, `mcp__brave-search__brave_web_search` and `mcp__brave-search__brave_llm_context`; verify direct `https://` citations and that each event is present in the frozen selected-tool manifest. +- [ ] Verify negative evidence: an unselected Brave tool and a lookalike/non-registry Brave ID do not count as web evidence. +- [ ] Record requested/final model, context window, fallback events, and failure metadata; verify fallback and incomplete outcomes remain bounded and truthful. +- [ ] Confirm the repository worktree and touched-files snapshot are unchanged, no active jobs remain, and the exact result is retrievable without cleanup side effects. +- [ ] Review rendered output and logs for redaction: no credentials, raw leases, raw MCP configuration, headers, prompts, queries marked sensitive, or unrestricted provider output. + +Do not treat CI as proof of these live provider, model, citation, or credential-dependent gates. Store only redacted evidence and keep live qualification approval separate from source, CI, and artifact readiness. + ### Uninstall ```bash diff --git a/internal-skills/peer-runtime/runtime.md b/internal-skills/peer-runtime/runtime.md index 80bdd13..80ffecd 100644 --- a/internal-skills/peer-runtime/runtime.md +++ b/internal-skills/peer-runtime/runtime.md @@ -26,7 +26,7 @@ In short: rerun preflight after installation or restart. ## New workflow 1. Resolve routing with `session-routing-context --json`. -2. Run `mcp-diagnose --json` with the user's exact MCP flags. This actively starts/probes every configured server in scope and can therefore have server-defined side effects. The active Codex controller chooses the smallest relevant subset of eligible exact IDs from their descriptions. Pass those choices as repeated internal `--auto-mcp-tool` values to `peer-create`; Node validates exact IDs and safety only. Eligibility trusts a server's `readOnlyHint` declaration or the audited registry, is not an OS sandbox, and always vetoes `destructiveHint`. The annotationless audited registry includes exactly `mcp__brave-search__brave_web_search` and `mcp__brave-search__brave_llm_context`; no other Brave ID is eligible through it. With `--no-auto-tools`, choose none automatically. Exact user pins remain exact and still must be eligible. +2. Run `mcp-diagnose --json` with the user's exact MCP flags. This actively starts/probes every configured server in scope and can therefore have server-defined side effects. The active Codex controller chooses the smallest relevant subset of eligible exact IDs from their descriptions. Pass those choices as repeated internal `--auto-mcp-tool` values to `peer-create`; Node validates exact IDs and safety only. Eligibility trusts a server's `readOnlyHint` declaration or an immutable four-ID audited registry — Context7 `mcp__context7__query-docs` and `mcp__context7__resolve-library-id`, plus Brave `mcp__brave-search__brave_web_search` and `mcp__brave-search__brave_llm_context`. It is not an OS sandbox and always vetoes `destructiveHint`. Only the two Brave IDs are Brave web-evidence tools; no other Brave ID is eligible through the registry. With `--no-auto-tools`, choose none automatically. Exact user pins remain exact and still must be eligible. 3. Keep a shell-hostile or multiline brief out of argv: normalize it once, write it to an OS temporary file outside the workspace, and use the internal `--brief-file`. Delete that temporary file after `peer-create` returns. 4. Run `peer-create --mode --cwd --owner-session-id ... --json`. Preserve public model/MCP flags and controller-selected internal IDs. 5. `peer-create` has already reserved the Codex memo, Claude memo, and checkpoint attempts atomically. Use its returned `spawnPlan` with built-in `spawn_agent`: spawn exactly two children. For both, pass `fork_turns: "none"` and the returned self-contained message. Do not add parent history. @@ -45,7 +45,7 @@ A Claude-first forwarder uses one absolute 30-minute deadline while waiting for Each worker receives only its own raw lease in its spawn message. A raw lease is never a Node argv value and never enters workflow, job, log, status, result, or rendered state. Durable targets contain only `attemptReservation: { leaseDigest, epoch, reservedAt, previousFailureDetail }`; `previousFailureDetail` is nullable and restricted to the bounded workflow failure-detail allowlist. Attempts and append-only attempt history advance when activation wins, not when the controller reserves work. Submit and failure transitions reuse the activated lease and epoch fence. -The pure Claude forwarder must run exactly one companion command, in the foreground, and return stdout unchanged. It does no repository inspection or reasoning itself. Never use shell backgrounding (`nohup`, detached spawn, or an ampersand operator). Never invoke `codex exec`. If the shell yields a session, poll that same session until exit. +The pure Claude forwarder must run exactly one companion command, in the foreground, and return its bounded receipt stdout unchanged. It does no repository inspection or reasoning itself. Never use shell backgrounding (`nohup`, detached spawn, or an ampersand operator). Never invoke `codex exec`. If the shell yields a session, poll that same session until exit. `peer-claude-turn` gives Claude only Read, Glob, Grep, the selected `WebSearch, WebFetch` route, and exact selected MCP tools. The companion enforces `permission-mode=dontAsk`, a strict MCP config, no Bash, and no Agent. It also requires a fail-closed filesystem sandbox: native Windows is unsupported, unsandboxed commands are disabled, the canonical workspace is the only explicit read allowance, and canonical `CODEX_HOME` plus `~/.claude/projects` are denied by both the sandbox and Read permission rules. If the required filesystem sandbox is unavailable or the workspace overlaps protected state, fail closed with `PEER_ISOLATION_UNAVAILABLE` before research can proceed. Selected external MCP servers remain trusted declarations rather than an OS sandbox; the rendered manifest preserves the exact trust basis. Revalidation starts/probes only the servers represented in the frozen selection. It records requested/final/fallback model telemetry and actual public tool-event names. A Brave event is web evidence only if its exact ID is one of the two audited IDs and is present in that frozen manifest; an unselected or lookalike Brave ID does not count. @@ -63,10 +63,10 @@ Every initial memo needs non-empty structured content, a canonical in-workspace Continue is foreground. -1. Read the explicit workflow in the current canonical workspace. Run `peer-resume-plan --continue --owner-session-id --json`, sending optional feedback as JSON on stdin. This explicitly rebinds a cross-session owner and reserves critique plus synthesis before dispatch; never use generic rescue `--resume-last`. +1. Read the explicit workflow in the current canonical workspace. Run `peer-resume-plan --continue --owner-session-id --json`. Empty or closed stdin means no optional feedback; non-empty feedback must be a JSON object on stdin. This explicitly rebinds a cross-session owner and reserves critique plus synthesis before dispatch; never use generic rescue `--resume-last`. 2. Execute the returned plans sequentially. Spawn the pure Claude forwarder with `fork_turns: "none"`, inherited model, and medium effort. Its heredoc supplies the reserved critique lease to the one foreground `peer-claude-critique` command. Wait for it. 3. The companion starts one fresh Claude turn with `--no-session-persistence`. Its stdin prompt contains the frozen brief, both frozen memos, and feedback; neither memo is rewritten. -4. Spawn the returned Codex synthesizer with `fork_turns: "none"`, the workflow's Codex model choice, and Codex effort. It activates its supplied synthesis lease immediately before reading the frozen workflow, produces the mode-specific final answer, sends `{lease,payload}` as JSON on stdin to `peer-final`, and performs zero workspace writes. Wait for it and return the stored final answer. +4. Spawn the returned Codex synthesizer with `fork_turns: "none"`, the workflow's Codex model choice, and Codex effort. It activates its supplied synthesis lease immediately before reading the frozen workflow, produces the mode-specific final answer, sends `{lease,payload}` as JSON on stdin to `peer-final`, and performs zero workspace writes. Its stdout is only a bounded receipt; wait for it, then read and return the stored final answer. ## Retry diff --git a/package-lock.json b/package-lock.json index 0026ad9..752fee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.7.3", + "version": "1.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.7.3", + "version": "1.7.4", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" @@ -694,29 +694,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1419,9 +1433,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1432,9 +1446,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1445,9 +1459,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -1465,11 +1479,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -1544,9 +1558,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -1718,9 +1732,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.384", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", - "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", + "version": "1.5.422", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.422.tgz", + "integrity": "sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==", "dev": true, "license": "ISC" }, @@ -2005,9 +2019,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -2722,9 +2736,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -2938,9 +2952,9 @@ } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3299,9 +3313,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index f057061..339404a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.7.3", + "version": "1.7.4", "description": "Claude Code Plugin for Codex (CBEPX fork)", "type": "module", "author": { @@ -108,7 +108,7 @@ }, "overrides": { "typed-rest-client": { - "qs": "6.15.3" + "qs": "6.16.0" } } } diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 3903ed8..1d03e19 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -157,6 +157,7 @@ import { markWorkflowNotification, markWorkflowBranchFailure, normalizeWorkflowFailureDetail, + preflightWorkflowAttempt, readWorkflow, reconcilePeerRetry, rebindWorkflowOwner, @@ -165,6 +166,7 @@ import { reserveWorkflowAttempts, revealWorkflowStage, submitWorkflowStage, + workflowPayloadSha256, workflowNotificationEvent, } from "./lib/workflows.mjs"; import { @@ -515,9 +517,10 @@ function requireWorkflowId(positionals) { return sanitizeId(value, "workflow ID"); } -function readJsonStdin(label) { - const source = readStdinIfPiped().trim(); +async function readJsonStdin(label, allowEmpty = false) { + const source = (await readStdinIfPiped()).trim(); if (!source) { + if (allowEmpty) return {}; throw new Error(`${label} must be provided as JSON on stdin.`); } let value; @@ -532,8 +535,8 @@ function readJsonStdin(label) { return value; } -function readPeerAttemptInput(label, payloadRequired = false) { - const input = readJsonStdin(label); +async function readPeerAttemptInput(label, payloadRequired = false) { + const input = await readJsonStdin(label); if (typeof input.lease !== "string" || !/^[a-f0-9]{64}$/u.test(input.lease)) { throw new Error(`${label} requires a valid attempt lease.`); } @@ -607,6 +610,9 @@ function formatClaudeFailureSummary(failure, fallback) { if (failure?.kind === "claude_auth") { return "Claude Code authentication failed; run `claude auth login`."; } + if (failure?.terminalCategory) { + return `Claude Code turn failed: ${failure.terminalCategory}.`; + } if (failure?.kind !== "claude_rate_limit") { return fallback; } @@ -615,6 +621,23 @@ function formatClaudeFailureSummary(failure, fallback) { : "Claude usage limit reached."; } +function renderBoundedClaudeReviewFailure(result) { + if ( + result?.status !== "failed" || + result.exitCode !== 0 || + result.receivedTerminalEvent !== true + ) { + return null; + } + if (result.failure?.kind === "claude_auth") { + return "Claude Code authentication failed; run `claude auth login`.\n"; + } + if (result.failure?.kind === "claude_rate_limit") { + return "Claude usage limit reached.\n"; + } + return `${formatClaudeFailureSummary(result.failure, "Claude Code turn failed.")}\n`; +} + function normalizeModelFallbacks(events) { if (!Array.isArray(events)) { return []; @@ -1423,15 +1446,18 @@ async function executeReviewRun(request) { streamDiagnostics: result.streamDiagnostics ?? [] } }; + const boundedFailure = renderBoundedClaudeReviewFailure(result); const rendered = appendModelFallbackSummary( - [ - `# Claude Code ${reviewName}`, - "", - `Target: ${target.label}`, - "", - typeof result.result === "string" ? result.result : JSON.stringify(result.result, null, 2), - "" - ].join("\n"), + boundedFailure + ? boundedFailure + : [ + `# Claude Code ${reviewName}`, + "", + `Target: ${target.label}`, + "", + typeof result.result === "string" ? result.result : JSON.stringify(result.result, null, 2), + "" + ].join("\n"), modelFallbacks ); @@ -1441,13 +1467,15 @@ async function executeReviewRun(request) { turnId: null, payload, rendered, - summary: formatClaudeFailureSummary( - result.failure, - firstMeaningfulLine( - typeof result.result === "string" ? result.result : "", - `${reviewName} completed.` - ) - ), + summary: + boundedFailure?.trim() ?? + formatClaudeFailureSummary( + result.failure, + firstMeaningfulLine( + typeof result.result === "string" ? result.result : "", + `${reviewName} completed.` + ) + ), jobTitle: `Claude Code ${reviewName}`, jobClass: "review", targetLabel: target.label @@ -1538,26 +1566,31 @@ async function executeReviewRun(request) { parseError: parsed.parseError }; + const boundedFailure = renderBoundedClaudeReviewFailure(result); return { exitStatus: resolveClaudeExitStatus(result), threadId: result.sessionId, turnId: null, payload, rendered: appendModelFallbackSummary( - renderReviewResult(parsed, { - reviewLabel: reviewName, - targetLabel: context.target.label, - reasoningSummary: null - }), + boundedFailure + ? boundedFailure + : renderReviewResult(parsed, { + reviewLabel: reviewName, + targetLabel: context.target.label, + reasoningSummary: null + }), modelFallbacks ), - summary: formatClaudeFailureSummary( - result.failure, - parsed.parsed?.summary ?? - firstMeaningfulLine( - typeof result.result === "string" ? result.result : "", - parsed.parseError ?? `${reviewName} finished.` - ) + summary: + boundedFailure?.trim() ?? + formatClaudeFailureSummary( + result.failure, + parsed.parsed?.summary ?? + firstMeaningfulLine( + typeof result.result === "string" ? result.result : "", + parsed.parseError ?? `${reviewName} finished.` + ) ), jobTitle: `Claude Code ${reviewName}`, jobClass: "review", @@ -2048,13 +2081,13 @@ async function executeTransfer(cwd, options = {}) { }; } -function readTaskPrompt(cwd, options, positionals) { +async function readTaskPrompt(cwd, options, positionals) { if (options["prompt-file"]) { return fs.readFileSync(path.resolve(cwd, options["prompt-file"]), "utf8"); } const positionalPrompt = positionals.join(" "); - return positionalPrompt || readStdinIfPiped(); + return positionalPrompt || await readStdinIfPiped(); } function requireTaskRequest(prompt, resumeLast) { @@ -2798,7 +2831,7 @@ async function handleTask(argv) { const model = resolveDefaultModel(requestedModel); const resolvedEffort = resolveDefaultEffort(model, options.effort); const effort = resolvedEffort ? resolveEffort(resolvedEffort) : null; - const prompt = readTaskPrompt(cwd, options, positionals); + const prompt = await readTaskPrompt(cwd, options, positionals); const foregroundTimeoutMs = parseWaitTimeoutMilliseconds(options); const markViewedOnTerminal = resolveMarkViewedOnTerminal( options["view-state"], @@ -3260,6 +3293,38 @@ function targetStatus(workflow, stage, branchId) { return branchId ? workflow.branches?.[branchId]?.status : workflow.stages?.[stage]?.status; } +function peerWorkflowHeader(workflow) { + return { + id: workflow.id, + mode: workflow.mode, + revision: workflow.revision, + epoch: workflow.epoch, + status: workflow.status, + phase: workflow.phase, + briefHash: workflow.briefHash, + }; +} + +function peerReceipt(workflow, stage, branchId = null, includePayloadSha256 = false) { + const target = branchId ? workflow.branches?.[branchId] : workflow.stages?.[stage]; + return { + workflowId: workflow.id, + mode: workflow.mode, + revision: workflow.revision, + epoch: workflow.epoch, + status: workflow.status, + phase: workflow.phase, + target: { + stage, + ...(branchId ? { branchId } : {}), + status: target?.status, + }, + ...(includePayloadSha256 + ? { payloadSha256: workflowPayloadSha256(target?.payload) } + : {}), + }; +} + function withLatestWorkflow(cwd, workflowId, run) { let lastError; for (let attempt = 0; attempt < 8; attempt += 1) { @@ -3401,7 +3466,7 @@ function peerFailureCode(error) { function failPeerAttempt(cwd, workflowId, target, fence, error) { const reason = peerFailureCode(error); - const failureDetail = reason === "EVIDENCE_INCOMPLETE" + const failureDetail = reason === "EVIDENCE_INCOMPLETE" || reason === "CLAUDE_TURN_FAILED" ? normalizeWorkflowFailureDetail(error?.failureDetail) : null; if (reason === "ATTEMPT_LEASE_REFLECTION") return; @@ -3429,7 +3494,6 @@ function submitPeerTargetOneShot(cwd, workflowId, options) { function parsePeerClaudePayload(result, label) { if ( - result.terminalSubtype === "success" && result.structuredOutput && typeof result.structuredOutput === "object" && !Array.isArray(result.structuredOutput) && @@ -3558,10 +3622,26 @@ async function executePeerClaudeTurn(cwd, workflowId, options = {}) { } ); if (result.status !== "completed") { + if (result.failure?.kind === "claude_auth") { + throw Object.assign(new Error("CLAUDE_AUTH"), { code: "CLAUDE_AUTH" }); + } + if (result.failure?.kind === "claude_rate_limit") { + throw Object.assign(new Error("CLAUDE_RATE_LIMIT"), { code: "CLAUDE_RATE_LIMIT" }); + } + if (result.failure?.terminalCategory) { + throw Object.assign( + new Error(`CLAUDE_TURN_FAILED: ${result.failure.terminalCategory}`), + { + code: "CLAUDE_TURN_FAILED", + failureDetail: result.failure.terminalCategory, + } + ); + } const failureText = [result.failure?.message, result.warning, result.stderr] .filter(Boolean) .join("\n"); if ( + !result.receivedTerminalEvent && /sandbox/iu.test(failureText) && /unavailable|not available|not supported|unsupported|failed|failure|could not|cannot|unable/iu.test(failureText) ) { @@ -3630,16 +3710,10 @@ async function executePeerClaudeTurn(cwd, workflowId, options = {}) { ...fence, }); } - return { - status: "completed", - branch: branchId, - stage, - memo: payload, - workflow: submitted, - }; + return peerReceipt(submitted, stage, branchId, true); } catch (error) { const code = peerFailureCode(error); - const failureDetail = code === "EVIDENCE_INCOMPLETE" + const failureDetail = code === "EVIDENCE_INCOMPLETE" || code === "CLAUDE_TURN_FAILED" ? normalizeWorkflowFailureDetail(error?.failureDetail) : null; const sanitized = Object.assign( @@ -3722,7 +3796,7 @@ async function handlePeerCreate(argv) { { stage: "checkpoint" }, ]); outputResult({ - workflow: reservation.workflow, + workflow: peerWorkflowHeader(reservation.workflow), spawnPlan: buildInitialAgentPlan(reservation.workflow, { companionPath: path.join(ROOT_DIR, "scripts", "claude-companion.mjs"), codexModel: route.codexModel, @@ -3733,7 +3807,7 @@ async function handlePeerCreate(argv) { }, options.json); } -function handlePeerActivateAttempt(argv) { +async function handlePeerActivateAttempt(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "mode", "brief-hash", "epoch", "stage", "branch"], booleanOptions: ["json"], @@ -3743,7 +3817,7 @@ function handlePeerActivateAttempt(argv) { const workflow = readPeerWorkflow(cwd, workflowId, options.mode, options["brief-hash"]); const expectedEpoch = parseWorkflowCounter(options.epoch, "Workflow epoch"); assertPeerEpoch(workflow, expectedEpoch); - const { lease } = readPeerAttemptInput("Peer activation"); + const { lease } = await readPeerAttemptInput("Peer activation"); const activated = activatePeerTarget( cwd, workflowId, @@ -3752,10 +3826,14 @@ function handlePeerActivateAttempt(argv) { expectedEpoch, lease ); - outputResult(activated, options.json); + outputResult(peerReceipt( + activated, + options.stage, + options.branch ?? null + ), options.json); } -function handlePeerSubmitMemo(argv) { +async function handlePeerSubmitMemo(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "branch", "brief-hash", "epoch"], booleanOptions: ["json"], @@ -3771,7 +3849,7 @@ function handlePeerSubmitMemo(argv) { } const expectedEpoch = parseWorkflowCounter(options.epoch, "Workflow epoch"); assertPeerEpoch(workflow, expectedEpoch); - const input = readPeerAttemptInput("Peer memo attempt", true); + const input = await readPeerAttemptInput("Peer memo attempt", true); const fence = { epoch: expectedEpoch, lease: input.lease }; try { const memo = validatePeerMemo(workflow, input.payload, { role: branch }); @@ -3781,7 +3859,7 @@ function handlePeerSubmitMemo(argv) { payload: memo, ...fence, }); - outputResult({ branch, memo, workflow: submitted }, options.json); + outputResult(peerReceipt(submitted, "memo", branch, true), options.json); } catch (error) { failPeerAttempt(cwd, workflowId, { stage: "memo", branchId: branch }, fence, error); throw error; @@ -3797,9 +3875,16 @@ async function handlePeerClaudeTurn(argv, critique = false) { const workflowId = requireWorkflowId(positionals); const workflow = readPeerWorkflow(cwd, workflowId, options.mode, options["brief-hash"]); const expectedEpoch = parseWorkflowCounter(options.epoch, "Workflow epoch"); - assertPeerEpoch(workflow, expectedEpoch); - const { lease } = readPeerAttemptInput("Peer Claude attempt"); + const { lease } = await readPeerAttemptInput("Peer Claude attempt"); const workflowStage = critique ? "critique" : "memo"; + const workflowBranchId = critique ? null : "claude"; + preflightWorkflowAttempt(cwd, workflowId, { + stage: workflowStage, + branchId: workflowBranchId, + epoch: expectedEpoch, + mode: workflow.mode, + lease, + }); const job = createCompanionJob({ prefix: "peer", kind: "task", @@ -3845,7 +3930,7 @@ async function handlePeerClaudeTurn(argv, critique = false) { ); } -function handlePeerCheckpoint(argv) { +async function handlePeerCheckpoint(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "mode", "brief-hash", "epoch"], booleanOptions: ["json"], @@ -3855,7 +3940,7 @@ function handlePeerCheckpoint(argv) { const workflow = readPeerWorkflow(cwd, workflowId, options.mode, options["brief-hash"]); const expectedEpoch = parseWorkflowCounter(options.epoch, "Workflow epoch"); assertPeerEpoch(workflow, expectedEpoch); - const input = readPeerAttemptInput("Checkpoint attempt", true); + const input = await readPeerAttemptInput("Checkpoint attempt", true); const fence = { epoch: expectedEpoch, lease: input.lease }; try { const checkpoint = buildPeerCheckpoint(workflow, input.payload); @@ -3867,14 +3952,14 @@ function handlePeerCheckpoint(argv) { phase: "checkpoint", ...fence, }); - outputResult({ checkpoint, workflow: submitted }, options.json); + outputResult(peerReceipt(submitted, "checkpoint", null, true), options.json); } catch (error) { failPeerAttempt(cwd, workflowId, { stage: "checkpoint" }, fence, error); throw error; } } -function handlePeerResumePlan(argv) { +async function handlePeerResumePlan(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "mode", "owner-session-id"], booleanOptions: ["json", "continue", "retry"], @@ -3890,6 +3975,9 @@ function handlePeerResumePlan(argv) { workflow.workspaceRoot ); if (!ownerSessionId) throw new Error("PEER_OWNER_REQUIRED: An owner session is required."); + const feedback = options.continue + ? await readJsonStdin("Continuation feedback", true) + : null; if (workflow.currentOwnerSessionId !== ownerSessionId) { workflow = rebindWorkflowOwner(cwd, workflowId, { revision: workflow.revision, @@ -3906,7 +3994,11 @@ function handlePeerResumePlan(argv) { listJobs(workflow.workspaceRoot).filter((job) => job.workflowId === workflow.id) ); if (reconciled.retryTargets.length === 0) { - outputResult({ workflow: reconciled.workflow, work: [], spawnPlan: [] }, options.json); + outputResult({ + workflow: peerWorkflowHeader(reconciled.workflow), + work: [], + spawnPlan: [], + }, options.json); return; } const reservation = reserveWorkflowAttempts(cwd, workflowId, { @@ -3921,7 +4013,7 @@ function handlePeerResumePlan(argv) { leases: reservation.leases, }; outputResult({ - workflow: reservation.workflow, + workflow: peerWorkflowHeader(reservation.workflow), work: reconciled.retryTargets.map(({ stage, branchId }) => branchId ? { kind: "branch", id: branchId } : { kind: "stage", id: stage }), @@ -3933,7 +4025,6 @@ function handlePeerResumePlan(argv) { workflow.stages.checkpoint.status !== "completed") { throw new Error("WORKFLOW_NOT_READY: Complete or retry the initial checkpoint first."); } - const feedback = readJsonStdin("Continuation feedback"); workflow = submitPeerTargetOneShot(cwd, workflowId, { stage: "feedback", payload: feedback, @@ -3948,7 +4039,7 @@ function handlePeerResumePlan(argv) { mode: workflow.mode, }, [{ stage: "critique" }, { stage: "synthesis" }]); outputResult({ - workflow: reservation.workflow, + workflow: peerWorkflowHeader(reservation.workflow), work: [{ kind: "stage", id: "critique" }, { kind: "stage", id: "synthesis" }], spawnPlan: buildContinuationAgentPlan(reservation.workflow, { companionPath: path.join(ROOT_DIR, "scripts", "claude-companion.mjs"), @@ -3959,7 +4050,7 @@ function handlePeerResumePlan(argv) { }, options.json); } -function handlePeerFinal(argv) { +async function handlePeerFinal(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "mode", "brief-hash", "epoch"], booleanOptions: ["json"], @@ -3972,7 +4063,7 @@ function handlePeerFinal(argv) { if (workflow.stages.critique.status !== "completed") { throw new Error("CRITIQUE_INCOMPLETE: Claude critique must be frozen before synthesis."); } - const input = readPeerAttemptInput("Final synthesis attempt", true); + const input = await readPeerAttemptInput("Final synthesis attempt", true); const result = input.payload; const fence = { epoch: expectedEpoch, lease: input.lease }; try { @@ -3987,20 +4078,20 @@ function handlePeerFinal(argv) { phase: "done", ...fence, }); - outputResult({ result, workflow: submitted }, options.json); + outputResult(peerReceipt(submitted, "synthesis", null, true), options.json); } catch (error) { failPeerAttempt(cwd, workflowId, { stage: "synthesis" }, fence, error); throw error; } } -function handleWorkflowCreate(argv) { +async function handleWorkflowCreate(argv) { const { options } = parseCommandInput(argv, { valueOptions: ["cwd"], booleanOptions: ["json"], }); const cwd = resolveCommandCwd(options); - const input = readJsonStdin("Workflow definition"); + const input = await readJsonStdin("Workflow definition"); const workflow = reserveWorkflow(cwd, { ...input, originSessionId: @@ -4074,7 +4165,7 @@ function rejectPublicPeerMutation(cwd, workflowId, options) { } } -function handleWorkflowSubmitStage(argv) { +async function handleWorkflowSubmitStage(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: [ "cwd", @@ -4092,7 +4183,7 @@ function handleWorkflowSubmitStage(argv) { }); const cwd = resolveCommandCwd(options); const workflowId = requireWorkflowId(positionals); - const payload = readJsonStdin("Stage payload"); + const payload = await readJsonStdin("Stage payload"); rejectPublicPeerMutation(cwd, workflowId, options); const mutation = workflowMutationOptions(options); const workflow = submitWorkflowStage( @@ -4424,7 +4515,7 @@ async function main() { break; case "workflow-create": case "workflow-reserve": - handleWorkflowCreate(argv); + await handleWorkflowCreate(argv); break; case "workflow-read": handleWorkflowRead(argv); @@ -4433,7 +4524,7 @@ async function main() { handleWorkflowList(argv); break; case "workflow-submit-stage": - handleWorkflowSubmitStage(argv); + await handleWorkflowSubmitStage(argv); break; case "workflow-fail-branch": handleWorkflowBranchFailure(argv); @@ -4451,10 +4542,10 @@ async function main() { await handlePeerCreate(argv); break; case "peer-activate-attempt": - handlePeerActivateAttempt(argv); + await handlePeerActivateAttempt(argv); break; case "peer-submit-memo": - handlePeerSubmitMemo(argv); + await handlePeerSubmitMemo(argv); break; case "peer-claude-turn": await handlePeerClaudeTurn(argv); @@ -4463,16 +4554,16 @@ async function main() { handlePeerWait(argv); break; case "peer-checkpoint": - handlePeerCheckpoint(argv); + await handlePeerCheckpoint(argv); break; case "peer-resume-plan": - handlePeerResumePlan(argv); + await handlePeerResumePlan(argv); break; case "peer-claude-critique": await handlePeerClaudeTurn(argv, true); break; case "peer-final": - handlePeerFinal(argv); + await handlePeerFinal(argv); break; case "cancel": await handleCancel(argv); diff --git a/scripts/lib/claude-cli.mjs b/scripts/lib/claude-cli.mjs index 97a5468..f8c98d7 100644 --- a/scripts/lib/claude-cli.mjs +++ b/scripts/lib/claude-cli.mjs @@ -375,7 +375,11 @@ export function classifyClaudeFailure(value = {}) { finalMessage && CLAUDE_FINAL_MESSAGE_LIMIT_RE.test(finalMessage) ); - const stderrLimit = Boolean(stderr && CLAUDE_ERROR_LIMIT_RE.test(stderr)); + const stderrLimit = Boolean( + stderr && + !(value.exitCode === 0 && value.receivedTerminalEvent === true) && + CLAUDE_ERROR_LIMIT_RE.test(stderr) + ); if (finalMessageLimit || stderrLimit) { const limitSource = finalMessageLimit ? finalMessage : stderr; const resetText = finalMessageLimit @@ -597,6 +601,8 @@ export class StreamParser { finalMessage: "", structuredOutput: null, terminalSubtype: null, + terminalReason: null, + terminalIsError: null, receivedTerminalEvent: false, unknownEvents: [], parseErrors: [], @@ -688,6 +694,10 @@ export class StreamParser { this.state.receivedTerminalEvent = true; this.state.terminalSubtype = typeof event.subtype === "string" ? event.subtype : null; + this.state.terminalReason = + typeof event.terminal_reason === "string" ? event.terminal_reason : null; + this.state.terminalIsError = + typeof event.is_error === "boolean" ? event.is_error : null; { const terminalModel = normalizeObservedModel( extractRawObservedModel(event) @@ -906,6 +916,101 @@ function mergeTerminalResultText(existingText, terminalText) { // Turn Completion Validation // --------------------------------------------------------------------------- +const CLAUDE_TERMINAL_FAILURES = Object.freeze({ + API_ERROR: Object.freeze({ + kind: "claude_api_error", + terminalCategory: "CLAUDE_API_ERROR", + }), + MAX_TURNS: Object.freeze({ + kind: "claude_max_turns", + terminalCategory: "CLAUDE_MAX_TURNS", + }), + MAX_BUDGET: Object.freeze({ + kind: "claude_max_budget", + terminalCategory: "CLAUDE_MAX_BUDGET", + }), + STRUCTURED_OUTPUT_RETRIES: Object.freeze({ + kind: "claude_structured_output_retries", + terminalCategory: "CLAUDE_STRUCTURED_OUTPUT_RETRIES", + }), + ABORTED: Object.freeze({ + kind: "claude_aborted", + terminalCategory: "CLAUDE_ABORTED", + }), + UNKNOWN_TERMINAL: Object.freeze({ + kind: "claude_unknown_terminal", + terminalCategory: "CLAUDE_UNKNOWN_TERMINAL", + }), +}); + +// Exact allowlist observed in Claude Code 2.1.260. Unknown values and +// conflicting subtype/reason pairs fail closed instead of growing a hierarchy +// from provider prose. +const CLAUDE_SUBTYPE_CATEGORIES = new Map([ + ["error_during_execution", "API_ERROR"], + ["error_max_turns", "MAX_TURNS"], + ["error_max_budget_usd", "MAX_BUDGET"], + ["error_max_structured_output_retries", "STRUCTURED_OUTPUT_RETRIES"], +]); + +const CLAUDE_REASON_CATEGORIES = new Map([ + ["blocking_limit", "API_ERROR"], + ["rapid_refill_breaker", "API_ERROR"], + ["prompt_too_long", "API_ERROR"], + ["image_error", "API_ERROR"], + ["model_error", "API_ERROR"], + ["api_error", "API_ERROR"], + ["malformed_tool_use_exhausted", "API_ERROR"], + ["tool_deferred_unavailable", "API_ERROR"], + ["turn_setup_failed", "API_ERROR"], + ["aborted_streaming", "ABORTED"], + ["aborted_tools", "ABORTED"], + ["stop_hook_prevented", "ABORTED"], + ["hook_stopped", "ABORTED"], + ["tool_deferred", "ABORTED"], + ["background_requested", "ABORTED"], + ["max_turns", "MAX_TURNS"], + ["budget_exhausted", "MAX_BUDGET"], + ["structured_output_retry_exhausted", "STRUCTURED_OUTPUT_RETRIES"], +]); + +function terminalFailure(category) { + return { ...CLAUDE_TERMINAL_FAILURES[category] }; +} + +function classifyClaudeTerminal(state) { + const subtype = state.terminalSubtype; + const reason = state.terminalReason; + const isError = state.terminalIsError; + if (typeof isError !== "boolean") { + return terminalFailure("UNKNOWN_TERMINAL"); + } + const reasonCategory = reason == null ? null : CLAUDE_REASON_CATEGORIES.get(reason); + + if (subtype === "success") { + if ((reason == null || reason === "completed") && isError === false) { + return null; + } + if ((reason == null || reasonCategory === "API_ERROR") && isError === true) { + return terminalFailure("API_ERROR"); + } + if (reasonCategory === "ABORTED" && isError === false) { + return terminalFailure("ABORTED"); + } + return terminalFailure("UNKNOWN_TERMINAL"); + } + + const subtypeCategory = CLAUDE_SUBTYPE_CATEGORIES.get(subtype); + if ( + subtypeCategory && + isError === true && + (reason == null || reasonCategory === subtypeCategory) + ) { + return terminalFailure(subtypeCategory); + } + return terminalFailure("UNKNOWN_TERMINAL"); +} + export function validateTurnCompletion(state, exitCode, options = {}) { if (exitCode !== 0) { return { status: "failed", exitCode }; @@ -916,6 +1021,10 @@ export function validateTurnCompletion(state, exitCode, options = {}) { warning: "No terminal result event received despite exit code 0", }; } + const terminalFailureResult = classifyClaudeTerminal(state); + if (terminalFailureResult) { + return { status: "failed", failure: terminalFailureResult }; + } if (state.unresolvedParseErrors > 0) { const warning = `${state.unresolvedParseErrors} unrecovered parse error` + @@ -1430,7 +1539,7 @@ export function buildArgs(prompt, options = {}) { /** * Execute a Claude Code turn with streaming progress. - * Returns { status, sessionId, finalMessage, structuredOutput, terminalSubtype, toolUses, touchedFiles, stderr, pid, pidIdentity } + * Returns { status, sessionId, finalMessage, structuredOutput, receivedTerminalEvent, terminalSubtype, terminalReason, terminalIsError, toolUses, touchedFiles, stderr, pid, pidIdentity } */ export async function runClaudeTurn(cwd, prompt, options = {}) { const args = buildArgs(prompt, { @@ -1446,7 +1555,10 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { sessionId: null, finalMessage: "", structuredOutput: null, + receivedTerminalEvent: false, terminalSubtype: null, + terminalReason: null, + terminalIsError: null, toolUses: [], touchedFiles: [], requestedModel, @@ -1552,17 +1664,22 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { const modelEvents = [...parser.state.modelEvents]; const finalModel = parser.state.finalModel; const contextWindow = parser.state.contextWindow; - const failure = - validation.status === "failed" - ? classifyClaudeFailure({ - finalMessage: parser.state.finalMessage, - finalMessageHasLimitSignal: parser.state.hasTerminalLimitSignal, - finalMessageHasAuthSignal: parser.state.hasTerminalAuthSignal, - stderr, - exitCode: code ?? 1, - receivedTerminalEvent: parser.state.receivedTerminalEvent, - }) - : null; + const detectedFailure = classifyClaudeFailure({ + finalMessage: parser.state.finalMessage, + finalMessageHasLimitSignal: parser.state.hasTerminalLimitSignal, + finalMessageHasAuthSignal: parser.state.hasTerminalAuthSignal, + stderr, + exitCode: code ?? 1, + receivedTerminalEvent: parser.state.receivedTerminalEvent, + }); + if (detectedFailure && validation.status !== "failed") { + validation = { status: "failed" }; + } + const failure = validation.failure && + !parser.state.hasTerminalLimitSignal && + !parser.state.hasTerminalAuthSignal + ? validation.failure + : detectedFailure ?? validation.failure ?? null; if ( requestedModel && finalModel && @@ -1588,7 +1705,10 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { sessionId: parser.state.sessionId, finalMessage: parser.state.finalMessage, structuredOutput: parser.state.structuredOutput, + receivedTerminalEvent: parser.state.receivedTerminalEvent, terminalSubtype: parser.state.terminalSubtype, + terminalReason: parser.state.terminalReason, + terminalIsError: parser.state.terminalIsError, toolUses: parser.state.toolUses, touchedFiles: parser.state.touchedFiles, requestedModel, @@ -1612,7 +1732,10 @@ export async function runClaudeTurn(cwd, prompt, options = {}) { sessionId: null, finalMessage: "", structuredOutput: null, + receivedTerminalEvent: false, terminalSubtype: null, + terminalReason: null, + terminalIsError: null, toolUses: [], touchedFiles: [], requestedModel, @@ -1663,6 +1786,7 @@ export async function runClaudeReview(cwd, prompt, options = {}) { warning: result.warning, result: result.finalMessage, structuredOutput: result.structuredOutput ?? null, + receivedTerminalEvent: result.receivedTerminalEvent, sessionId: result.sessionId, requestedModel: result.requestedModel, finalModel: result.finalModel, @@ -1671,6 +1795,9 @@ export async function runClaudeReview(cwd, prompt, options = {}) { parseErrors: result.parseErrors, unresolvedParseErrors: result.unresolvedParseErrors, streamDiagnostics: result.streamDiagnostics, + terminalSubtype: result.terminalSubtype, + terminalReason: result.terminalReason, + terminalIsError: result.terminalIsError, failure: result.failure, stderr: result.stderr, pid: result.pid, diff --git a/scripts/lib/fs.mjs b/scripts/lib/fs.mjs index 1c566c2..e85d0d8 100644 --- a/scripts/lib/fs.mjs +++ b/scripts/lib/fs.mjs @@ -2,7 +2,7 @@ * Copyright 2026 Sendbird, Inc. * SPDX-License-Identifier: Apache-2.0 */ -import fs from "node:fs"; +import { isatty } from "node:tty"; export function isProbablyText(buffer) { const sample = buffer.subarray(0, Math.min(buffer.length, 4096)); @@ -14,9 +14,14 @@ export function isProbablyText(buffer) { return true; } -export function readStdinIfPiped() { - if (process.stdin.isTTY) { +export async function readStdinIfPiped() { + if (isatty(0)) { return ""; } - return fs.readFileSync(0, "utf8"); + process.stdin.setEncoding("utf8"); + let input = ""; + for await (const chunk of process.stdin) { + input += chunk; + } + return input; } diff --git a/scripts/lib/mcp-capabilities.mjs b/scripts/lib/mcp-capabilities.mjs index 8342779..7c3d110 100644 --- a/scripts/lib/mcp-capabilities.mjs +++ b/scripts/lib/mcp-capabilities.mjs @@ -31,11 +31,21 @@ export const BRAVE_WEB_EVIDENCE_TOOLS = Object.freeze({ return braveWebEvidenceToolIds.values(); }, }); -export const AUDITED_ANNOTATIONLESS_READ_ONLY_TOOLS = new Set([ +const auditedAnnotationlessReadOnlyToolIds = new Set([ "mcp__context7__query-docs", "mcp__context7__resolve-library-id", - ...BRAVE_WEB_EVIDENCE_TOOLS, + "mcp__brave-search__brave_web_search", + "mcp__brave-search__brave_llm_context", ]); +export const AUDITED_ANNOTATIONLESS_READ_ONLY_TOOLS = Object.freeze({ + /** @param {string} toolId */ + has(toolId) { + return auditedAnnotationlessReadOnlyToolIds.has(toolId); + }, + [Symbol.iterator]() { + return auditedAnnotationlessReadOnlyToolIds.values(); + }, +}); function stableJson(value) { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; @@ -295,7 +305,7 @@ function stdioProbe(config, timeoutMs) { params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "cc-plugin-codex", version: "1.7.3" }, + clientInfo: { name: "cc-plugin-codex", version: "1.7.4" }, }, }); }); @@ -384,7 +394,7 @@ async function httpProbe(config, timeoutMs) { params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "cc-plugin-codex", version: "1.7.3" }, + clientInfo: { name: "cc-plugin-codex", version: "1.7.4" }, }, }, null, deadline); if (initialized.statusCode === 401 || initialized.statusCode === 403) { diff --git a/scripts/lib/peer-orchestration.mjs b/scripts/lib/peer-orchestration.mjs index 602135b..27fd5d3 100644 --- a/scripts/lib/peer-orchestration.mjs +++ b/scripts/lib/peer-orchestration.mjs @@ -184,6 +184,28 @@ function heredoc(command, value, marker) { return `${command} <<'${marker}'\n${promptData(value)}\n${marker}`; } +function submissionRecipes(command, lease, marker) { + return [ + "For a small payload, replace CC_PEER_PAYLOAD_JSON with the payload object and run:", + `${command} <<'${marker}'`, + `{"lease":${promptData(lease ?? null)},"payload":CC_PEER_PAYLOAD_JSON}`, + marker, + "For a large payload or PTY, encode the same complete JSON attempt object as wrapped base64, replace CC_PEER_WRAPPED_BASE64, and run:", + "(", + "CC_PEER_INPUT=$(mktemp) || exit", + "trap 'rm -f \"$CC_PEER_INPUT\"' EXIT", + "node -e '", + "const fs = require(\"node:fs\");", + "const text = fs.readFileSync(0, \"utf8\").replace(/\\s/g, \"\");", + "fs.writeFileSync(process.argv[1], Buffer.from(text, \"base64\"));", + `' "$CC_PEER_INPUT" <<'${marker}_B64'`, + "CC_PEER_WRAPPED_BASE64", + `${marker}_B64`, + `${command} < "$CC_PEER_INPUT"`, + ")", + ].join("\n"); +} + function checkpointReadInstructions(readCommand) { return [ "Make separate short foreground peer-wait calls; wait for each call to exit before starting another.", @@ -234,15 +256,27 @@ export function buildInitialAgentPlan(workflow, options) { "The attempt leases below belong only to this worker. Never persist, render, log, or pass them on argv.", attemptBlock({ memo: codexLease, checkpoint: checkpointLease }), "Before research, send {lease:} as JSON stdin to this activation command:", - activationCommand(workflow, companionPath, "memo", "codex"), + heredoc( + activationCommand(workflow, companionPath, "memo", "codex"), + { lease: codexLease }, + "CC_PEER_MEMO_ACTIVATION" + ), "Submit {lease:,payload:} as JSON stdin to this command:", - submitMemoCommand, + submissionRecipes(submitMemoCommand, codexLease, "CC_PEER_MEMO_SUBMISSION"), "After submission, read the peer state with these one-shot instructions:", ...checkpointReadInstructions(readCommand), "When both memos completed, activate checkpoint with {lease:} on JSON stdin immediately before comparison:", - activationCommand(workflow, companionPath, "checkpoint"), + heredoc( + activationCommand(workflow, companionPath, "checkpoint"), + { lease: checkpointLease }, + "CC_PEER_CHECKPOINT_ACTIVATION" + ), "Then compare the frozen payloads and submit {lease:,payload:{agreements,disagreements,decisionsNeeded}} as JSON stdin to peer-checkpoint.", - checkpointCommand, + submissionRecipes( + checkpointCommand, + checkpointLease, + "CC_PEER_CHECKPOINT_SUBMISSION" + ), "If the workflow is incomplete, do not synthesize or replace either memo.", ].join("\n\n"), }; @@ -253,10 +287,10 @@ export function buildInitialAgentPlan(workflow, options) { message: [ "You are a pure Claude forwarder for an independent peer workflow.", common, - "Run exactly one shell command in the foreground and return stdout unchanged.", + "Run exactly one shell command in the foreground and return its bounded receipt stdout unchanged.", "Do not inspect the repository, research, reinterpret the brief, or add commentary.", "Never use shell backgrounding. If the shell yields a session, poll only that session until it exits.", - "Exit code 0 is success; otherwise return the raw stdout or failure diagnostic.", + "Exit code 0 is success; otherwise return the failure diagnostic.", heredoc(baseCommand, { lease: claudeLease }, "CC_PEER_CLAUDE_ATTEMPT"), ].join("\n\n"), }; @@ -276,7 +310,7 @@ export function buildContinuationAgentPlan(workflow, options) { reasoning_effort: "medium", message: [ "You are a pure Claude forwarder for a peer continuation.", - "Run exactly one shell command in the foreground and return stdout unchanged.", + "Run exactly one shell command in the foreground and return its bounded receipt stdout unchanged.", heredoc(critiqueCommand, { lease: critiqueLease }, "CC_PEER_CRITIQUE_ATTEMPT"), ].join("\n\n"), }, @@ -292,9 +326,13 @@ export function buildContinuationAgentPlan(workflow, options) { "Wait until the critique is completed, then activate immediately before synthesis.", "The attempt lease below belongs only to this worker. Never persist, render, log, or pass it on argv.", attemptBlock({ synthesis: synthesisLease }), - activationCommand(workflow, companionPath, "synthesis"), + heredoc( + activationCommand(workflow, companionPath, "synthesis"), + { lease: synthesisLease }, + "CC_PEER_SYNTHESIS_ACTIVATION" + ), "Read the frozen workflow, synthesize the final answer without workspace writes, and submit {lease,payload} as JSON stdin:", - finalCommand, + submissionRecipes(finalCommand, synthesisLease, "CC_PEER_FINAL_SUBMISSION"), ].join("\n\n"), }, ]; @@ -323,9 +361,17 @@ export function buildRetryAgentPlan(workflow, retryTargets, options) { "You are the Codex checkpoint waiter for a peer retry.", ...checkpointReadInstructions(waitCommand), attemptBlock({ checkpoint: options.leases?.["stage:checkpoint"] }), - activationCommand(workflow, options.companionPath, "checkpoint"), + heredoc( + activationCommand(workflow, options.companionPath, "checkpoint"), + { lease: options.leases?.["stage:checkpoint"] }, + "CC_PEER_CHECKPOINT_ACTIVATION" + ), "Submit {lease,payload:{agreements,disagreements,decisionsNeeded}} as JSON stdin:", - peerCommand(workflow, options.companionPath, "peer-checkpoint"), + submissionRecipes( + peerCommand(workflow, options.companionPath, "peer-checkpoint"), + options.leases?.["stage:checkpoint"], + "CC_PEER_CHECKPOINT_SUBMISSION" + ), ].join("\n\n"), }); } diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 0d1022d..8c32210 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -439,6 +439,9 @@ export function renderTaskResult(parsedResult) { "", ].join("\n"); } + if (parsedResult?.failure?.terminalCategory) { + return `Claude Code turn failed: ${parsedResult.failure.terminalCategory}.\n`; + } const rawOutput = typeof parsedResult?.rawOutput === "string" ? parsedResult.rawOutput : ""; if (rawOutput) return rawOutput.endsWith("\n") ? rawOutput : `${rawOutput}\n`; const message = String(parsedResult?.failureMessage ?? "").trim() || "Claude Code did not return a final message."; diff --git a/scripts/lib/workflows.mjs b/scripts/lib/workflows.mjs index e4d893d..f893ceb 100644 --- a/scripts/lib/workflows.mjs +++ b/scripts/lib/workflows.mjs @@ -35,6 +35,12 @@ export const BRANCH_STATUSES = new Set([ "cancel_failed", ]); const WORKFLOW_FAILURE_DETAILS = new Set([ + "CLAUDE_API_ERROR", + "CLAUDE_MAX_TURNS", + "CLAUDE_MAX_BUDGET", + "CLAUDE_STRUCTURED_OUTPUT_RETRIES", + "CLAUDE_ABORTED", + "CLAUDE_UNKNOWN_TERMINAL", "STRUCTURED_JSON_REQUIRED", "NON_EMPTY_CONTENT_REQUIRED", "REPOSITORY_CITATION_REQUIRED", @@ -270,10 +276,14 @@ function assertCas(workflow, options) { `Expected revision ${options.revision}, found ${workflow.revision}.` ); } - if (workflow.epoch !== options.epoch) { + assertWorkflowEpoch(workflow, options.epoch); +} + +function assertWorkflowEpoch(workflow, expectedEpoch) { + if (workflow.epoch !== expectedEpoch) { throw workflowError( "STALE_EPOCH", - `Expected epoch ${options.epoch}, found ${workflow.epoch}.` + `Expected epoch ${expectedEpoch}, found ${workflow.epoch}.` ); } } @@ -297,7 +307,22 @@ function assertAttemptFence(workflow, target, options) { } } -function payloadCommitment(payload) { +function attemptActivationTarget(workflow, options) { + if (TERMINAL_WORKFLOW_STATUSES.has(workflow.status)) { + throw workflowError("WORKFLOW_TERMINAL", `Workflow ${workflow.id} is ${workflow.status}.`); + } + const target = targetState(workflow, options.stage, options.branchId); + if (target.state.status === "completed") { + throw workflowError("COMPLETED_STAGE_IMMUTABLE", `${target.key} is already completed.`); + } + if (target.state.status === "running") { + throw workflowError("DUPLICATE_CONTINUE", `${target.key} is already running.`); + } + assertAttemptFence(workflow, target, options); + return target; +} + +export function workflowPayloadSha256(payload) { return createHash("sha256").update(JSON.stringify(payload), "utf8").digest("hex"); } @@ -664,21 +689,23 @@ export function reserveWorkflowAttempts(cwd, workflowId, options, targets) { return { workflow, leases }; } +export function preflightWorkflowAttempt(cwd, workflowId, options) { + const workflow = readWorkflow(cwd, workflowId, { + ...(options.mode ? { mode: options.mode } : {}), + }); + if (!workflow) { + throw workflowError("WORKFLOW_NOT_FOUND", `No workflow found for ${workflowId}.`); + } + assertWorkflowEpoch(workflow, options.epoch); + attemptActivationTarget(workflow, options); + return workflow; +} + export function activateWorkflowAttempt(cwd, workflowId, options) { const currentFingerprint = getWorkingTreeFingerprint(cwd); let drifted = false; const next = mutateWorkflow(cwd, workflowId, options, (workflow, timestamp) => { - if (TERMINAL_WORKFLOW_STATUSES.has(workflow.status)) { - throw workflowError("WORKFLOW_TERMINAL", `Workflow ${workflow.id} is ${workflow.status}.`); - } - const target = targetState(workflow, options.stage, options.branchId); - if (target.state.status === "completed") { - throw workflowError("COMPLETED_STAGE_IMMUTABLE", `${target.key} is already completed.`); - } - if (target.state.status === "running") { - throw workflowError("DUPLICATE_CONTINUE", `${target.key} is already running.`); - } - assertAttemptFence(workflow, target, options); + const target = attemptActivationTarget(workflow, options); if (!sameFingerprint(workflow.fingerprint, currentFingerprint)) { drifted = true; return { @@ -753,7 +780,7 @@ function completeWorkflowStage(cwd, workflowId, options, reveal) { } if (!options.oneShot) assertAttemptFence(workflow, target, options); if (reveal) { - if (!target.state.commitment || target.state.commitment !== payloadCommitment(payload)) { + if (!target.state.commitment || target.state.commitment !== workflowPayloadSha256(payload)) { throw workflowError("COMMITMENT_MISMATCH", `${target.key} payload does not match its commitment.`); } } else if (target.state.commitment) { @@ -865,7 +892,7 @@ export function commitWorkflowStage(cwd, workflowId, options) { return { ...updateTarget(workflow, target, { ...target.state, - commitment: payloadCommitment(payload), + commitment: workflowPayloadSha256(payload), committedAt: timestamp, }), ...(options.claudeSessionId ? { claudeSessionId: options.claudeSessionId } : {}), diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index 2ac6514..31e9f8d 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -126,10 +126,37 @@ describe("StreamParser", () => { parser.feed(JSON.stringify({ type: "result", subtype: "success", + terminal_reason: "completed", + is_error: false, result: "done", }) + "\n"); assert.equal(parser.state.terminalSubtype, "success"); + assert.equal(parser.state.terminalReason, "completed"); + assert.equal(parser.state.terminalIsError, false); + }); + + it("fails parsed terminals closed when required is_error is missing or non-boolean", () => { + for (const event of [ + { type: "result", subtype: "success", terminal_reason: "completed" }, + { + type: "result", + subtype: "error_max_turns", + terminal_reason: "max_turns", + is_error: "true", + }, + ]) { + const parser = new StreamParser(); + parser.feed(`${JSON.stringify(event)}\n`); + + assert.deepEqual(validateTurnCompletion(parser.state, 0), { + status: "failed", + failure: { + kind: "claude_unknown_terminal", + terminalCategory: "CLAUDE_UNKNOWN_TERMINAL", + }, + }); + } }); it("ignores Claude synthetic error model ids", () => { @@ -1426,13 +1453,13 @@ describe("classifyClaudeFailure", () => { }); describe("runClaudeTurn", () => { - it("returns the terminal result subtype internally", async () => { + it("returns the structured terminal fields internally", async () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-subtype-")); const oldPath = process.env.PATH ?? ""; try { createFakeClaudeCommand( tmpDir, - `const out = JSON.stringify({ type: "result", subtype: "success", result: "done", session_id: "sess-subtype" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` + `const out = JSON.stringify({ type: "result", subtype: "success", terminal_reason: "completed", is_error: false, result: "done", session_id: "sess-subtype" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` ); process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; @@ -1440,6 +1467,52 @@ describe("runClaudeTurn", () => { assert.equal(result.status, "completed"); assert.equal(result.terminalSubtype, "success"); + assert.equal(result.terminalReason, "completed"); + assert.equal(result.terminalIsError, false); + } finally { + process.env.PATH = oldPath; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("keeps a successful terminal completed after a retried 429 warning", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-success-")); + const oldPath = process.env.PATH ?? ""; + try { + createFakeClaudeCommand( + tmpDir, + `process.stderr.write("HTTP 429 was retried successfully\\n");\nconst out = JSON.stringify({ type: "result", subtype: "success", terminal_reason: "completed", is_error: false, result: "done", session_id: "sess-success" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` + ); + process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; + + const result = await runClaudeTurn(process.cwd(), "prompt"); + + assert.equal(result.status, "completed"); + assert.equal(result.failure, null); + assert.equal(result.finalMessage, "done"); + } finally { + process.env.PATH = oldPath; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("prefers a structured terminal failure over a loose 429 warning", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-plugin-claude-failure-")); + const oldPath = process.env.PATH ?? ""; + try { + createFakeClaudeCommand( + tmpDir, + `process.stderr.write("HTTP 429 was retried successfully\\n");\nconst out = JSON.stringify({ type: "result", subtype: "error_max_turns", terminal_reason: "max_turns", is_error: true, result: "partial", session_id: "sess-failure" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` + ); + process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; + + const result = await runClaudeTurn(process.cwd(), "prompt"); + + assert.equal(result.status, "failed"); + assert.deepEqual(result.failure, { + kind: "claude_max_turns", + terminalCategory: "CLAUDE_MAX_TURNS", + }); } finally { process.env.PATH = oldPath; fs.rmSync(tmpDir, { recursive: true, force: true }); @@ -1452,7 +1525,7 @@ describe("runClaudeTurn", () => { try { createFakeClaudeCommand( tmpDir, - `process.stdout.write("not-json\\n");\nconst out = JSON.stringify({ type: "result", result: "done", session_id: "sess-parse" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` + `process.stdout.write("not-json\\n");\nconst out = JSON.stringify({ type: "result", subtype: "success", is_error: false, result: "done", session_id: "sess-parse" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` ); process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; @@ -1477,7 +1550,7 @@ describe("runClaudeTurn", () => { try { createFakeClaudeCommand( tmpDir, - `let prompt = "";\nprocess.stdin.setEncoding("utf8");\nprocess.stdin.on("data", (chunk) => { prompt += chunk; });\nprocess.stdin.on("end", () => {\n const result = JSON.stringify({ argv: process.argv.slice(2), prompt });\n const out = JSON.stringify({ type: "result", result, session_id: "sess-stdin" });\n process.stdout.write(out + "\\n", () => process.exit(0));\n});\n` + `let prompt = "";\nprocess.stdin.setEncoding("utf8");\nprocess.stdin.on("data", (chunk) => { prompt += chunk; });\nprocess.stdin.on("end", () => {\n const result = JSON.stringify({ argv: process.argv.slice(2), prompt });\n const out = JSON.stringify({ type: "result", subtype: "success", is_error: false, result, session_id: "sess-stdin" });\n process.stdout.write(out + "\\n", () => process.exit(0));\n});\n` ); process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; const prompt = "Привет, Claude! 🧪\n".repeat(4_000); @@ -1569,7 +1642,7 @@ describe("runClaudeTurn", () => { try { createFakeClaudeCommand( tmpDir, - `const result = JSON.stringify({ env: process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT ?? "unset", argv: process.argv.slice(2) });\nconst out = JSON.stringify({ type: "result", result, session_id: "sess-env" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` + `const result = JSON.stringify({ env: process.env.CLAUDE_CODE_FORWARD_SUBAGENT_TEXT ?? "unset", argv: process.argv.slice(2) });\nconst out = JSON.stringify({ type: "result", subtype: "success", is_error: false, result, session_id: "sess-env" });\nprocess.stdout.write(out + "\\n", () => process.exit(0));\n` ); process.env.PATH = `${tmpDir}${path.delimiter}${oldPath}`; @@ -2136,11 +2209,111 @@ describe("areModelIdsEquivalent", () => { describe("validateTurnCompletion", () => { it("returns completed for exit 0 with terminal event", () => { - const state = { receivedTerminalEvent: true, unresolvedParseErrors: 0, unknownEvents: [] }; + const state = { + receivedTerminalEvent: true, + terminalSubtype: "success", + terminalReason: "completed", + terminalIsError: false, + unresolvedParseErrors: 0, + unknownEvents: [], + }; const result = validateTurnCompletion(state, 0); assert.equal(result.status, "completed"); }); + it("maps the observed non-success result subtypes to bounded failures", () => { + for (const [terminalSubtype, terminalReason, kind, terminalCategory] of [ + ["error_during_execution", "api_error", "claude_api_error", "CLAUDE_API_ERROR"], + ["error_max_turns", "max_turns", "claude_max_turns", "CLAUDE_MAX_TURNS"], + ["error_max_budget_usd", "budget_exhausted", "claude_max_budget", "CLAUDE_MAX_BUDGET"], + [ + "error_max_structured_output_retries", + "structured_output_retry_exhausted", + "claude_structured_output_retries", + "CLAUDE_STRUCTURED_OUTPUT_RETRIES", + ], + ]) { + const result = validateTurnCompletion({ + receivedTerminalEvent: true, + terminalSubtype, + terminalReason, + terminalIsError: true, + unresolvedParseErrors: 0, + unknownEvents: [], + }, 0); + + assert.deepEqual(result, { + status: "failed", + failure: { kind, terminalCategory }, + }); + } + }); + + it("maps observed aborted success terminals to a bounded failure", () => { + const result = validateTurnCompletion({ + receivedTerminalEvent: true, + terminalSubtype: "success", + terminalReason: "aborted_streaming", + terminalIsError: false, + unresolvedParseErrors: 0, + unknownEvents: [], + }, 0); + + assert.deepEqual(result, { + status: "failed", + failure: { + kind: "claude_aborted", + terminalCategory: "CLAUDE_ABORTED", + }, + }); + }); + + it("fails closed for unknown and conflicting structured terminals", () => { + for (const state of [ + { terminalSubtype: "future_terminal", terminalReason: null, terminalIsError: true }, + { terminalSubtype: "success", terminalReason: "max_turns", terminalIsError: false }, + { terminalSubtype: "error_max_turns", terminalReason: "api_error", terminalIsError: true }, + { terminalSubtype: null, terminalReason: "completed", terminalIsError: false }, + ]) { + const result = validateTurnCompletion({ + receivedTerminalEvent: true, + unresolvedParseErrors: 0, + unknownEvents: [], + ...state, + }, 0); + + assert.deepEqual(result, { + status: "failed", + failure: { + kind: "claude_unknown_terminal", + terminalCategory: "CLAUDE_UNKNOWN_TERMINAL", + }, + }); + } + }); + + it("requires boolean is_error before interpreting any terminal subtype", () => { + for (const state of [ + { terminalSubtype: "success", terminalReason: "completed" }, + { terminalSubtype: "success", terminalReason: "completed", terminalIsError: "false" }, + { terminalSubtype: "error_max_turns", terminalReason: "max_turns" }, + { terminalSubtype: "error_max_turns", terminalReason: "max_turns", terminalIsError: 1 }, + ]) { + assert.deepEqual(validateTurnCompletion({ + receivedTerminalEvent: true, + unresolvedParseErrors: 0, + unknownEvents: [], + ...state, + }, 0), { + status: "failed", + failure: { + kind: "claude_unknown_terminal", + terminalCategory: "CLAUDE_UNKNOWN_TERMINAL", + }, + }); + } + }); + it("returns unknown for exit 0 without terminal event", () => { const state = { receivedTerminalEvent: false, unresolvedParseErrors: 0, unknownEvents: [] }; const result = validateTurnCompletion(state, 0); @@ -2149,21 +2322,38 @@ describe("validateTurnCompletion", () => { }); it("returns failed for non-zero exit code", () => { - const state = { receivedTerminalEvent: true, unresolvedParseErrors: 0, unknownEvents: [] }; + const state = { + receivedTerminalEvent: true, + terminalSubtype: "success", + unresolvedParseErrors: 0, + unknownEvents: [], + }; const result = validateTurnCompletion(state, 1); assert.equal(result.status, "failed"); assert.equal(result.exitCode, 1); }); it("returns unknown when there are unresolved parse errors", () => { - const state = { receivedTerminalEvent: true, unresolvedParseErrors: 3, unknownEvents: [] }; + const state = { + receivedTerminalEvent: true, + terminalSubtype: "success", + terminalIsError: false, + unresolvedParseErrors: 3, + unknownEvents: [], + }; const result = validateTurnCompletion(state, 0); assert.equal(result.status, "unknown"); assert.ok(result.warning.includes("3 unrecovered parse errors")); }); it("accepts terminal output with parse errors only when explicitly allowed", () => { - const state = { receivedTerminalEvent: true, unresolvedParseErrors: 1, unknownEvents: [] }; + const state = { + receivedTerminalEvent: true, + terminalSubtype: "success", + terminalIsError: false, + unresolvedParseErrors: 1, + unknownEvents: [], + }; const result = validateTurnCompletion(state, 0, { allowTerminalWithParseErrors: true, }); @@ -2181,7 +2371,13 @@ describe("validateTurnCompletion", () => { }); it("returns completed even when unknown events exist (protocol drift)", () => { - const state = { receivedTerminalEvent: true, unresolvedParseErrors: 0, unknownEvents: [{ type: "new_type", ts: 1 }] }; + const state = { + receivedTerminalEvent: true, + terminalSubtype: "success", + terminalIsError: false, + unresolvedParseErrors: 0, + unknownEvents: [{ type: "new_type", ts: 1 }], + }; const result = validateTurnCompletion(state, 0); assert.equal(result.status, "completed"); }); diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index 07f0878..bc924eb 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -143,6 +143,9 @@ async function main() { JSON.stringify({ type: "result", session_id: sessionId, + subtype: "success", + terminal_reason: "completed", + is_error: false, result: resultText, }) + "\\n" ); diff --git a/tests/e2e/peer-workflow-e2e.test.mjs b/tests/e2e/peer-workflow-e2e.test.mjs index da3e929..22284be 100644 --- a/tests/e2e/peer-workflow-e2e.test.mjs +++ b/tests/e2e/peer-workflow-e2e.test.mjs @@ -114,7 +114,7 @@ async function main() { webCitations: process.env.FAKE_CLAUDE_SPARSE === "1" ? [] : ["https://example.test/primary"], }; const resultLine = () => JSON.stringify({ - type: "result", session_id: sessionId, subtype: "success", + type: "result", session_id: sessionId, subtype: "success", is_error: false, structured_output: payload, result: JSON.stringify(payload), model: "claude-opus-5", modelUsage: { "claude-opus-5": { inputTokens: 1, outputTokens: 1, contextWindow: 1000000 } }, @@ -509,24 +509,25 @@ test("peer workflow acceptance covers aggregate surfaces, retry, lifecycle, and const lifecycle = createPeer(testEnv, "SessionEnd path."); const startedAt = new Date().toISOString(); + const lifecycleWorkflow = readWorkflow(testEnv, lifecycle.workflow.id); writeWorkflow(testEnv, { - ...lifecycle.workflow, + ...lifecycleWorkflow, status: "running", phase: "memo", - revision: lifecycle.workflow.revision + 1, + revision: lifecycleWorkflow.revision + 1, startedAt, updatedAt: startedAt, branches: { - ...lifecycle.workflow.branches, + ...lifecycleWorkflow.branches, codex: { - ...lifecycle.workflow.branches.codex, + ...lifecycleWorkflow.branches.codex, status: "running", stage: "memo", attempts: 1, startedAt, - startFingerprint: lifecycle.workflow.fingerprint, + startFingerprint: lifecycleWorkflow.fingerprint, attemptReservation: { - epoch: lifecycle.workflow.epoch, + epoch: lifecycleWorkflow.epoch, leaseDigest: createHash("sha256").update("e2e-attempt").digest("hex"), reservedAt: startedAt, }, diff --git a/tests/fs.test.mjs b/tests/fs.test.mjs index c2c1d1c..3501a2a 100644 --- a/tests/fs.test.mjs +++ b/tests/fs.test.mjs @@ -4,12 +4,27 @@ */ import { describe, it } from "node:test"; import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; import { isProbablyText, } from "../scripts/lib/fs.mjs"; import { samePath } from "../scripts/lib/codex-paths.mjs"; +const readStdinProbe = ` + await new Promise((resolve) => setTimeout(resolve, Number(process.argv[1] || 0))); + if (process.argv[2] === "nonblocking") void process.stdin.isTTY; + const { readStdinIfPiped } = await import(${JSON.stringify( + new URL("../scripts/lib/fs.mjs", import.meta.url).href + )}); + if (typeof process.send === "function") { + process.send("ready"); + await new Promise((resolve) => process.once("message", resolve)); + process.send("reading"); + } + process.stdout.end(await readStdinIfPiped(), () => process.disconnect?.()); +`; + // --------------------------------------------------------------------------- // isProbablyText // --------------------------------------------------------------------------- @@ -49,6 +64,54 @@ describe("isProbablyText", () => { }); }); +describe("readStdinIfPiped", () => { + it("reads a delayed 24 KiB nonblocking pipe", async () => { + const first = "a".repeat(8 * 1024); + const second = "b".repeat(16 * 1024); + const child = spawn( + process.execPath, + ["--input-type=module", "-e", readStdinProbe, "150", "nonblocking"], + { stdio: ["pipe", "pipe", "pipe", "ipc"] } + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.stdin.on("error", () => {}); + + const result = await new Promise((resolve) => { + child.on("message", (message) => { + if (message === "ready") { + child.stdin.write(first, () => child.send("go")); + } else if (message === "reading") { + setTimeout(() => child.stdin.end(second), 100); + } + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 5_000); + child.once("close", (status, signal) => { + clearTimeout(timer); + resolve({ status, signal }); + }); + }); + + assert.equal(result.status, 0, stderr || `terminated by ${result.signal}`); + assert.equal(stdout, first + second); + }); + + it("returns promptly when stdin is ignored", () => { + const result = spawnSync( + process.execPath, + ["--input-type=module", "-e", readStdinProbe], + { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", timeout: 2_000 } + ); + + assert.equal(result.status, 0, result.error?.message || result.stderr); + assert.equal(result.stdout, ""); + }); +}); + describe("samePath", () => { it("matches identical resolved paths", () => { assert.equal(samePath("/tmp/example", "/tmp/example"), true); diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index b9c8241..edbdb22 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -59,6 +59,8 @@ if (process.env.CLAUDE_ARGS_FILE) { process.stdout.write(JSON.stringify({ type: "result", session_id: "hook-session-result", + subtype: "success", + is_error: false, result: "" }) + "\\n"); process.exit(0); @@ -67,6 +69,8 @@ if (process.env.CLAUDE_ARGS_FILE) { process.stdout.write(JSON.stringify({ type: "result", session_id: "hook-session-result", + subtype: "success", + is_error: false, result: "BLOCK: fix the failing regression" }) + "\\n"); process.exit(0); @@ -75,6 +79,8 @@ if (process.env.CLAUDE_ARGS_FILE) { process.stdout.write(JSON.stringify({ type: "result", session_id: "hook-session-result", + subtype: "success", + is_error: false, result: "Review complete.\\nBLOCK: fix the failing regression" }) + "\\n"); process.exit(0); @@ -93,6 +99,8 @@ if (process.env.CLAUDE_ARGS_FILE) { process.stdout.write(JSON.stringify({ type: "result", session_id: "hook-session-result", + subtype: "success", + is_error: false, result: "ALLOW: hook ok" }) + "\\n"); process.exit(0); @@ -101,6 +109,8 @@ if (process.env.CLAUDE_ARGS_FILE) { process.stdout.write(JSON.stringify({ type: "result", session_id: "hook-session-result", + subtype: "success", + is_error: false, result: "MAYBE: hook unsure" }) + "\\n"); process.exit(0); @@ -121,6 +131,8 @@ if (process.env.CLAUDE_ARGS_FILE) { process.stdout.write(JSON.stringify({ type: "result", session_id: "hook-session-result", + subtype: "success", + is_error: false, result: "ALLOW: hook ok" }) + "\\n"); process.exit(0); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 2c5309c..5b16fcd 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -73,6 +73,14 @@ function sanitize(value) { } async function main() { + if (process.env.CLAUDE_CHILD_PID_FILE) { + require("node:fs").writeFileSync( + process.env.CLAUDE_CHILD_PID_FILE, + String(process.pid), + "utf8" + ); + } + if (process.env.CLAUDE_INVOCATION_LOG) { require("node:fs").appendFileSync( process.env.CLAUDE_INVOCATION_LOG, @@ -133,11 +141,21 @@ async function main() { const emitModelFallback = (!terminalModelFallback && /\\bmodel-fallback\\b/.test(prompt)) || process.env.CLAUDE_FAKE_MODEL_FALLBACK === "1"; - const resultText = \`completed:\${prompt}\`; + const resultText = process.env.CLAUDE_FAKE_RESULT_TEXT || \`completed:\${prompt}\`; + const terminalSubtype = process.env.CLAUDE_FAKE_TERMINAL_SUBTYPE || "success"; + const terminalReason = process.env.CLAUDE_FAKE_TERMINAL_REASON || "completed"; + const terminalIsError = process.env.CLAUDE_FAKE_TERMINAL_IS_ERROR === "1" + ? true + : process.env.CLAUDE_FAKE_TERMINAL_IS_ERROR === "0" + ? false + : terminalSubtype !== "success"; + if (process.env.CLAUDE_FAKE_STDERR) { + process.stderr.write(process.env.CLAUDE_FAKE_STDERR + "\\n"); + } const structuredResult = jsonSchema ? { verdict: "approve", - summary: "Structured output path works.", + summary: process.env.CLAUDE_FAKE_REVIEW_SUMMARY || "Structured output path works.", findings: [], next_steps: [], } @@ -243,7 +261,13 @@ async function main() { JSON.stringify({ type: "result", session_id: sessionId, - result: structuredResult ? "" : resultText, + subtype: terminalSubtype, + terminal_reason: terminalReason, + is_error: terminalIsError, + result: + structuredResult && process.env.CLAUDE_FAKE_FORCE_RESULT_TEXT !== "1" + ? "" + : resultText, ...(terminalModel || terminalModelFallback || emitModelFallback ? { model: terminalModel || "claude-sonnet-5" } : {}), @@ -1806,6 +1830,28 @@ describe("claude-companion integration", () => { } }); + it("reaps its Claude child after a foreground task", () => { + const testEnv = createTestEnvironment(); + + try { + const childPidFile = path.join(testEnv.rootDir, "claude-child.pid"); + runCompanion( + ["task", "--cwd", testEnv.workspaceDir, "--quiet-progress", "child-reap delay=20"], + { + env: { + ...testEnv.env, + CLAUDE_CHILD_PID_FILE: childPidFile, + }, + } + ); + + const childPid = Number(fs.readFileSync(childPidFile, "utf8")); + assert.throws(() => process.kill(childPid, 0), { code: "ESRCH" }); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("reports task model fallbacks in JSON payloads", () => { const testEnv = createTestEnvironment(); @@ -1896,8 +1942,12 @@ describe("claude-companion integration", () => { } }); - it("does not classify completed output that mentions rate limiting as a Claude limit failure", () => { + it("renders completed output after Claude retries a 429 warning", () => { const testEnv = createTestEnvironment(); + const env = { + ...testEnv.env, + CLAUDE_FAKE_STDERR: "HTTP 429 was retried successfully", + }; try { const jsonPayload = runCompanionJson( @@ -1909,7 +1959,7 @@ describe("claude-companion integration", () => { "--quiet-progress", "document rate limiting and 429 handling delay=20", ], - { env: testEnv.env } + { env } ); assert.equal(jsonPayload.status, "completed"); @@ -1924,7 +1974,7 @@ describe("claude-companion integration", () => { "--quiet-progress", "document rate limiting and 429 handling delay=20", ], - { env: testEnv.env } + { env } ); assert.equal(textResult.status, 0); assert.match(textResult.stdout, /completed:document rate limiting and 429 handling/); @@ -1998,6 +2048,183 @@ describe("claude-companion integration", () => { } }); + it("fails an exit-zero task on a known non-success terminal without rendering partial output", () => { + const testEnv = createTestEnvironment(); + const marker = "TASK_PARTIAL_PROVIDER_MARKER_MUST_NOT_RENDER"; + + try { + const result = runCompanionExpectFailure( + [ + "task", + "--cwd", + testEnv.workspaceDir, + "--json", + "--quiet-progress", + "known terminal delay=20", + ], + { + env: { + ...testEnv.env, + CLAUDE_FAKE_TERMINAL_SUBTYPE: "error_max_turns", + CLAUDE_FAKE_TERMINAL_REASON: "max_turns", + CLAUDE_FAKE_TERMINAL_IS_ERROR: "1", + CLAUDE_FAKE_RESULT_TEXT: marker, + }, + } + ); + const payload = JSON.parse(result.stdout); + + assert.equal(payload.status, "failed"); + assert.equal(payload.failure.kind, "claude_max_turns"); + assert.equal(payload.failure.terminalCategory, "CLAUDE_MAX_TURNS"); + for (const field of [ + "receivedTerminalEvent", + "terminalSubtype", + "terminalReason", + "terminalIsError", + ]) { + assert.equal(Object.hasOwn(payload, field), false); + } + assert.equal(payload.rawOutput, marker); + + const [job] = listStoredJobs(testEnv); + assert.equal(job.status, "failed"); + assert.equal(job.summary, "Claude Code turn failed: CLAUDE_MAX_TURNS."); + assert.equal(job.rendered, "Claude Code turn failed: CLAUDE_MAX_TURNS.\n"); + for (const field of [ + "receivedTerminalEvent", + "terminalSubtype", + "terminalReason", + "terminalIsError", + ]) { + assert.equal(Object.hasOwn(job.result, field), false); + } + assert.doesNotMatch(job.summary, new RegExp(marker)); + assert.doesNotMatch(job.rendered, new RegExp(marker)); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + + it("fails standard and adversarial reviews on unknown/conflicting exit-zero terminals", () => { + for (const testCase of [ + { + command: "review", + subtype: "future_terminal", + reason: "provider_reason_MUST_NOT_PERSIST", + isError: "1", + markerEnv: "CLAUDE_FAKE_RESULT_TEXT", + marker: "STANDARD_PARTIAL_PROVIDER_MARKER_MUST_NOT_RENDER", + }, + { + command: "adversarial-review", + subtype: "success", + reason: "max_turns", + isError: "0", + markerEnv: "CLAUDE_FAKE_REVIEW_SUMMARY", + marker: "ADVERSARIAL_PARTIAL_PROVIDER_MARKER_MUST_NOT_RENDER", + }, + ]) { + const testEnv = createTestEnvironment(); + try { + setupGitWorkspace(testEnv.workspaceDir); + seedWorkingTreeDiff(testEnv.workspaceDir); + const result = runCompanionExpectFailure( + [testCase.command, "--cwd", testEnv.workspaceDir, "--scope", "working-tree", "--json"], + { + env: { + ...testEnv.env, + CLAUDE_FAKE_TERMINAL_SUBTYPE: testCase.subtype, + CLAUDE_FAKE_TERMINAL_REASON: testCase.reason, + CLAUDE_FAKE_TERMINAL_IS_ERROR: testCase.isError, + [testCase.markerEnv]: testCase.marker, + }, + } + ); + const payload = JSON.parse(result.stdout); + + assert.equal(payload.codex.status, "failed", testCase.command); + assert.equal(payload.codex.failure.kind, "claude_unknown_terminal", testCase.command); + assert.equal( + payload.codex.failure.terminalCategory, + "CLAUDE_UNKNOWN_TERMINAL", + testCase.command + ); + for (const field of [ + "receivedTerminalEvent", + "terminalSubtype", + "terminalReason", + "terminalIsError", + ]) { + assert.equal(Object.hasOwn(payload.codex, field), false, testCase.command); + } + + const [job] = listStoredJobs(testEnv); + assert.equal(job.status, "failed", testCase.command); + assert.equal(job.summary, "Claude Code turn failed: CLAUDE_UNKNOWN_TERMINAL."); + assert.match(job.rendered, /CLAUDE_UNKNOWN_TERMINAL/); + assert.doesNotMatch(job.summary, new RegExp(testCase.marker)); + assert.doesNotMatch(job.rendered, new RegExp(testCase.marker)); + assert.doesNotMatch(job.rendered, /Verdict: approve/); + assert.doesNotMatch(job.summary, /provider_reason_MUST_NOT_PERSIST/); + assert.doesNotMatch(job.rendered, /provider_reason_MUST_NOT_PERSIST/); + assert.doesNotMatch(JSON.stringify(job), /provider_reason_MUST_NOT_PERSIST/); + assert.doesNotMatch(JSON.stringify(job), /future_terminal/); + assert.doesNotMatch( + fs.readFileSync(path.join(stateDirFor(testEnv), "jobs", `${job.id}.log`), "utf8"), + /provider_reason_MUST_NOT_PERSIST/ + ); + } finally { + cleanupTestEnvironment(testEnv); + } + } + }); + + it("bounds auth and rate-limit rendering for exit-zero structured review failures", () => { + for (const testCase of [ + { command: "review", signal: "auth", kind: "claude_auth" }, + { command: "review", signal: "rate", kind: "claude_rate_limit" }, + { command: "adversarial-review", signal: "auth", kind: "claude_auth" }, + { command: "adversarial-review", signal: "rate", kind: "claude_rate_limit" }, + ]) { + const testEnv = createTestEnvironment(); + const marker = `${testCase.command}_${testCase.signal}_PARTIAL_MUST_NOT_RENDER`; + const signalText = testCase.signal === "auth" + ? "Not logged in. Run claude auth login to continue." + : "You've hit your session limit · resets 4:50pm (Europe/Moscow)"; + try { + setupGitWorkspace(testEnv.workspaceDir); + seedWorkingTreeDiff(testEnv.workspaceDir); + const result = runCompanionExpectFailure( + [testCase.command, "--cwd", testEnv.workspaceDir, "--scope", "working-tree", "--json"], + { + env: { + ...testEnv.env, + CLAUDE_FAKE_TERMINAL_SUBTYPE: "error_max_turns", + CLAUDE_FAKE_TERMINAL_REASON: "max_turns", + CLAUDE_FAKE_TERMINAL_IS_ERROR: "1", + CLAUDE_FAKE_TERMINAL_MODEL: "", + CLAUDE_FAKE_FORCE_RESULT_TEXT: "1", + CLAUDE_FAKE_RESULT_TEXT: `${signalText} ${marker}`, + CLAUDE_FAKE_REVIEW_SUMMARY: marker, + }, + } + ); + const payload = JSON.parse(result.stdout); + assert.equal(payload.codex.status, "failed", `${testCase.command}/${testCase.signal}`); + assert.equal(payload.codex.failure.kind, testCase.kind, `${testCase.command}/${testCase.signal}`); + + const [job] = listStoredJobs(testEnv); + assert.equal(job.status, "failed", `${testCase.command}/${testCase.signal}`); + assert.doesNotMatch(job.rendered, new RegExp(marker)); + assert.doesNotMatch(job.rendered, /Verdict: approve/); + assert.doesNotMatch(job.summary, new RegExp(marker)); + } finally { + cleanupTestEnvironment(testEnv); + } + } + }); + it("returns stable list-tools diagnostics without raw warning text for a read-only task", () => { const testEnv = createTestEnvironment(); diff --git a/tests/mcp-capabilities.test.mjs b/tests/mcp-capabilities.test.mjs index 55e9ddf..84e205c 100644 --- a/tests/mcp-capabilities.test.mjs +++ b/tests/mcp-capabilities.test.mjs @@ -384,6 +384,83 @@ describe("MCP capability discovery", () => { }); }); + it("keeps the audited registry immutable while preserving exact default and injected eligibility", async () => { + assert.equal(Object.isFrozen(mcp.AUDITED_ANNOTATIONLESS_READ_ONLY_TOOLS), true); + assert.deepEqual([...mcp.AUDITED_ANNOTATIONLESS_READ_ONLY_TOOLS], [ + "mcp__context7__query-docs", + "mcp__context7__resolve-library-id", + "mcp__brave-search__brave_web_search", + "mcp__brave-search__brave_llm_context", + ]); + const mutable = /** @type {{add(value: string): unknown, delete(value: string): unknown, clear(): unknown}} */ ( + /** @type {unknown} */ (mcp.AUDITED_ANNOTATIONLESS_READ_ONLY_TOOLS) + ); + for (const mutate of [ + () => mutable.add("mcp__context7__unlisted"), + () => mutable.delete("mcp__context7__query-docs"), + () => mutable.clear(), + ]) { + assert.throws(mutate, TypeError); + } + await withTempHome(async ({ homeDir, cwd }) => { + const serverPath = writeStdioServer(homeDir, { + initialize: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "context7", version: "1" }, + }, + "tools/list": { + tools: [ + { + name: "query-docs", + description: "Query documentation", + annotations: { destructiveHint: true }, + }, + { name: "unlisted", description: "Unlisted documentation" }, + ], + }, + }); + fs.writeFileSync( + path.join(homeDir, ".claude.json"), + JSON.stringify({ + mcpServers: { context7: { command: process.execPath, args: [serverPath] } }, + }), + "utf8" + ); + const discovery = mcp.collectConfiguredMcpServers(cwd, { homeDir }); + + const defaultResult = await mcp.probeMcpCapabilities(discovery); + assert.deepEqual(defaultResult.catalog.map(({ toolId, safety }) => ({ toolId, safety })), [ + { + toolId: "mcp__context7__query-docs", + safety: { eligible: false, decision: "blocked", reason: "destructive_annotation" }, + }, + { + toolId: "mcp__context7__unlisted", + safety: { eligible: false, decision: "blocked", reason: "read_only_unverified" }, + }, + ]); + + const injectedResult = await mcp.probeMcpCapabilities(discovery, { + auditedTools: new Set(["mcp__context7__unlisted"]), + }); + assert.deepEqual(injectedResult.catalog.map(({ toolId, safety }) => ({ toolId, safety })), [ + { + toolId: "mcp__context7__query-docs", + safety: { eligible: false, decision: "blocked", reason: "destructive_annotation" }, + }, + { + toolId: "mcp__context7__unlisted", + safety: { + eligible: true, + decision: "eligible", + reason: "audited_read_only_registry", + }, + }, + ]); + }); + }); + it("allows only the audited Brave web tools and keeps the destructive veto first", async () => { assert.equal(Object.isFrozen(mcp.BRAVE_WEB_EVIDENCE_TOOLS), true); assert.deepEqual([...mcp.BRAVE_WEB_EVIDENCE_TOOLS], [ diff --git a/tests/peer-companion.test.mjs b/tests/peer-companion.test.mjs index 0261511..e50fa76 100644 --- a/tests/peer-companion.test.mjs +++ b/tests/peer-companion.test.mjs @@ -90,6 +90,9 @@ async function stdin() { async function main() { if (args[0] === "--version") return void process.stdout.write("2.1.90 (Claude Code)\\n"); if (args[0] === "auth" && args[1] === "status") return void process.stdout.write("authenticated\\n"); + if (process.env.FAKE_CLAUDE_TURN_LOG) { + fs.appendFileSync(process.env.FAKE_CLAUDE_TURN_LOG, "turn\\n"); + } const prompt = await stdin(); const resumed = value("--resume"); const critique = prompt.includes("Critique both frozen memos"); @@ -157,7 +160,7 @@ async function main() { content: process.env.FAKE_CLAUDE_EMPTY_CRITIQUE === "1" ? {} : { - critique: "Compare the frozen memos.", + critique: process.env.FAKE_CLAUDE_CRITIQUE_MARKER || "Compare the frozen memos.", agreements: [], disagreements: [], corrections: [], @@ -182,6 +185,9 @@ async function main() { }, ...citations, }; + if (process.env.FAKE_CLAUDE_STDERR) { + process.stderr.write(process.env.FAKE_CLAUDE_STDERR + "\\n"); + } const emitResult = () => { if (process.env.FAKE_CLAUDE_STALE_STRUCTURED_OUTPUT === "1") { process.stdout.write(JSON.stringify({ @@ -204,10 +210,22 @@ async function main() { ? {} : { structured_output: payload }), subtype: process.env.FAKE_CLAUDE_TERMINAL_SUBTYPE || "success", - result: process.env.FAKE_CLAUDE_UNSTRUCTURED === "1" - ? "not structured JSON" - : JSON.stringify(payload), - model: process.env.FAKE_CLAUDE_FALLBACK === "1" ? "claude-opus-5" : "claude-fable-5-1", + terminal_reason: process.env.FAKE_CLAUDE_TERMINAL_REASON || "completed", + is_error: process.env.FAKE_CLAUDE_TERMINAL_IS_ERROR === "1" + ? true + : process.env.FAKE_CLAUDE_TERMINAL_IS_ERROR === "0" + ? false + : (process.env.FAKE_CLAUDE_TERMINAL_SUBTYPE || "success") !== "success", + result: process.env.FAKE_CLAUDE_FAILURE_SIGNAL === "auth" + ? "Not logged in. Run claude auth login to continue." + : process.env.FAKE_CLAUDE_FAILURE_SIGNAL === "rate" + ? "You've hit your session limit · resets 4:50pm (Europe/Moscow)" + : process.env.FAKE_CLAUDE_UNSTRUCTURED === "1" + ? "not structured JSON" + : JSON.stringify(payload), + model: process.env.FAKE_CLAUDE_FAILURE_SIGNAL + ? "" + : process.env.FAKE_CLAUDE_FALLBACK === "1" ? "claude-opus-5" : "claude-fable-5-1", modelUsage: { "claude-fable-5-1": { inputTokens: 1, outputTokens: 1, contextWindow: 1000000 } }, }) + "\\n"); }; @@ -264,6 +282,7 @@ function createEnvironment() { workspaceDir, repoFile, claudeLog: path.join(rootDir, "claude.ndjson"), + claudeTurnLog: path.join(rootDir, "claude-turns.log"), mcpRequestLog, env: { ...process.env, @@ -274,6 +293,7 @@ function createEnvironment() { CLAUDE_COMPANION_SESSION_ID: "owner-a", FAKE_REPO_FILE: repoFile, FAKE_CLAUDE_LOG: path.join(rootDir, "claude.ndjson"), + FAKE_CLAUDE_TURN_LOG: path.join(rootDir, "claude-turns.log"), PATH: `${binDir}${path.delimiter}${process.env.PATH || ""}`, }, }; @@ -353,6 +373,29 @@ function readWorkflow(testEnv, id) { ), "utf8")); } +function writeWorkflow(testEnv, workflow) { + fs.writeFileSync( + path.join(peerStateDir(testEnv), "workflows", workflow.id + ".json"), + JSON.stringify(workflow, null, 2) + "\n", + "utf8" + ); +} + +function readPeerJobArtifacts(testEnv) { + const jobsDir = path.join(peerStateDir(testEnv), "jobs"); + if (!fs.existsSync(jobsDir)) return []; + return fs.readdirSync(jobsDir) + .filter((name) => name.endsWith(".json") || name.endsWith(".log")) + .sort() + .map((name) => [name, fs.readFileSync(path.join(jobsDir, name), "utf8")]); +} + +function countClaudeTurns(testEnv) { + if (!fs.existsSync(testEnv.claudeTurnLog)) return 0; + return fs.readFileSync(testEnv.claudeTurnLog, "utf8") + .trim().split("\n").filter(Boolean).length; +} + function readPeerJobs(testEnv, workflowId) { const jobsDir = path.join(peerStateDir(testEnv), "jobs"); return fs.readdirSync(jobsDir) @@ -414,6 +457,28 @@ function attemptInput(lease, payload) { return JSON.stringify({ lease, ...(payload === undefined ? {} : { payload }) }); } +function payloadSha256(value) { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +function workflowHeader(workflow) { + return { + id: workflow.id, + mode: workflow.mode, + revision: workflow.revision, + epoch: workflow.epoch, + status: workflow.status, + phase: workflow.phase, + briefHash: workflow.briefHash, + }; +} + +function boundedJson(result) { + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.ok(Buffer.byteLength(result.stdout, "utf8") < 2 * 1024, result.stdout.length); + return JSON.parse(result.stdout); +} + function activate(testEnv, result, stage, branch, lease) { return runJson(testEnv, [ "peer-activate-attempt", result.workflow.id, "--cwd", testEnv.workspaceDir, @@ -428,6 +493,211 @@ afterEach(() => { }); describe("peer companion with fake Claude", () => { + it("accepts a large piped memo and returns bounded peer receipts", () => { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + assert.deepEqual(created.workflow, workflowHeader(readWorkflow(testEnv, created.workflow.id))); + + const memoLease = planLease(created, "_codex_", "memo"); + const activation = run(testEnv, [ + "peer-activate-attempt", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--stage", "memo", "--branch", "codex", + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(memoLease) }); + const activated = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(boundedJson(activation), { + workflowId: created.workflow.id, + mode: "design", + revision: activated.revision, + epoch: 0, + status: "running", + phase: "memo", + target: { stage: "memo", branchId: "codex", status: "running" }, + }); + assert.equal(activation.stdout.includes(memoLease), false); + + const memoMarker = `OVERSIZED_CODEX_MEMO_${"m".repeat(24 * 1024)}`; + const memoResult = run(testEnv, [ + "peer-submit-memo", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--branch", "codex", "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(memoLease, { + content: { findings: [memoMarker] }, + repoCitations: [{ path: testEnv.repoFile, line: 1 }], + webCitations: ["https://example.test/codex"], + toolEvents: [{ tool: "repo-read" }, { tool: "web-search" }], + }) }); + let stored = readWorkflow(testEnv, created.workflow.id); + const memoReceipt = boundedJson(memoResult); + assert.deepEqual(memoReceipt, { + workflowId: created.workflow.id, + mode: "design", + revision: stored.revision, + epoch: 0, + status: "running", + phase: "memo", + target: { stage: "memo", branchId: "codex", status: "completed" }, + payloadSha256: payloadSha256(stored.branches.codex.payload), + }); + assert.equal(memoResult.stdout.includes(memoMarker), false); + assert.equal(memoResult.stdout.includes(memoLease), false); + + const claudeLease = planLease(created, "_claude_"); + const claudeMarker = `OVERSIZED_CLAUDE_MEMO_${"c".repeat(24 * 1024)}`; + const claudeResult = run(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { + input: attemptInput(claudeLease), + env: { FAKE_CLAUDE_MARKER: claudeMarker }, + }); + stored = readWorkflow(testEnv, created.workflow.id); + const claudeReceipt = boundedJson(claudeResult); + assert.deepEqual(claudeReceipt, { + workflowId: created.workflow.id, + mode: "design", + revision: stored.revision, + epoch: 0, + status: "running", + phase: "memo", + target: { stage: "memo", branchId: "claude", status: "completed" }, + payloadSha256: payloadSha256(stored.branches.claude.payload), + }); + assert.equal(claudeResult.stdout.includes(claudeMarker), false); + assert.equal(claudeResult.stdout.includes(claudeLease), false); + const memoJob = readPeerJobs(testEnv, created.workflow.id) + .find(({ workflowStage }) => workflowStage === "memo"); + assert.deepEqual(memoJob.result, claudeReceipt); + assert.equal(memoJob.rendered, `${JSON.stringify(claudeReceipt, null, 2)}\n`); + assert.ok(Buffer.byteLength(memoJob.rendered, "utf8") < 2 * 1024); + assert.equal(JSON.stringify(memoJob).includes(claudeMarker), false); + + const checkpointLease = planLease(created, "_codex_", "checkpoint"); + const checkpointActivation = run(testEnv, [ + "peer-activate-attempt", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--stage", "checkpoint", "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(checkpointLease) }); + stored = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(boundedJson(checkpointActivation), { + workflowId: created.workflow.id, + mode: "design", + revision: stored.revision, + epoch: 0, + status: "running", + phase: "checkpoint", + target: { stage: "checkpoint", status: "running" }, + }); + + const checkpointMarker = `OVERSIZED_CHECKPOINT_${"p".repeat(24 * 1024)}`; + const checkpointResult = run(testEnv, [ + "peer-checkpoint", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(checkpointLease, { + agreements: [checkpointMarker], disagreements: [], decisionsNeeded: [], + }) }); + stored = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(boundedJson(checkpointResult), { + workflowId: created.workflow.id, + mode: "design", + revision: stored.revision, + epoch: 0, + status: "awaiting_user", + phase: "checkpoint", + target: { stage: "checkpoint", status: "completed" }, + payloadSha256: payloadSha256(stored.stages.checkpoint.payload), + }); + assert.equal(checkpointResult.stdout.includes(checkpointMarker), false); + assert.equal(checkpointResult.stdout.includes(checkpointLease), false); + + const continuation = runJson(testEnv, [ + "peer-resume-plan", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--continue", "--owner-session-id", "owner-b", "--json", + ], { input: JSON.stringify({ feedback: "Keep the receipt bounded." }) }); + stored = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(continuation.workflow, workflowHeader(stored)); + assert.deepEqual(continuation.work, [ + { kind: "stage", id: "critique" }, + { kind: "stage", id: "synthesis" }, + ]); + assert.equal(JSON.stringify(continuation).includes(checkpointMarker), false); + assert.equal(JSON.stringify(continuation).includes(memoMarker), false); + + const critiqueLease = planLease(continuation, "_critique_"); + const critiqueMarker = `OVERSIZED_CLAUDE_CRITIQUE_${"q".repeat(24 * 1024)}`; + const critiqueResult = run(testEnv, [ + "peer-claude-critique", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(continuation.workflow.epoch), + ], { + input: attemptInput(critiqueLease), + env: { FAKE_CLAUDE_CRITIQUE_MARKER: critiqueMarker }, + }); + stored = readWorkflow(testEnv, created.workflow.id); + const critiqueReceipt = boundedJson(critiqueResult); + assert.deepEqual(critiqueReceipt, { + workflowId: created.workflow.id, + mode: "design", + revision: stored.revision, + epoch: 1, + status: "running", + phase: "synthesis", + target: { stage: "critique", status: "completed" }, + payloadSha256: payloadSha256(stored.stages.critique.payload), + }); + assert.equal(critiqueResult.stdout.includes(critiqueMarker), false); + assert.equal(critiqueResult.stdout.includes(critiqueLease), false); + const critiqueJob = readPeerJobs(testEnv, created.workflow.id) + .find(({ workflowStage }) => workflowStage === "critique"); + assert.deepEqual(critiqueJob.result, critiqueReceipt); + assert.equal(critiqueJob.rendered, `${JSON.stringify(critiqueReceipt, null, 2)}\n`); + assert.equal(JSON.stringify(critiqueJob).includes(critiqueMarker), false); + + const synthesisLease = planLease(continuation, "_synthesis_", "synthesis"); + const synthesisActivation = run(testEnv, [ + "peer-activate-attempt", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--stage", "synthesis", "--brief-hash", created.workflow.briefHash, + "--epoch", String(continuation.workflow.epoch), "--json", + ], { input: attemptInput(synthesisLease) }); + stored = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(boundedJson(synthesisActivation), { + workflowId: created.workflow.id, + mode: "design", + revision: stored.revision, + epoch: 1, + status: "running", + phase: "synthesis", + target: { stage: "synthesis", status: "running" }, + }); + + const finalMarker = `OVERSIZED_FINAL_RESULT_${"f".repeat(24 * 1024)}`; + const finalResult = run(testEnv, [ + "peer-final", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(continuation.workflow.epoch), "--json", + ], { input: attemptInput(synthesisLease, { recommendation: finalMarker }) }); + stored = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(boundedJson(finalResult), { + workflowId: created.workflow.id, + mode: "design", + revision: stored.revision, + epoch: 1, + status: "completed", + phase: "done", + target: { stage: "synthesis", status: "completed" }, + payloadSha256: payloadSha256(stored.stages.synthesis.payload), + }); + assert.equal(finalResult.stdout.includes(finalMarker), false); + assert.equal(finalResult.stdout.includes(synthesisLease), false); + const authoritative = runJson(testEnv, [ + "workflow-read", created.workflow.id, "--cwd", testEnv.workspaceDir, "--json", + ]); + assert.equal(authoritative.finalResult.recommendation, finalMarker); + }); + it("selects the design and research schemas for initial Claude turns", () => { const testEnv = createEnvironment(); const invocationFor = (mode) => { @@ -522,7 +792,7 @@ describe("peer companion with fake Claude", () => { const created = createPeer(testEnv); submitCodexMemo(testEnv, created); const claudeLease = planLease(created, "_claude_"); - const result = runJson(testEnv, [ + runJson(testEnv, [ "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, "--brief-hash", created.workflow.briefHash, "--epoch", String(created.workflow.epoch), "--json", @@ -531,7 +801,10 @@ describe("peer companion with fake Claude", () => { env: { FAKE_CLAUDE_NATIVE_STRUCTURED: "1", FAKE_CLAUDE_UNSTRUCTURED: "1" }, }); - assert.equal(result.memo.content.recommendation, "The repository and primary source agree."); + assert.equal( + readWorkflow(testEnv, created.workflow.id).branches.claude.payload.content.recommendation, + "The repository and primary source agree." + ); }); it("rejects a memo with JSON text but no native structured output", () => { @@ -552,22 +825,90 @@ describe("peer companion with fake Claude", () => { assert.equal(failed.stderr, "EVIDENCE_INCOMPLETE: STRUCTURED_JSON_REQUIRED\n"); }); - it("rejects a memo with a non-success terminal subtype", () => { - const testEnv = createEnvironment(); - const created = createPeer(testEnv); - submitCodexMemo(testEnv, created); - const claudeLease = planLease(created, "_claude_"); - const failed = run(testEnv, [ - "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, - "--brief-hash", created.workflow.briefHash, - "--epoch", String(created.workflow.epoch), "--json", - ], { - input: attemptInput(claudeLease), - env: { FAKE_CLAUDE_TERMINAL_SUBTYPE: "error" }, - }); + it("fails Claude memos closed with bounded terminal details and auth/rate precedence", () => { + for (const testCase of [ + { + name: "known max turns", + env: { + FAKE_CLAUDE_TERMINAL_SUBTYPE: "error_max_turns", + FAKE_CLAUDE_TERMINAL_REASON: "max_turns", + }, + code: "CLAUDE_TURN_FAILED", + detail: "CLAUDE_MAX_TURNS", + }, + { + name: "unknown terminal", + env: { + FAKE_CLAUDE_TERMINAL_SUBTYPE: "future_terminal", + FAKE_CLAUDE_TERMINAL_REASON: "provider_reason_MUST_NOT_PERSIST", + }, + code: "CLAUDE_TURN_FAILED", + detail: "CLAUDE_UNKNOWN_TERMINAL", + }, + { + name: "auth precedence over sandbox prose", + env: { + FAKE_CLAUDE_TERMINAL_SUBTYPE: "future_terminal", + FAKE_CLAUDE_TERMINAL_REASON: "provider_reason_MUST_NOT_PERSIST", + FAKE_CLAUDE_FAILURE_SIGNAL: "auth", + FAKE_CLAUDE_STDERR: "Sandbox cleanup failed after authentication", + }, + code: "CLAUDE_AUTH", + detail: null, + }, + { + name: "rate precedence over sandbox prose", + env: { + FAKE_CLAUDE_TERMINAL_SUBTYPE: "future_terminal", + FAKE_CLAUDE_TERMINAL_REASON: "provider_reason_MUST_NOT_PERSIST", + FAKE_CLAUDE_FAILURE_SIGNAL: "rate", + FAKE_CLAUDE_STDERR: "Sandbox cleanup failed after rate limiting", + }, + code: "CLAUDE_RATE_LIMIT", + detail: null, + }, + ]) { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + submitCodexMemo(testEnv, created); + const claudeLease = planLease(created, "_claude_"); + const failed = run(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { + input: attemptInput(claudeLease), + env: { + FAKE_CLAUDE_MARKER: "provider_payload_MUST_NOT_PERSIST", + ...testCase.env, + }, + }); - assert.notEqual(failed.status, 0); - assert.equal(failed.stderr, "EVIDENCE_INCOMPLETE: STRUCTURED_JSON_REQUIRED\n"); + assert.notEqual(failed.status, 0, testCase.name); + assert.equal( + failed.stderr, + `${testCase.code}${testCase.detail ? `: ${testCase.detail}` : ""}\n`, + testCase.name + ); + const stored = readWorkflow(testEnv, created.workflow.id); + assert.equal(stored.failureReason, testCase.code, testCase.name); + assert.equal(stored.failureDetail, testCase.detail, testCase.name); + assert.equal(stored.branches.claude.status, "retryable_failed", testCase.name); + assert.equal(stored.branches.claude.failureDetail, testCase.detail, testCase.name); + assert.equal(stored.branches.claude.payload, null, testCase.name); + assert.equal(stored.branches.claude.commitment ?? null, null, testCase.name); + const [failedJob] = readPeerJobs(testEnv, created.workflow.id) + .filter(({ status }) => status === "failed"); + assert.equal( + failedJob.errorMessage, + `${testCase.code}${testCase.detail ? `: ${testCase.detail}` : ""}`, + testCase.name + ); + const durable = readManagedStateText(testEnv); + assert.doesNotMatch(durable, /provider_payload_MUST_NOT_PERSIST/, testCase.name); + assert.doesNotMatch(durable, /provider_reason_MUST_NOT_PERSIST/, testCase.name); + assert.doesNotMatch(durable, /Not logged in|session limit/, testCase.name); + } }); it("rejects a success result without native output after failed native output", () => { @@ -784,6 +1125,7 @@ describe("peer companion with fake Claude", () => { "peer-resume-plan", created.workflow.id, "--cwd", testEnv.workspaceDir, "--mode", "design", "--retry", "--owner-session-id", "owner-a", "--json", ]); + assert.deepEqual(retry.workflow, workflowHeader(readWorkflow(testEnv, created.workflow.id))); assert.deepEqual(retry.work, [ { kind: "branch", id: "codex" }, { kind: "stage", id: "checkpoint" }, @@ -830,10 +1172,12 @@ describe("peer companion with fake Claude", () => { "--mode", "design", "--retry", "--owner-session-id", "owner-a", "--json", ]); + const stored = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(retry.workflow, workflowHeader(stored)); assert.equal(retry.workflow.status, "cancel_failed"); assert.deepEqual(retry.work, []); assert.deepEqual(retry.spawnPlan, []); - assert.equal(readWorkflow(testEnv, created.workflow.id).status, "cancel_failed"); + assert.equal(stored.status, "cancel_failed"); }); it("revalidates only MCP servers represented in the frozen selection", () => { @@ -971,6 +1315,110 @@ describe("peer companion with fake Claude", () => { assert.deepEqual(readWorkflow(testEnv, created.workflow.id), before); }); + it("rejects deterministic Claude attempts before creating jobs, logs, or child turns", () => { + const cases = [ + ["terminal", "WORKFLOW_TERMINAL"], + ["completed", "COMPLETED_STAGE_IMMUTABLE"], + ["running", "DUPLICATE_CONTINUE"], + ["stale epoch", "STALE_EPOCH"], + ["stale lease", "STALE_ATTEMPT"], + ["missing reservation", "STALE_ATTEMPT"], + ]; + + for (const target of ["memo", "critique"]) { + for (const [scenario, errorCode] of cases) { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + const workflow = readWorkflow(testEnv, created.workflow.id); + const branchId = target === "memo" ? "claude" : null; + const lease = branchId ? planLease(created, "_claude_") : "c".repeat(64); + const collection = branchId ? workflow.branches : workflow.stages; + const key = branchId ?? target; + const state = { + ...collection[key], + attemptReservation: { + leaseDigest: createHash("sha256").update(lease).digest("hex"), + epoch: workflow.epoch, + reservedAt: workflow.updatedAt, + previousFailureDetail: null, + }, + }; + const invocationEpoch = workflow.epoch; + let inputLease = lease; + + if (scenario === "terminal") { + workflow.status = "cancelled"; + workflow.phase = "cancelled"; + } else if (scenario === "completed") { + state.status = "completed"; + state.payload = { done: true }; + delete state.attemptReservation; + } else if (scenario === "running") { + workflow.status = "running"; + workflow.phase = target; + state.status = "running"; + } else if (scenario === "stale epoch") { + workflow.epoch += 1; + state.attemptReservation.epoch = workflow.epoch; + } else if (scenario === "stale lease") { + inputLease = "f".repeat(64); + } else { + delete state.attemptReservation; + } + collection[key] = state; + writeWorkflow(testEnv, workflow); + + const workflowFile = path.join( + peerStateDir(testEnv), "workflows", workflow.id + ".json" + ); + const beforeWorkflow = fs.readFileSync(workflowFile); + const beforeArtifacts = readPeerJobArtifacts(testEnv); + const beforeTurns = countClaudeTurns(testEnv); + const result = run(testEnv, [ + target === "memo" ? "peer-claude-turn" : "peer-claude-critique", + workflow.id, + "--cwd", testEnv.workspaceDir, + "--brief-hash", workflow.briefHash, + "--epoch", String(invocationEpoch), + "--json", + ], { input: attemptInput(inputLease) }); + const label = target + "/" + scenario; + + assert.notEqual(result.status, 0, label); + assert.match(result.stderr, new RegExp(errorCode), label); + assert.deepEqual(readPeerJobArtifacts(testEnv), beforeArtifacts, label); + assert.equal(countClaudeTurns(testEnv), beforeTurns, label); + assert.deepEqual(fs.readFileSync(workflowFile), beforeWorkflow, label); + } + } + }); + + it("keeps workspace drift in authoritative activation and records its rejected job", () => { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + const claudeLease = planLease(created, "_claude_"); + const beforeArtifacts = readPeerJobArtifacts(testEnv); + const beforeTurns = countClaudeTurns(testEnv); + fs.writeFileSync(testEnv.repoFile, "drift before activation\n", "utf8"); + + const result = run(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(claudeLease) }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /STALE_WORKSPACE/u); + assert.equal(readPeerJobArtifacts(testEnv).length, beforeArtifacts.length + 2); + assert.equal(countClaudeTurns(testEnv), beforeTurns); + const stored = readWorkflow(testEnv, created.workflow.id); + assert.equal(stored.status, "incomplete"); + assert.equal(stored.failureReason, "STALE_WORKSPACE"); + const [job] = readPeerJobs(testEnv, created.workflow.id); + assert.equal(job.status, "failed"); + assert.equal(job.errorMessage, "STALE_WORKSPACE: Workspace changed before continuation."); + }); + it("creates a frozen workflow and runs Claude with exact strict read-only tools and fallback telemetry", () => { const testEnv = createEnvironment(); const created = createPeer(testEnv); @@ -1001,18 +1449,19 @@ describe("peer companion with fake Claude", () => { env: { FAKE_CLAUDE_FALLBACK: "1", FAKE_CLAUDE_LIST_TOOLS_WARNING: "1" }, }); - assert.equal(result.status, "completed"); - assert.equal(result.branch, "claude"); - assert.equal(result.memo.model.requestedModel, "fable"); - assert.equal(result.memo.model.finalModel, "claude-opus-5"); - assert.equal(result.memo.model.fallbackModel, "opus"); - assert.equal(result.memo.model.modelFallbacks.length, 1); - assert.equal(result.memo.model.modelFallbacks[0].fromModel, "claude-fable-5-1"); - assert.deepEqual(result.memo.model.streamDiagnostics, [ + const stored = readWorkflow(testEnv, created.workflow.id); + assert.equal(result.target.status, "completed"); + assert.equal(result.target.branchId, "claude"); + assert.equal(stored.branches.claude.payload.model.requestedModel, "fable"); + assert.equal(stored.branches.claude.payload.model.finalModel, "claude-opus-5"); + assert.equal(stored.branches.claude.payload.model.fallbackModel, "opus"); + assert.equal(stored.branches.claude.payload.model.modelFallbacks.length, 1); + assert.equal(stored.branches.claude.payload.model.modelFallbacks[0].fromModel, "claude-fable-5-1"); + assert.deepEqual(stored.branches.claude.payload.model.streamDiagnostics, [ { code: "CLIENT_LIST_TOOLS_WITHOUT_TOOLS_CAPABILITY" }, ]); - assert.equal(JSON.stringify(result.memo).includes("Client.listTools()"), false); - assert.deepEqual(result.memo.toolEvents.map(({ tool }) => tool), ["Read", "WebSearch"]); + assert.equal(JSON.stringify(stored.branches.claude.payload).includes("Client.listTools()"), false); + assert.deepEqual(stored.branches.claude.payload.toolEvents.map(({ tool }) => tool), ["Read", "WebSearch"]); const invocation = JSON.parse(fs.readFileSync(testEnv.claudeLog, "utf8").trim()); const allowed = invocation.args.flatMap((value, index, args) => args[index - 1] === "--allowedTools" ? [value] : [] @@ -1047,7 +1496,7 @@ describe("peer companion with fake Claude", () => { assert.match(systemPrompt, /Never write, edit, create, or delete workspace files/); assert.ok(invocation.args.includes("--strict-mcp-config")); assert.deepEqual(Object.keys(invocation.mcpConfig.mcpServers), ["docs"]); - assert.equal(invocation.prompt.includes(created.workflow.brief), true); + assert.equal(invocation.prompt.includes(stored.brief), true); assert.equal(invocation.prompt.includes(created.workflow.briefHash), true); assert.equal(readWorkflow(testEnv, created.workflow.id).claudeSessionId, null); const [linkedJob] = readPeerJobs(testEnv, created.workflow.id); @@ -1168,10 +1617,11 @@ describe("peer companion with fake Claude", () => { { kind: "stage", id: "checkpoint" }, ]); assert.equal(retry.spawnPlan.some(({ task_name }) => task_name.includes("_checkpoint_")), true); - assert.equal(retry.workflow.currentOwnerSessionId, "owner-b"); assert.equal(retry.workflow.epoch, 1); + const retriedWorkflow = readWorkflow(testEnv, created.workflow.id); + assert.equal(retriedWorkflow.currentOwnerSessionId, "owner-b"); assert.equal( - retry.workflow.branches.claude.attemptReservation.previousFailureDetail, + retriedWorkflow.branches.claude.attemptReservation.previousFailureDetail, "DIRECT_HTTPS_CITATION_REQUIRED" ); const retryWait = runJson(testEnv, [ @@ -1275,6 +1725,118 @@ describe("peer companion with fake Claude", () => { assert.deepEqual(retry.work, [{ kind: "stage", id: "synthesis" }]); }); + it("continues without optional feedback on closed stdin but rejects malformed JSON", () => { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + submitCodexMemo(testEnv, created); + const claudeLease = planLease(created, "_claude_"); + runJson(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(claudeLease) }); + const checkpointLease = planLease(created, "_codex_", "checkpoint"); + activate(testEnv, created, "checkpoint", null, checkpointLease); + runJson(testEnv, [ + "peer-checkpoint", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(checkpointLease, { + agreements: [], disagreements: [], decisionsNeeded: [], + }) }); + + const workflowFile = path.join( + peerStateDir(testEnv), "workflows", `${created.workflow.id}.json` + ); + const before = fs.readFileSync(workflowFile); + const beforeWorkflow = JSON.parse(before.toString("utf8")); + const malformed = run(testEnv, [ + "peer-resume-plan", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--mode", "design", "--continue", "--owner-session-id", "owner-b", "--json", + ], { input: "{" }); + assert.notEqual(malformed.status, 0); + assert.match(malformed.stderr, /Continuation feedback is not valid JSON/u); + assert.deepEqual(fs.readFileSync(workflowFile), before); + + const continuation = runJson(testEnv, [ + "peer-resume-plan", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--mode", "design", "--continue", "--owner-session-id", "owner-b", "--json", + ]); + assert.equal(continuation.workflow.epoch, beforeWorkflow.epoch + 1); + const continuedWorkflow = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(continuedWorkflow.feedback, {}); + assert.equal(continuedWorkflow.currentOwnerSessionId, "owner-b"); + assert.deepEqual(continuation.work, [ + { kind: "stage", id: "critique" }, + { kind: "stage", id: "synthesis" }, + ]); + assert.equal(continuation.spawnPlan.length, 2); + + const rawLeases = [ + planLease(created, "_codex_", "memo"), + claudeLease, + checkpointLease, + planLease(continuation, "_critique_"), + planLease(continuation, "_synthesis_", "synthesis"), + ]; + for (const lease of rawLeases) { + assert.doesNotMatch(readManagedStateText(testEnv), new RegExp(lease)); + } + }); + + it("fails a conflicting Claude critique terminal closed without committing or exposing it", () => { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + submitCodexMemo(testEnv, created); + const claudeLease = planLease(created, "_claude_"); + runJson(testEnv, [ + "peer-claude-turn", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(claudeLease) }); + const checkpointLease = planLease(created, "_codex_", "checkpoint"); + activate(testEnv, created, "checkpoint", null, checkpointLease); + runJson(testEnv, [ + "peer-checkpoint", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(created.workflow.epoch), "--json", + ], { input: attemptInput(checkpointLease, { + agreements: [], disagreements: [], decisionsNeeded: [], + }) }); + const continuation = runJson(testEnv, [ + "peer-resume-plan", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--mode", "design", "--continue", "--owner-session-id", "owner-b", "--json", + ], { input: JSON.stringify({ feedback: "Check both memos." }) }); + const critiqueLease = planLease(continuation, "_critique_"); + const failed = run(testEnv, [ + "peer-claude-critique", created.workflow.id, "--cwd", testEnv.workspaceDir, + "--brief-hash", created.workflow.briefHash, + "--epoch", String(continuation.workflow.epoch), "--json", + ], { + input: attemptInput(critiqueLease), + env: { + FAKE_CLAUDE_MARKER: "critique_provider_payload_MUST_NOT_PERSIST", + FAKE_CLAUDE_TERMINAL_SUBTYPE: "success", + FAKE_CLAUDE_TERMINAL_REASON: "max_turns", + FAKE_CLAUDE_TERMINAL_IS_ERROR: "0", + }, + }); + + assert.notEqual(failed.status, 0); + assert.equal(failed.stderr, "CLAUDE_TURN_FAILED: CLAUDE_UNKNOWN_TERMINAL\n"); + const stored = readWorkflow(testEnv, created.workflow.id); + assert.equal(stored.failureReason, "CLAUDE_TURN_FAILED"); + assert.equal(stored.failureDetail, "CLAUDE_UNKNOWN_TERMINAL"); + assert.equal(stored.stages.critique.status, "retryable_failed"); + assert.equal(stored.stages.critique.failureDetail, "CLAUDE_UNKNOWN_TERMINAL"); + assert.equal(stored.critique, null); + assert.equal(stored.stages.synthesis.status, "pending"); + assert.doesNotMatch( + readManagedStateText(testEnv), + /critique_provider_payload_MUST_NOT_PERSIST|max_turns/ + ); + }); + it("rejects a critique with JSON text but no native structured output", () => { const testEnv = createEnvironment(); const created = createPeer(testEnv); diff --git a/tests/peer-orchestration.test.mjs b/tests/peer-orchestration.test.mjs index 28bfd51..5a64547 100644 --- a/tests/peer-orchestration.test.mjs +++ b/tests/peer-orchestration.test.mjs @@ -3,12 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { describe, it } from "node:test"; import { + buildContinuationAgentPlan, buildInitialAgentPlan, buildPeerCheckpoint, buildPeerWaitView, @@ -17,6 +19,36 @@ import { validatePeerMemo, } from "../scripts/lib/peer-orchestration.mjs"; +const STDIN_COMMANDS = new Set([ + "peer-activate-attempt", + "peer-submit-memo", + "peer-claude-turn", + "peer-checkpoint", + "peer-claude-critique", + "peer-final", +]); + +function commandName(line) { + return [...STDIN_COMMANDS].find((command) => line.includes(` ${command} `)) ?? null; +} + +function extractHeredoc(message, marker) { + const lines = message.split("\n"); + const start = lines.findIndex((line) => line.includes(`<<'${marker}'`)); + const end = lines.indexOf(marker, start + 1); + assert.ok(start >= 0 && end > start, `missing ${marker} heredoc`); + return lines.slice(start, end + 1).join("\n"); +} + +function extractBase64Recipe(message, marker) { + const lines = message.split("\n"); + const body = lines.findIndex((line) => line.includes(`<<'${marker}'`)); + const start = lines.lastIndexOf("(", body); + const end = lines.indexOf(")", body + 1); + assert.ok(start >= 0 && end > body, `missing ${marker} base64 recipe`); + return lines.slice(start, end + 1).join("\n"); +} + function assertOneShotCheckpointInstructions(message) { assert.match( message, @@ -150,6 +182,125 @@ describe("fake built-in agent orchestration", () => { } }); + it("generates complete stdin recipes for every initial, retry, and continuation worker", () => { + const workflow = { + id: "workflow-recipes", + mode: "design", + epoch: 4, + workspaceRoot: "/workspace/repo", + brief: "Compare queues and streams.", + briefHash: "a".repeat(64), + }; + const options = { + companionPath: "/plugin/scripts/claude-companion.mjs", + leases: { + "branch:codex": "c".repeat(64), + "branch:claude": "d".repeat(64), + "stage:checkpoint": "e".repeat(64), + "stage:critique": "f".repeat(64), + "stage:synthesis": "9".repeat(64), + }, + }; + const initial = buildInitialAgentPlan(workflow, options); + const retry = buildRetryAgentPlan(workflow, [{ stage: "checkpoint" }], options); + const continuation = buildContinuationAgentPlan(workflow, options); + const messages = [...initial, ...retry, ...continuation].map(({ message }) => message); + + for (const message of messages) { + for (const line of message.split("\n").filter((candidate) => candidate.startsWith("node "))) { + if (commandName(line)) { + assert.match(line, /(?:<<'CC_PEER_[A-Z_]+?'|< "\$CC_PEER_INPUT")/u, line); + } + assert.doesNotMatch(line, /[cdef9]{64}|--lease/u); + } + } + + for (const [message, marker] of [ + [initial[0].message, "MEMO"], + [initial[0].message, "CHECKPOINT"], + [retry[0].message, "CHECKPOINT"], + [continuation[1].message, "FINAL"], + ]) { + assert.match(message, new RegExp(`CC_PEER_${marker}_SUBMISSION`, "u")); + assert.match(message, new RegExp(`CC_PEER_${marker}_SUBMISSION_B64`, "u")); + assert.match(message, /wrapped base64/iu); + } + }); + + it("executes generated heredoc and wrapped-base64 recipes with parsed stdin", { + skip: process.platform === "win32", + }, () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cc-peer-recipes-")); + try { + const companionPath = path.join(root, "fake companion.mjs"); + const capturePath = path.join(root, "captured.ndjson"); + fs.writeFileSync(companionPath, `import fs from "node:fs"; +const input = JSON.parse(fs.readFileSync(0, "utf8")); +fs.appendFileSync(process.env.CC_PEER_CAPTURE, JSON.stringify({ + argv: process.argv.slice(2), input, +}) + "\\n"); +`, "utf8"); + const workflow = { + id: "workflow-recipes", + mode: "research", + epoch: 7, + workspaceRoot: path.join(root, "workspace with spaces"), + brief: "Inspect recipe transport.", + briefHash: "a".repeat(64), + }; + const leases = { + "branch:codex": "c".repeat(64), + "branch:claude": "d".repeat(64), + "stage:checkpoint": "e".repeat(64), + "stage:critique": "f".repeat(64), + "stage:synthesis": "9".repeat(64), + }; + const initial = buildInitialAgentPlan(workflow, { companionPath, leases }); + const continuation = buildContinuationAgentPlan(workflow, { companionPath, leases }); + const smallPayload = { content: { finding: "quoted ' value" } }; + const largePayload = { answer: "x".repeat(24_000) }; + const largeAttempt = { + lease: leases["stage:synthesis"], + payload: largePayload, + }; + const wrapped = Buffer.from(JSON.stringify(largeAttempt)) + .toString("base64").match(/.{1,64}/gu).join("\n"); + const recipes = [ + extractHeredoc(initial[0].message, "CC_PEER_MEMO_ACTIVATION"), + extractHeredoc(initial[1].message, "CC_PEER_CLAUDE_ATTEMPT"), + extractHeredoc(initial[0].message, "CC_PEER_MEMO_SUBMISSION") + .replace("CC_PEER_PAYLOAD_JSON", JSON.stringify(smallPayload)), + extractBase64Recipe(continuation[1].message, "CC_PEER_FINAL_SUBMISSION_B64") + .replace("CC_PEER_WRAPPED_BASE64", wrapped), + ]; + + for (const recipe of recipes) { + assert.doesNotMatch(recipe, /CC_PEER_(?:PAYLOAD_JSON|WRAPPED_BASE64)/u); + assert.equal(recipe.split("\n").every((line) => line.length < 512), true); + const result = spawnSync("sh", ["-c", recipe], { + cwd: root, + env: { ...process.env, CC_PEER_CAPTURE: capturePath }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + } + + const captured = fs.readFileSync(capturePath, "utf8").trim() + .split("\n").map((line) => JSON.parse(line)); + assert.deepEqual(captured.map(({ input }) => input), [ + { lease: leases["branch:codex"] }, + { lease: leases["branch:claude"] }, + { lease: leases["branch:codex"], payload: smallPayload }, + largeAttempt, + ]); + assert.equal(captured.every(({ argv }) => + !argv.some((value) => Object.values(leases).includes(value))), true); + assert.equal(wrapped.split("\n").every((line) => line.length <= 64), true); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it("generates a one-shot retry checkpoint worker that stops on terminal state", () => { const [worker] = buildRetryAgentPlan({ id: "workflow-retry", diff --git a/tests/peer-skills-contract.test.mjs b/tests/peer-skills-contract.test.mjs index 6ad7068..9996bf2 100644 --- a/tests/peer-skills-contract.test.mjs +++ b/tests/peer-skills-contract.test.mjs @@ -66,7 +66,7 @@ test("peer runtime keeps preflight live, initial children independent, and Claud "cannot read the sibling memo before submitting its own", "pure Claude forwarder", "run exactly one companion command", - "return stdout unchanged", + "return its bounded receipt stdout unchanged", "Never use shell backgrounding", "Never invoke `codex exec`", "Initial execution is always background", diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 3dd1f28..ad0018b 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -436,6 +436,19 @@ describe("renderTaskResult", () => { assert.match(output, /Not logged in/); }); + it("renders bounded structured terminal failures without partial provider text", () => { + const output = renderTaskResult({ + rawOutput: "UNTRUSTED_PARTIAL_PROVIDER_MARKER", + failure: { + kind: "claude_max_turns", + terminalCategory: "CLAUDE_MAX_TURNS", + }, + }); + + assert.equal(output, "Claude Code turn failed: CLAUDE_MAX_TURNS.\n"); + assert.doesNotMatch(output, /UNTRUSTED_PARTIAL_PROVIDER_MARKER/); + }); + it("returns default message when nothing provided", () => { const output = renderTaskResult({}); assert.ok(output.includes("did not return a final message")); diff --git a/tests/test-env-isolation.test.mjs b/tests/test-env-isolation.test.mjs index 8a603e4..e478f35 100644 --- a/tests/test-env-isolation.test.mjs +++ b/tests/test-env-isolation.test.mjs @@ -9,12 +9,15 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { it } from "node:test"; +import { fileURLToPath } from "node:url"; import { resolveStateDir, saveConfig, } from "../scripts/lib/state.mjs"; +const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url))); + it("routes state writes away from the original CODEX_HOME", () => { const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "cc-test-env-repo-")); const init = spawnSync("git", ["init", "-q"], { @@ -42,3 +45,33 @@ it("routes state writes away from the original CODEX_HOME", () => { fs.rmSync(repoDir, { recursive: true, force: true }); } }); + +it("preload lets a Claude-hosted integration task terminate and reap its Claude child", () => { + /** @type {NodeJS.ProcessEnv} */ + const env = { + ...process.env, + CLAUDECODE: "1", + CLAUDE_CODE_ENTRYPOINT: "cli", + }; + delete env.NODE_TEST_CONTEXT; + const result = spawnSync( + process.execPath, + [ + "--import", + "./tests/test-env.mjs", + "--test", + "--test-name-pattern=reaps its Claude child after a foreground task", + "tests/integration/claude-companion.test.mjs", + ], + { + cwd: PROJECT_ROOT, + env, + encoding: "utf8", + timeout: 30_000, + } + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /reaps its Claude child after a foreground task/); + assert.match(result.stdout, /pass 1/); +}); diff --git a/tests/test-env.mjs b/tests/test-env.mjs index 9edf6db..677d15b 100644 --- a/tests/test-env.mjs +++ b/tests/test-env.mjs @@ -14,6 +14,8 @@ const testCodexHome = fs.mkdtempSync( path.join(os.tmpdir(), "cc-plugin-codex-test-") ); process.env.CODEX_HOME = testCodexHome; +delete process.env.CLAUDECODE; +delete process.env.CLAUDE_CODE_ENTRYPOINT; process.once("exit", () => { fs.rmSync(testCodexHome, { recursive: true, force: true }); diff --git a/tests/workflow-companion.test.mjs b/tests/workflow-companion.test.mjs index 3730f53..3e4c07c 100644 --- a/tests/workflow-companion.test.mjs +++ b/tests/workflow-companion.test.mjs @@ -48,7 +48,7 @@ if (args[0] === "--version") { if (process.env.CLAUDE_INVOCATION_FILE) { fs.writeFileSync(process.env.CLAUDE_INVOCATION_FILE, JSON.stringify({ args, sessionId }) + "\\n"); } - process.stdout.write(JSON.stringify({ type: "result", session_id: sessionId, result: "done" }) + "\\n"); + process.stdout.write(JSON.stringify({ type: "result", session_id: sessionId, subtype: "success", terminal_reason: "completed", is_error: false, result: "done" }) + "\\n"); } `, "utf8" diff --git a/tests/workflows.test.mjs b/tests/workflows.test.mjs index 812bf1c..e10cf4d 100644 --- a/tests/workflows.test.mjs +++ b/tests/workflows.test.mjs @@ -20,6 +20,7 @@ import { listWorkflows, markWorkflowBranchFailure, markWorkflowNotification, + preflightWorkflowAttempt, readWorkflow, rebindWorkflowOwner, reserveWorkflowCancellation, @@ -787,6 +788,47 @@ describe("peer workflow store", () => { assert.equal(stored.stages.memo.status, "running"); }); + it("preflights attempts read-only without evaluating workspace drift", () => { + const repo = createRepo(); + const created = createWorkflow(repo, { + id: "workflow-attempt-preflight", + stages: ["memo"], + branches: [], + }); + const reservation = reserveWorkflowAttempts(repo, created.id, { + revision: created.revision, + epoch: created.epoch, + }, [{ stage: "memo" }]); + const options = { + stage: "memo", + epoch: reservation.workflow.epoch, + mode: reservation.workflow.mode, + lease: reservation.leases["stage:memo"], + }; + const before = fs.readFileSync(resolveWorkflowFile(repo, created.id)); + + assert.deepEqual( + preflightWorkflowAttempt(repo, created.id, options), + reservation.workflow + ); + assert.deepEqual(fs.readFileSync(resolveWorkflowFile(repo, created.id)), before); + fs.writeFileSync(path.join(repo, "tracked.txt"), "drift before activation\n", "utf8"); + assert.deepEqual( + preflightWorkflowAttempt(repo, created.id, options), + reservation.workflow + ); + assert.deepEqual(fs.readFileSync(resolveWorkflowFile(repo, created.id)), before); + + assert.equal(errorCode(() => activateWorkflowAttempt(repo, created.id, { + ...options, + revision: reservation.workflow.revision, + })), "STALE_WORKSPACE"); + const stored = readWorkflow(repo, created.id); + assert.equal(stored.status, "incomplete"); + assert.equal(stored.failureReason, "STALE_WORKSPACE"); + assert.equal(stored.revision, reservation.workflow.revision + 1); + }); + it("rejects branch activation through a stage other than the reserved stage", () => { const repo = createRepo(); const created = createWorkflow(repo, { id: "workflow-branch-stage-fence" });