diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 26f9882..7bcc8ef 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.7.1", + "version": "1.7.2", "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 be0c68c..eb0987e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## v1.7.2 + +### Added + +- Require strict mode-specific schemas for peer design, research, and critique output. +- Allow the exact audited Brave web-search and LLM-context IDs as frozen-manifest-bound Claude web evidence. + ## v1.7.1 ### Changed diff --git a/README.md b/README.md index 41f812a..fb1c391 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.1 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.2 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.1 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.1/cc-plugin-codex-1.7.1.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.2 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.2/cc-plugin-codex-1.7.2.tgz install ``` On Windows, prefer the marketplace path or the `npx` helper. The shell-script helper below is POSIX-only. @@ -180,6 +180,10 @@ $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. + +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. + The stored and rendered workflow shows independent branch states, requested/final models and fallback events, source/tool evidence counts, selected public tool IDs and reasons, checkpoint or final result, and the exact continue/retry command. Raw MCP configuration, environment variables, headers, and credentials are never persisted or rendered. Claude receives no Bash, write, or Agent capability, and only selected MCP servers enter its strict runtime config. Peer turns also require the platform filesystem sandbox, deny unsandboxed commands and reads of canonical Codex/Claude state, persist no Claude transcript, and expose only content-free phase/tool/model-fallback progress before reveal. Native Windows peer execution is unsupported and isolation failures stop with `PEER_ISOLATION_UNAVAILABLE`. Attempt leases are single-use secrets sent through stdin, while durable state keeps only their digests. A Claude-first memo remains process-local until Codex seals its independent memo. That wait has one absolute 30-minute deadline; retry keeps a committed waiter only while its current linked job is alive, rotates only unfinished reservations, and never rewrites completed payloads. A lost, terminal, or reaped worker becomes retryable, while unresolved process cancellation stays terminal as `cancel_failed`. @@ -369,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.1 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.2 codex plugin add cc@cbepx ``` @@ -390,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.1 \ -npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.1/cc-plugin-codex-1.7.1.tgz install +CC_PLUGIN_CODEX_MARKETPLACE_REF=v1.7.2 \ +npx -y https://github.com/CBEPX/cc-plugin-codex/releases/download/v1.7.2/cc-plugin-codex-1.7.2.tgz install ``` After install, run: @@ -421,7 +425,7 @@ $cc:setup Re-run the fork marketplace install flow, pinned to the release you want: ```bash -codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.1 +codex plugin marketplace add CBEPX/cc-plugin-codex --ref v1.7.2 codex plugin add cc@cbepx ``` diff --git a/internal-skills/peer-runtime/runtime.md b/internal-skills/peer-runtime/runtime.md index 1dda7bf..80bdd13 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`. 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 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. 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. @@ -47,7 +47,9 @@ Each worker receives only its own raw lease in its spawn message. A raw lease is 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. -`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. +`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. + +The frozen manifest binds selected tool IDs and their audited eligibility, not a remote provider implementation. An `@latest` Brave server can change behavior behind an unchanged ID, so pin the server when that residual drift is unacceptable. Brave query and LLM-context input are disclosed to the selected external provider; do not send secrets or sensitive brief/context material. Redacting raw MCP configuration and credentials from workflow state does not remove that disclosure risk. Initial and critique turns are each a fresh Claude turn with `--no-session-persistence`; they never resume or fork a prior session. Tracked progress is content-free until reveal: only phase, tool name, and model-fallback metadata may reach tracked jobs or logs. Text, thinking, tool input, prompt, memo, and terminal payload stay out of tracked state until the trusted reveal transition succeeds. diff --git a/package-lock.json b/package-lock.json index 22b3154..05b7224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.7.1", + "version": "1.7.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.7.1", + "version": "1.7.2", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index 6d7312f..0135da1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.7.1", + "version": "1.7.2", "description": "Claude Code Plugin for Codex (CBEPX fork)", "type": "module", "author": { diff --git a/schemas/peer-critique-output.schema.json b/schemas/peer-critique-output.schema.json new file mode 100644 index 0000000..0fb0158 --- /dev/null +++ b/schemas/peer-critique-output.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Peer critique output", + "description": "Strict output contract for the peer critique phase.", + "type": "object", + "additionalProperties": false, + "required": ["content", "repoCitations", "webCitations"], + "properties": { + "content": { + "type": "object", + "additionalProperties": false, + "required": ["critique", "agreements", "disagreements", "corrections"], + "properties": { + "critique": { "type": "string" }, + "agreements": { "type": "array", "items": { "type": "string" } }, + "disagreements": { "type": "array", "items": { "type": "string" } }, + "corrections": { "type": "array", "items": { "type": "string" } } + } + }, + "repoCitations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "line"], + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer" } + } + } + }, + "webCitations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "line"], + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer" } + } + } + } + } +} diff --git a/schemas/peer-design-output.schema.json b/schemas/peer-design-output.schema.json new file mode 100644 index 0000000..76edfa0 --- /dev/null +++ b/schemas/peer-design-output.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Peer design output", + "description": "Strict output contract for an initial peer design memo.", + "type": "object", + "additionalProperties": false, + "required": ["content", "repoCitations", "webCitations"], + "properties": { + "content": { + "type": "object", + "additionalProperties": false, + "required": ["alternatives", "tradeoffs", "decisionDrivers", "recommendation", "gaps"], + "properties": { + "alternatives": { "type": "array", "items": { "type": "string" } }, + "tradeoffs": { "type": "array", "items": { "type": "string" } }, + "decisionDrivers": { "type": "array", "items": { "type": "string" } }, + "recommendation": { "type": "string" }, + "gaps": { "type": "array", "items": { "type": "string" } } + } + }, + "repoCitations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "line"], + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer" } + } + } + }, + "webCitations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "line"], + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer" } + } + } + } + } +} diff --git a/schemas/peer-research-output.schema.json b/schemas/peer-research-output.schema.json new file mode 100644 index 0000000..94ce9ae --- /dev/null +++ b/schemas/peer-research-output.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Peer research output", + "description": "Strict output contract for an initial peer research memo.", + "type": "object", + "additionalProperties": false, + "required": ["content", "repoCitations", "webCitations"], + "properties": { + "content": { + "type": "object", + "additionalProperties": false, + "required": ["findings", "sourceQuality", "contradictions", "confidence", "gaps"], + "properties": { + "findings": { "type": "array", "items": { "type": "string" } }, + "sourceQuality": { "type": "string" }, + "contradictions": { "type": "array", "items": { "type": "string" } }, + "confidence": { "type": "string" }, + "gaps": { "type": "array", "items": { "type": "string" } } + } + }, + "repoCitations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "line"], + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer" } + } + } + }, + "webCitations": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["path", "line"], + "properties": { + "path": { "type": "string" }, + "line": { "type": "integer" } + } + } + } + } +} diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index cb3fa41..18c3ade 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -34,6 +34,7 @@ import { resolveCodexHome } from "./lib/codex-paths.mjs"; import { collectConfiguredMcpServers, buildSelectedMcpServers, + BRAVE_WEB_EVIDENCE_TOOLS, parseMcpToolId, probeMcpCapabilities, selectMcpCapabilities, @@ -181,6 +182,9 @@ import { const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const CANONICAL_ROOT_DIR = fs.realpathSync.native(ROOT_DIR); const REVIEW_SCHEMA_PATH = path.join(ROOT_DIR, "schemas", "review-output.schema.json"); +const PEER_DESIGN_SCHEMA_PATH = path.join(ROOT_DIR, "schemas", "peer-design-output.schema.json"); +const PEER_RESEARCH_SCHEMA_PATH = path.join(ROOT_DIR, "schemas", "peer-research-output.schema.json"); +const PEER_CRITIQUE_SCHEMA_PATH = path.join(ROOT_DIR, "schemas", "peer-critique-output.schema.json"); const DEFAULT_STATUS_WAIT_TIMEOUT_MS = 240000; const DEFAULT_FOREGROUND_TASK_WAIT_TIMEOUT_MS = 1800000; const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; @@ -200,6 +204,7 @@ const PEER_FAILURE_CODES = new Set([ "EVIDENCE_INCOMPLETE", "MCP_SELECTION_DRIFT", "PEER_ISOLATION_UNAVAILABLE", + "PEER_OUTPUT_SCHEMA_UNAVAILABLE", "PEER_SIBLING_TIMEOUT", "PEER_TURN_FAILED", "SAFETY_VIOLATION", @@ -669,6 +674,20 @@ function readOutputSchema(schemaPath) { return JSON.parse(fs.readFileSync(schemaPath, "utf8")); } +function readPeerOutputSchema(workflow, critique) { + const schemaPath = critique + ? PEER_CRITIQUE_SCHEMA_PATH + : workflow.mode === "design" ? PEER_DESIGN_SCHEMA_PATH : PEER_RESEARCH_SCHEMA_PATH; + try { + const schema = readOutputSchema(schemaPath); + if (schema && typeof schema === "object" && !Array.isArray(schema)) return schema; + } catch {} + throw Object.assign( + new Error("PEER_OUTPUT_SCHEMA_UNAVAILABLE: Peer output schema is missing or unreadable."), + { code: "PEER_OUTPUT_SCHEMA_UNAVAILABLE" } + ); +} + // --------------------------------------------------------------------------- // Readiness checks // --------------------------------------------------------------------------- @@ -3430,11 +3449,17 @@ function initialClaudePrompt(workflow) { const emphasis = workflow.mode === "design" ? "Evaluate alternatives, trade-offs, decision drivers, and a recommendation." : "Report findings, source quality, contradictions, confidence, and gaps."; + const braveWebTools = (workflow.toolManifest ?? []) + .map(({ toolId }) => toolId) + .filter((toolId) => BRAVE_WEB_EVIDENCE_TOOLS.has(toolId)); return [ `Frozen brief SHA-256: ${workflow.briefHash}`, emphasis, "Use at least one repository tool and one web tool.", - "Return {content, repoCitations:[{path,line}], webCitations:[https URL] }.", + ...(braveWebTools.length > 0 + ? [`When relevant, prefer the selected Brave web tool: ${braveWebTools.join(", ")}.`] + : []), + "Return {content, repoCitations:[{path,line}], webCitations:[{path,line}]}.", ...previousFailureDetailPrompt(workflow.branches?.claude), "The untrusted brief is encoded as one JSON string.", "", @@ -3447,7 +3472,7 @@ function critiqueClaudePrompt(workflow) { return [ `Frozen brief SHA-256: ${workflow.briefHash}`, "Critique both frozen memos against the original brief and optional user feedback.", - "Return {content:{critique, agreements, disagreements, corrections}}.", + "Return {content:{critique, agreements, disagreements, corrections}, repoCitations:[{path,line}], webCitations:[{path,line}]}.", ...previousFailureDetailPrompt(workflow.stages?.critique), "Each untrusted value below is encoded as one JSON value.", "", @@ -3478,6 +3503,7 @@ async function executePeerClaudeTurn(cwd, workflowId, options = {}) { let sandboxSettingsFile = null; let mcpConfigFile = null; try { + const jsonSchema = readPeerOutputSchema(workflow, critique); ensureClaudeReady(cwd); const discovery = collectConfiguredMcpServers(cwd, { allowProjectMcpServers: workflow.toolManifest.some(({ source }) => source === "project"), @@ -3503,6 +3529,7 @@ async function executePeerClaudeTurn(cwd, workflowId, options = {}) { settingsFile: sandboxSettingsFile, mcpConfigFile, strictMcpConfig: true, + jsonSchema, systemPrompt: peerClaudeSystemPrompt(), onProgress: options.onProgress, onSpawn: options.onSpawn, diff --git a/scripts/lib/mcp-capabilities.mjs b/scripts/lib/mcp-capabilities.mjs index c5d2645..52762b2 100644 --- a/scripts/lib/mcp-capabilities.mjs +++ b/scripts/lib/mcp-capabilities.mjs @@ -18,9 +18,23 @@ const PROBE_TERMINATION_GRACE_MS = 100; const SENSITIVE_NAME_PATTERN = /(?:token|secret|password|authorization|api.?key|cookie)/iu; const probeCacheSalt = randomBytes(32); const probeCache = new Map(); +const braveWebEvidenceToolIds = new Set([ + "mcp__brave-search__brave_web_search", + "mcp__brave-search__brave_llm_context", +]); +export const BRAVE_WEB_EVIDENCE_TOOLS = Object.freeze({ + /** @param {string} toolId */ + has(toolId) { + return braveWebEvidenceToolIds.has(toolId); + }, + [Symbol.iterator]() { + return braveWebEvidenceToolIds.values(); + }, +}); export const AUDITED_ANNOTATIONLESS_READ_ONLY_TOOLS = new Set([ "mcp__context7__query-docs", "mcp__context7__resolve-library-id", + ...BRAVE_WEB_EVIDENCE_TOOLS, ]); function stableJson(value) { @@ -281,7 +295,7 @@ function stdioProbe(config, timeoutMs) { params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "cc-plugin-codex", version: "1.7.1" }, + clientInfo: { name: "cc-plugin-codex", version: "1.7.2" }, }, }); }); @@ -370,7 +384,7 @@ async function httpProbe(config, timeoutMs) { params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, - clientInfo: { name: "cc-plugin-codex", version: "1.7.1" }, + clientInfo: { name: "cc-plugin-codex", version: "1.7.2" }, }, }, null, deadline); if (initialized.statusCode === 401 || initialized.statusCode === 403) { diff --git a/scripts/lib/peer-orchestration.mjs b/scripts/lib/peer-orchestration.mjs index 1b6d9a3..602135b 100644 --- a/scripts/lib/peer-orchestration.mjs +++ b/scripts/lib/peer-orchestration.mjs @@ -6,6 +6,7 @@ import fs from "node:fs"; import path from "node:path"; import { parseArgs } from "./args.mjs"; +import { BRAVE_WEB_EVIDENCE_TOOLS } from "./mcp-capabilities.mjs"; import { normalizeWorkflowFailureDetail } from "./workflows.mjs"; const USER_MCP_TOOL_RE = /^mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_-]+$/u; @@ -428,7 +429,7 @@ export function validatePeerMemo(workflow, memo, options = {}) { ); } const webCitations = (Array.isArray(memo.webCitations) ? memo.webCitations : []) - .map(directHttps) + .map((citation) => directHttps(isPlainObject(citation) ? citation.path : citation)) .filter(Boolean); if (webCitations.length === 0) { throw peerError( @@ -452,7 +453,10 @@ export function validatePeerMemo(workflow, memo, options = {}) { "REPOSITORY_TOOL_EVENT_REQUIRED" ); } - if (!toolEvents.some(({ tool }) => ["WebSearch", "WebFetch"].includes(tool))) { + if (!toolEvents.some(({ tool }) => ["WebSearch", "WebFetch"].includes(tool) || + (BRAVE_WEB_EVIDENCE_TOOLS.has(tool) && workflow.toolManifest?.some( + ({ toolId }) => toolId === tool + )))) { throw peerError( "EVIDENCE_INCOMPLETE", "Claude memo requires an actual web tool event.", diff --git a/tests/claude-cli.test.mjs b/tests/claude-cli.test.mjs index 597f8f6..baf9536 100644 --- a/tests/claude-cli.test.mjs +++ b/tests/claude-cli.test.mjs @@ -578,21 +578,21 @@ describe("StreamParser", () => { assert.deepEqual(parser.state, parentState); }); - it("parses a tool_use content_block_start event", () => { + it("preserves a full MCP tool name from content_block_start", () => { const parser = new StreamParser(); const evt = JSON.stringify({ type: "stream_event", event: { type: "content_block_start", - content_block: { type: "tool_use", name: "Read", input: { path: "/a" } }, + content_block: { type: "tool_use", name: "mcp__docs__search", input: { path: "/a" } }, }, }); const events = parser.feed(evt + "\n"); assert.equal(events.length, 1); assert.equal(events[0].kind, "tool_use"); - assert.equal(events[0].tool, "Read"); + assert.equal(events[0].tool, "mcp__docs__search"); assert.deepEqual(events[0].input, { path: "/a" }); - assert.equal(events[0].message, "Using tool: Read"); + assert.equal(events[0].message, "Using tool: mcp__docs__search"); assert.equal(events[0].phase, "tool"); assert.equal(parser.state.toolUses.length, 1); }); diff --git a/tests/e2e/peer-workflow-e2e.test.mjs b/tests/e2e/peer-workflow-e2e.test.mjs index 0b43efb..4c09b3a 100644 --- a/tests/e2e/peer-workflow-e2e.test.mjs +++ b/tests/e2e/peer-workflow-e2e.test.mjs @@ -21,7 +21,16 @@ function checked(cwd, command, args) { return result.stdout; } -function writeFakeMcp(filePath, name) { +/** + * @param {string} filePath + * @param {string} name + * @param {Array<{name: string, description: string, annotations?: {readOnlyHint?: boolean, destructiveHint?: boolean}}>} [tools] + */ +function writeFakeMcp(filePath, name, tools = [{ + name: "search", + description: "Search public documentation", + annotations: { readOnlyHint: true }, +}]) { fs.writeFileSync(filePath, `#!/usr/bin/env node import readline from "node:readline"; const input = readline.createInterface({ input: process.stdin }); @@ -30,7 +39,7 @@ input.on("line", (line) => { if (request.id == null) return; const result = request.method === "initialize" ? { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: ${JSON.stringify(name)}, version: "1" } } - : { tools: [{ name: "search", description: "Search public documentation", annotations: { readOnlyHint: true } }] }; + : { tools: ${JSON.stringify(tools)} }; process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n"); }); `, "utf8"); @@ -80,7 +89,10 @@ async function main() { event: { type: "content_block_start", content_block: { type: "tool_use", name, input } }, }) + "\\n"); tool("Read", { file_path: process.env.FAKE_REPO_FILE }); - if (process.env.FAKE_CLAUDE_SPARSE !== "1") tool("WebSearch", { query: "primary docs" }); + if (process.env.FAKE_CLAUDE_SPARSE !== "1") tool( + process.env.FAKE_CLAUDE_BRAVE === "1" ? "mcp__brave-search__brave_web_search" : "WebSearch", + { query: "primary docs" } + ); if (process.env.FAKE_CLAUDE_DELTA_MARKER) process.stdout.write(JSON.stringify({ type: "stream_event", session_id: sessionId, @@ -307,6 +319,56 @@ function activate(testEnv, result, stage, branch, lease) { ], { input: attemptInput(lease) }); } +test("peer workflow accepts selected Brave MCP evidence without workspace writes", () => { + const testEnv = createEnvironment(); + try { + const braveMcp = path.join(testEnv.rootDir, "brave-mcp.mjs"); + writeFakeMcp(braveMcp, "brave-search", [{ + name: "brave_web_search", + description: "Search the web", + }]); + const configPath = path.join(testEnv.env.HOME, ".claude.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + config.mcpServers["brave-search"] = { command: process.execPath, args: [braveMcp] }; + fs.writeFileSync(configPath, JSON.stringify(config), "utf8"); + const before = checked(testEnv.workspaceDir, "git", ["status", "--porcelain=v1", "--untracked-files=all"]); + const created = runJson(testEnv, [ + "peer-create", "--mode", "design", "--cwd", testEnv.workspaceDir, + "--owner-session-id", "owner-a", + "--user-mcp-tool", "mcp__brave-search__brave_web_search", + "--json", "Compare", "the", "runtime", "design.", + ]); + const codexLease = planLease(created, "_codex_", "memo"); + activate(testEnv, created, "memo", "codex", codexLease); + runJson(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(codexLease, memo(testEnv, "codex")) }); + 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), env: { FAKE_CLAUDE_BRAVE: "1" } }); + + const stored = readWorkflow(testEnv, created.workflow.id); + assert.deepEqual(stored.toolManifest.map(({ toolId }) => toolId), [ + "mcp__brave-search__brave_web_search", + ]); + assert.deepEqual(stored.branches.claude.payload.toolEvents.map(({ tool }) => tool), [ + "Read", "mcp__brave-search__brave_web_search", + ]); + assert.deepEqual(stored.branches.claude.payload.webCitations, ["https://example.test/primary"]); + const invocation = JSON.parse(fs.readFileSync(testEnv.env.FAKE_CLAUDE_LOG, "utf8").trim()); + assert.deepEqual(Object.keys(invocation.mcpConfig.mcpServers), ["brave-search"]); + const after = checked(testEnv.workspaceDir, "git", ["status", "--porcelain=v1", "--untracked-files=all"]); + assert.equal(after, before); + } finally { + fs.rmSync(testEnv.rootDir, { recursive: true, force: true }); + } +}); + test("peer workflow acceptance covers aggregate surfaces, retry, lifecycle, and no workspace writes", async () => { const testEnv = createEnvironment(); try { diff --git a/tests/mcp-capabilities.test.mjs b/tests/mcp-capabilities.test.mjs index ec7825a..55e9ddf 100644 --- a/tests/mcp-capabilities.test.mjs +++ b/tests/mcp-capabilities.test.mjs @@ -384,6 +384,79 @@ describe("MCP capability discovery", () => { }); }); + 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], [ + "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.BRAVE_WEB_EVIDENCE_TOOLS) + ); + for (const mutate of [ + () => mutable.add("mcp__context7__query-docs"), + () => mutable.delete("mcp__brave-search__brave_web_search"), + () => mutable.clear(), + ]) { + assert.throws(mutate, TypeError); + } + assert.deepEqual([...mcp.BRAVE_WEB_EVIDENCE_TOOLS], [ + "mcp__brave-search__brave_web_search", + "mcp__brave-search__brave_llm_context", + ]); + await withTempHome(async ({ homeDir, cwd }) => { + const serverPath = writeStdioServer(homeDir, { + initialize: { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "brave-search", version: "1" }, + }, + "tools/list": { + tools: [ + { name: "brave_web_search", description: "Search the web" }, + { name: "brave_llm_context", description: "Read search context" }, + { name: "brave_news_search", description: "Search news" }, + { + name: "brave_web_search_destructive", + description: "Unsafe search", + annotations: { destructiveHint: true }, + }, + ], + }, + }); + fs.writeFileSync( + path.join(homeDir, ".claude.json"), + JSON.stringify({ + mcpServers: { "brave-search": { command: process.execPath, args: [serverPath] } }, + }), + "utf8" + ); + + const result = await mcp.probeMcpCapabilities( + mcp.collectConfiguredMcpServers(cwd, { homeDir }) + ); + + assert.deepEqual(result.catalog.map(({ toolId, safety }) => ({ toolId, safety })), [ + { + toolId: "mcp__brave-search__brave_llm_context", + safety: { eligible: true, decision: "eligible", reason: "audited_read_only_registry" }, + }, + { + toolId: "mcp__brave-search__brave_news_search", + safety: { eligible: false, decision: "blocked", reason: "read_only_unverified" }, + }, + { + toolId: "mcp__brave-search__brave_web_search", + safety: { eligible: true, decision: "eligible", reason: "audited_read_only_registry" }, + }, + { + toolId: "mcp__brave-search__brave_web_search_destructive", + safety: { eligible: false, decision: "blocked", reason: "destructive_annotation" }, + }, + ]); + }); + }); + it("reuses a capability probe for the same fingerprint within ten minutes", async () => { await withTempHome(async ({ homeDir, cwd }) => { const requestLog = path.join(homeDir, "requests.log"); diff --git a/tests/peer-companion.test.mjs b/tests/peer-companion.test.mjs index bfa2064..8fe7463 100644 --- a/tests/peer-companion.test.mjs +++ b/tests/peer-companion.test.mjs @@ -9,19 +9,49 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, it } from "node:test"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; const PROJECT_ROOT = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const COMPANION = path.join(PROJECT_ROOT, "scripts", "claude-companion.mjs"); const cleanup = []; +function writeMissingSchemaPreload(rootDir, unavailablePath) { + const filePath = path.join(rootDir, "missing-schema-preload.mjs"); + fs.writeFileSync(filePath, `import fs from "node:fs"; + +const target = ${JSON.stringify(unavailablePath)}; +const existsSync = fs.existsSync.bind(fs); +const readFileSync = fs.readFileSync.bind(fs); +fs.existsSync = (candidate) => candidate === target ? false : existsSync(candidate); +fs.readFileSync = (candidate, ...args) => { + if (candidate === target) { + const error = new Error("ENOENT: no such file or directory, open " + target); + error.code = "ENOENT"; + error.path = target; + throw error; + } + return readFileSync(candidate, ...args); +}; +`, "utf8"); + return filePath; +} + function runGit(cwd, args) { const result = spawnSync("git", args, { cwd, encoding: "utf8" }); assert.equal(result.status, 0, result.stderr || result.stdout); } -function writeFakeMcp(root) { - const filePath = path.join(root, "fake-mcp.mjs"); +/** + * @param {string} root + * @param {string} [name] + * @param {Array<{name: string, description: string, annotations?: {readOnlyHint?: boolean, destructiveHint?: boolean}}>} [tools] + */ +function writeFakeMcp(root, name = "docs", tools = [{ + name: "search", + description: "Search public web documentation", + annotations: { readOnlyHint: true }, +}]) { + const filePath = path.join(root, `fake-${name}.mjs`); fs.writeFileSync(filePath, `#!/usr/bin/env node import fs from "node:fs"; import readline from "node:readline"; @@ -33,8 +63,8 @@ input.on("line", (line) => { } if (request.id == null) return; const result = request.method === "initialize" - ? { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "docs", version: "1" } } - : { tools: [{ name: "search", description: "Search public web documentation", annotations: { readOnlyHint: true } }] }; + ? { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: ${JSON.stringify(name)}, version: "1" } } + : { tools: ${JSON.stringify(tools)} }; process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n"); }); `, "utf8"); @@ -117,21 +147,49 @@ async function main() { if (process.env.FAKE_CLAUDE_LIST_TOOLS_WARNING === "1") { process.stdout.write("Client.listTools() called but server does not advertise tools capability - returning empty list\\n"); } + const marker = process.env.FAKE_CLAUDE_MARKER || "The repository and primary source agree."; + const citations = { + repoCitations: [{ path: process.env.FAKE_REPO_FILE, line: 1 }], + webCitations: sparse ? [] : [{ path: "https://example.test/primary", line: 1 }], + }; const payload = critique - ? { content: process.env.FAKE_CLAUDE_EMPTY_CRITIQUE === "1" - ? {} - : { critique: "Compare the frozen memos." } } + ? { + content: process.env.FAKE_CLAUDE_EMPTY_CRITIQUE === "1" + ? {} + : { + critique: "Compare the frozen memos.", + agreements: [], + disagreements: [], + corrections: [], + }, + ...citations, + } : { - content: { findings: [process.env.FAKE_CLAUDE_MARKER || "The repository and primary source agree."] }, - repoCitations: [{ path: process.env.FAKE_REPO_FILE, line: 1 }], - webCitations: sparse ? [] : ["https://example.test/primary"], + content: prompt.includes("Evaluate alternatives") + ? { + alternatives: ["Keep the current design."], + tradeoffs: ["It favors compatibility."], + decisionDrivers: ["Preserve the peer contract."], + recommendation: marker, + gaps: [], + } + : { + findings: [marker], + sourceQuality: "Primary source.", + contradictions: [], + confidence: "high", + gaps: [], + }, + ...citations, }; const emitResult = () => process.stdout.write(JSON.stringify({ type: "result", session_id: sessionId, ...(process.env.FAKE_CLAUDE_STRUCTURED_ARRAY === "1" ? { structured_output: [payload] } - : {}), + : process.env.FAKE_CLAUDE_NATIVE_STRUCTURED === "1" + ? { structured_output: payload } + : {}), result: process.env.FAKE_CLAUDE_UNSTRUCTURED === "1" ? "not structured JSON" : JSON.stringify(payload), @@ -298,15 +356,30 @@ function writePeerJob(testEnv, job) { ); } -function createPeer(testEnv, extra = []) { +function createPeer(testEnv, extra = [], mode = "design") { return runJson(testEnv, [ - "peer-create", "--mode", "design", "--cwd", testEnv.workspaceDir, + "peer-create", "--mode", mode, "--cwd", testEnv.workspaceDir, "--owner-session-id", "owner-a", "--user-mcp-tool", "mcp__docs__search", ...extra, "--json", "Compare", "the", "runtime", "design.", ]); } +function submitCodexMemo(testEnv, created) { + const codexLease = planLease(created, "_codex_", "memo"); + activate(testEnv, created, "memo", "codex", codexLease); + runJson(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(codexLease, { + content: { findings: ["Independent Codex result."] }, + repoCitations: [{ path: testEnv.repoFile, line: 1 }], + webCitations: ["https://example.test/codex"], + toolEvents: [{ tool: "repo-read" }, { tool: "web-search" }], + }) }); +} + function planLease(result, taskPart, attempt = null) { const child = result.spawnPlan.find(({ task_name }) => task_name.includes(taskPart)); assert.ok(child, `missing ${taskPart} worker plan`); @@ -340,6 +413,112 @@ afterEach(() => { }); describe("peer companion with fake Claude", () => { + it("selects the design and research schemas for initial Claude turns", () => { + const testEnv = createEnvironment(); + const invocationFor = (mode) => { + const created = createPeer(testEnv, [], mode); + 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) }); + return fs.readFileSync(testEnv.claudeLog, "utf8").trim() + .split("\n").map((line) => JSON.parse(line)).at(-1); + }; + + const design = invocationFor("design"); + const research = invocationFor("research"); + for (const [invocation, file] of [ + [design, "peer-design-output.schema.json"], + [research, "peer-research-output.schema.json"], + ]) { + const schemaIndex = invocation.args.indexOf("--json-schema"); + assert.ok(schemaIndex >= 0); + assert.deepEqual(JSON.parse(invocation.args[schemaIndex + 1]), JSON.parse( + fs.readFileSync(path.join(PROJECT_ROOT, "schemas", file), "utf8") + )); + } + }); + + it("guides the initial Claude turn to the selected Brave web tool", () => { + const testEnv = createEnvironment(); + const configPath = path.join(testEnv.env.HOME, ".claude.json"); + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + const braveMcp = writeFakeMcp(testEnv.rootDir, "brave-search", [{ + name: "brave_web_search", + description: "Search the web", + }]); + config.mcpServers["brave-search"] = { + ...config.mcpServers.docs, + args: [braveMcp], + env: { ...config.mcpServers.docs.env, FAKE_MCP_NAME: "brave-search" }, + }; + delete config.mcpServers.docs; + fs.writeFileSync(configPath, JSON.stringify(config), "utf8"); + const created = runJson(testEnv, [ + "peer-create", "--mode", "design", "--cwd", testEnv.workspaceDir, + "--owner-session-id", "owner-a", + "--user-mcp-tool", "mcp__brave-search__brave_web_search", + "--json", "Compare", "the", "runtime", "design.", + ]); + 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 invocation = JSON.parse(fs.readFileSync(testEnv.claudeLog, "utf8").trim()); + assert.match(invocation.prompt, /prefer the selected Brave web tool/u); + assert.match(invocation.prompt, /mcp__brave-search__brave_web_search/u); + assert.deepEqual(Object.keys(invocation.mcpConfig.mcpServers), ["brave-search"]); + }); + + it("fails closed before spawning Claude when its output schema is missing", () => { + const testEnv = createEnvironment(); + const schemaPath = path.join(PROJECT_ROOT, "schemas", "peer-design-output.schema.json"); + const preloadDir = path.join(testEnv.rootDir, "preload with spaces"); + fs.mkdirSync(preloadDir); + const preloadPath = writeMissingSchemaPreload(preloadDir, schemaPath); + const created = createPeer(testEnv); + 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: { + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${pathToFileURL(preloadPath).href}`] + .filter(Boolean).join(" "), + FAKE_CLAUDE_SANDBOX_UNAVAILABLE: "1", + }, + }); + assert.notEqual(failed.status, 0); + assert.match(failed.stderr, /PEER_OUTPUT_SCHEMA_UNAVAILABLE/); + assert.equal(fs.existsSync(testEnv.claudeLog), false); + }); + + it("uses native structured output when final text is invalid JSON", () => { + const testEnv = createEnvironment(); + const created = createPeer(testEnv); + submitCodexMemo(testEnv, created); + const claudeLease = planLease(created, "_claude_"); + const result = 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), + env: { FAKE_CLAUDE_NATIVE_STRUCTURED: "1", FAKE_CLAUDE_UNSTRUCTURED: "1" }, + }); + + assert.equal(result.memo.content.recommendation, "The repository and primary source agree."); + }); + it("rejects a memo that reflects its live checkpoint lease without mutation or exposure", () => { const testEnv = createEnvironment(); const created = createPeer(testEnv); @@ -499,7 +678,11 @@ describe("peer companion with fake Claude", () => { assert.equal(ready.readyForCheckpoint, true); assert.deepEqual(ready.memos.codex.content, codexMemo.content); assert.deepEqual(ready.memos.claude.content, { - findings: [marker], + alternatives: ["Keep the current design."], + tradeoffs: ["It favors compatibility."], + decisionDrivers: ["Preserve the peer contract."], + recommendation: marker, + gaps: [], }); }); @@ -987,6 +1170,17 @@ describe("peer companion with fake Claude", () => { .split("\n") .map((line) => JSON.parse(line)); const critique = invocations.at(-1); + const initial = invocations.at(-2); + for (const [invocation, file] of [ + [initial, "peer-design-output.schema.json"], + [critique, "peer-critique-output.schema.json"], + ]) { + const schemaIndex = invocation.args.indexOf("--json-schema"); + assert.ok(schemaIndex >= 0); + assert.deepEqual(JSON.parse(invocation.args[schemaIndex + 1]), JSON.parse( + fs.readFileSync(path.join(PROJECT_ROOT, "schemas", file), "utf8") + )); + } assert.ok(critique.args.includes("--no-session-persistence")); assert.equal(critique.args.includes("--resume"), false); assert.equal(critique.args.includes("--fork-session"), false); diff --git a/tests/peer-orchestration.test.mjs b/tests/peer-orchestration.test.mjs index aaf200b..28bfd51 100644 --- a/tests/peer-orchestration.test.mjs +++ b/tests/peer-orchestration.test.mjs @@ -282,6 +282,74 @@ describe("fake built-in agent orchestration", () => { }); describe("peer evidence validation", () => { + it("accepts built-ins and only selected audited Brave web tool events", () => { + const workspaceRoot = fs.realpathSync.native( + fs.mkdtempSync(path.join(os.tmpdir(), "cc-peer-brave-evidence-")) + ); + try { + const source = path.join(workspaceRoot, "source.mjs"); + fs.writeFileSync(source, "export const value = 1;\n", "utf8"); + const base = { + content: { finding: "validated" }, + repoCitations: [{ path: source, line: 1 }], + webCitations: ["https://example.test/reference"], + }; + for (const toolEvents of [ + [{ tool: "Read" }, { tool: "WebSearch" }], + [{ tool: "Read" }, { tool: "WebFetch" }], + ]) { + assert.deepEqual( + validatePeerMemo({ workspaceRoot }, base, { role: "claude", toolEvents }).toolEvents, + toolEvents + ); + } + for (const tool of [ + "mcp__brave-search__brave_web_search", + "mcp__brave-search__brave_llm_context", + ]) { + const toolEvents = [{ tool: "Read" }, { tool }]; + assert.deepEqual(validatePeerMemo({ + workspaceRoot, + toolManifest: [{ toolId: tool }], + }, base, { role: "claude", toolEvents }).toolEvents, toolEvents); + } + const workflow = { + workspaceRoot, + toolManifest: [{ toolId: "mcp__brave-search__brave_web_search" }], + }; + for (const tool of [ + "mcp__brave-search__brave_llm_context", + "mcp__brave-search__brave_news_search", + "mcp__context7__query-docs", + ]) { + assert.throws( + () => validatePeerMemo(workflow, base, { + role: "claude", toolEvents: [{ tool: "Read" }, { tool }], + }), + (error) => { + const failure = /** @type {Error & {code?: string, failureDetail?: string}} */ (error); + return failure.code === "EVIDENCE_INCOMPLETE" && + failure.failureDetail === "WEB_TOOL_EVENT_REQUIRED"; + } + ); + } + const lookalike = "mcp__brave-search__brave_web_search_extra"; + assert.throws( + () => validatePeerMemo({ + workspaceRoot, + toolManifest: [{ toolId: lookalike }], + }, base, { role: "claude", toolEvents: [{ tool: "Read" }, { tool: lookalike }] }), + (error) => { + const failure = /** @type {Error & {code?: string, failureDetail?: string}} */ (error); + return failure.code === "EVIDENCE_INCOMPLETE" && + failure.failureDetail === "WEB_TOOL_EVENT_REQUIRED"; + } + ); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); + it("accepts only regular in-workspace files with positive lines and credential-free HTTPS URLs", () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "cc-peer-evidence-")); try { diff --git a/tests/structured-output.test.mjs b/tests/structured-output.test.mjs index 64a1625..624a9af 100644 --- a/tests/structured-output.test.mjs +++ b/tests/structured-output.test.mjs @@ -38,6 +38,83 @@ describe("review output schema", () => { }); }); +describe("peer output schemas", () => { + const schemaCases = [ + { + file: "peer-design-output.schema.json", + content: { + alternatives: { type: "array", items: { type: "string" } }, + tradeoffs: { type: "array", items: { type: "string" } }, + decisionDrivers: { type: "array", items: { type: "string" } }, + recommendation: { type: "string" }, + gaps: { type: "array", items: { type: "string" } }, + }, + }, + { + file: "peer-research-output.schema.json", + content: { + findings: { type: "array", items: { type: "string" } }, + sourceQuality: { type: "string" }, + contradictions: { type: "array", items: { type: "string" } }, + confidence: { type: "string" }, + gaps: { type: "array", items: { type: "string" } }, + }, + }, + { + file: "peer-critique-output.schema.json", + content: { + critique: { type: "string" }, + agreements: { type: "array", items: { type: "string" } }, + disagreements: { type: "array", items: { type: "string" } }, + corrections: { type: "array", items: { type: "string" } }, + }, + }, + ]; + + it("uses strict documented Draft-07 contracts for every peer phase", () => { + const unsupportedLimits = new Set(["minItems", "minLength", "minimum", "maximum"]); + const visit = (value) => { + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + assert.equal(unsupportedLimits.has(key), false, `${key} is not part of the peer contract`); + visit(child); + } + }; + + for (const { file, content } of schemaCases) { + const schema = JSON.parse(fs.readFileSync( + new URL(`../schemas/${file}`, import.meta.url), + "utf8" + )); + assert.equal(schema.$schema, "http://json-schema.org/draft-07/schema#"); + assert.equal(schema.type, "object"); + assert.equal(schema.additionalProperties, false); + assert.deepEqual(schema.required, ["content", "repoCitations", "webCitations"]); + assert.deepEqual(schema.properties.content, { + type: "object", + additionalProperties: false, + required: Object.keys(content), + properties: content, + }); + for (const field of ["repoCitations", "webCitations"]) { + assert.deepEqual(schema.properties[field], { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["path", "line"], + properties: { + path: { type: "string" }, + line: { type: "integer" }, + }, + }, + }); + } + visit(schema); + } + }); +}); + describe("extractFirstJsonObject", () => { it("extracts a JSON object after prose", () => { const extracted = extractFirstJsonObject(