diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 203d7b5..e35bc6d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ "email": "hi@okis.dev" }, "description": "Multi-model orchestration marketplace for Claude Code.", - "version": "0.0.39", + "version": "0.0.40", "plugins": [ { "name": "grok", "source": "./plugins/grok", "displayName": "Grok Companion", "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", - "version": "0.0.39", + "version": "0.0.40", "author": { "name": "Harry Yep" }, @@ -34,7 +34,7 @@ "source": "./plugins/codex", "displayName": "Codex Companion", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", - "version": "0.0.39", + "version": "0.0.40", "author": { "name": "Harry Yep" }, @@ -55,7 +55,7 @@ "source": "./plugins/fusion", "displayName": "Fusion Orchestrator", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", - "version": "0.0.39", + "version": "0.0.40", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 94312cd..97529bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # changelog +## 0.0.40 + +- routing becomes capability first: a package routes by its load bearing capability instead of its surface task type, capability monopolies hard route ahead of every task type default (X and live platform reads to grok `live-web` even when digest shaped, Claude tool surface and privacy to Claude workers, judgment dense authorship to the main session), and lane defaults are named as cached capability observations that fresh observations override per package; grounded in a same day controlled probe where grok `web_fetch` returned a full x.com post body while the codex web surface returned empty HTML for the same URL +- the orchestration policy gains a judgment dense authorship section: a deliverable whose faithful brief would have to contain the deliverable itself (public naming, API shape, UX copy, rules text, a design's first exemplar) is authored in the main session as control plane work, the judgment density to volume ratio decides the split, design intent travels to peers as contract (exemplar files, MUST NOT gates, signatures, snapshots) rather than prose aspiration, and lane acceptance rates are never evidence of taste; the interactive design carve out generalizes to this rule, codex briefs gain a lean and gate shaped requirement (the vendor system card documents intent overreach through permissive instruction reading, worse under persistence emphasis), the grok design gate now points at the authorship rule instead of a taste score threshold no code reads, and readme, contributing, and troubleshooting restate the new invariants +- judgment shaped rejections become measurable: `/fusion:stats --record` accepts `--failure-kind` and per pair `--failure-kind-for` (raw args transport, rejected pairs only, one of `intent_override`, `scope_rewrite`, `wrong_approach`, `style_mismatch`), the engine acceptance bridge forwards the kind to both companions, `record-acceptance` on both engines validates it and now requires `--reason` for every rejected verdict (previously optional at the engine layer, which left 41 of 63 ledger rejections reasonless), worker records persist `acceptanceFailureKind` through queued verdicts, and the brief envelope accepts `package-type: design` +- the bench suite gains T09-style-fidelity, the first task measuring adherence to an externally imposed style contract as distinct from output quality under a model's own defaults: six machine checkable gates that deliberately conflict with model default habits (snake_case exports, promise chains with async and await forbidden, no classes, errors only through `app_error`, zero comments, named exports only), a style checker independent of the hidden behavior tests, and a mutant that passes every behavior test while violating only the contract, so the verifier isolates style fidelity; methodology and readme drop the stale single task claims and the capability table rows stop citing the nonexistent "bench B2" for taste +- `docs/grok-contract.md` documents X content access: x.com reads work today through consult `web_fetch` and are treated as a grok lane monopoly in routing, while the first party `x_search` backend tool rides the agent profile surface rather than the `--tools` allowlist and stays unwired until upstream documents headless enablement + ## 0.0.39 - the over budget stop gate settles instead of looping when the harness has already reaped a worker's runtime task: a `cancel_requested` record whose runtime id is absent from a provided `background_tasks` array settles as `task_reaped` through the same `settleReapedWorker` helper the no task found escape hatch uses, because that hatch never fires for this class; `TaskStop` on a reaped id returns a tool_use_error, which emits neither PostToolUse nor PostToolUseFailure, so the demand was unsatisfiable (observed live: two user interrupted workers held eight consecutive blocking stop rounds while twelve errored `TaskStop` and `TaskOutput` calls delivered zero hook events, and only SessionEnd closed the records) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5578f4..2d11d0f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,7 @@ Any change to `plugins/fusion/rules/orchestration.md` or `plugins/fusion/rules/t - The main Claude session is the only control plane and final judge. It owns ambiguity resolution, decomposition, integration, semantic acceptance, and user communication, while work packages execute in delegated lanes. - Codex owns ordinary implementation by default. Grok remains implementation capable only through its four protected roles (`burst`, `independence`, `live-web`, `large-context`) or an explicit user choice; it is not a generic alternative merely because it is idle. - Codex admission order is the current workspace, an isolated worktree for an independent eligible package, then Grok under `burst`, then the matching Claude fallback. Packages with overlapping files, shared generated state, or ordering dependencies are consolidated or sequenced instead of parallel overflow. + - Routing stays capability first: capability monopolies (X and live platform reads on Grok `live-web`, Claude tool surface and privacy on Claude workers, judgment dense authorship in the main session) hard route ahead of every task type default, and the judgment dense authorship rule keeps policy text, public naming, API shape, and UX copy authored in the main session with only the mechanical spread dispatched. - Claude worker and Grok dispatches are background Agents. Codex is the foreground Agent exception: its rescue Agent and companion Bash call stay foreground regardless of complexity or expected duration unless the incoming user request already contains `--background`. - A Codex package that cannot fit the foreground cap is split or routed elsewhere. Complexity and duration never silently detach it. - Independent packages fan out in a single message rather than being queued one at a time. Dependence is never removed by switching engines. diff --git a/README.md b/README.md index 24e5e81..2eaeaf5 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,15 @@ Multi-model orchestration for Claude Code. Your chosen Claude session model is t | Layer | Who | Job | |---|---|---| -| Orchestrator | the user's chosen main session model; `best[1m]` is recommended but never set by the plugin | sole control plane: resolve ambiguity, decompose, integrate, verify, record semantic acceptance, make the final judgment, and communicate with the user; it does not execute work packages | +| Orchestrator | the user's chosen main session model; `best[1m]` is recommended but never set by the plugin | sole control plane: resolve ambiguity, decompose, integrate, verify, record semantic acceptance, make the final judgment, and communicate with the user; it does not execute work packages; it authors judgment dense cores (naming, API shape, UX copy, policy text) itself under the authorship rule and dispatches their mechanical spread | | Claude workers | `fusion:deep-reasoner` (Fable), `fusion:fast-worker` (Sonnet), `fusion:trivial-worker` (Haiku) | native specialists rather than external diversity: read only high stakes advice, resolved work needing Claude Code tools or the Claude privacy boundary, and exact tiny fallback packages when no eligible peer lane fits | | Peer engines | Companion plugins implementing the shared contract. Current instances are the hosted Codex and Grok plugins in this marketplace | Codex is the primary implementation lane and default deep external reviewer. Grok is a complementary specialist and burst lane with four protected roles: `burst`, `independence`, `live-web`, and `large-context`. It remains implementation capable inside those roles, but idle capacity alone does not make it the ordinary default. Each lane runs whatever its own CLI is configured for; model names and scores live in the /fusion:config capability table, refreshed from each CLI's live listing. | | Panel mechanism | `/fusion:panel` | sends one blind brief to available peer engines in parallel and gives their attributed evidence to the main Claude session for final adjudication; it is not an independent role or authority | Codex first admission follows this order: use Codex in the current workspace, then an isolated Codex worktree for an independent eligible package, then Grok under `burst` for an independent package inside its safety and turn boundaries, then the matching Claude fallback. Overlapping files, shared generated state, and ordering dependencies are consolidated or sequenced instead of parallelized by changing engines. +Routing is capability first: a package routes by its load bearing capability, not its surface task type. Capability monopolies hard route: X and live platform reads go to Grok `live-web` even for digest shaped work, Claude tool surface and privacy needs go to Claude workers, and judgment dense authorship stays in the main session. Lane defaults are cached capability observations; a fresh observation that disagrees wins for the package at hand. + Direct `/codex:*` and `/grok:*` commands are user selected engine escape hatches. They return the selected engine's transport result directly and do not first pass through Fusion's automatic routing or main session adjudication. ## Quick start @@ -59,7 +61,7 @@ Then, in a new session, run `/fusion:setup` once per machine (it writes the rout Companion text outcomes use the shared footer grammar for `state`, `failure`, `job`, `delivery`, `semantic`, and `-session` lines defined in [docs/companion-contract.md](docs/companion-contract.md), with delivery and semantic lines present when that adapter exposes those dimensions in text. The Codex runtime contract is [docs/codex-contract.md](docs/codex-contract.md), and the Grok runtime contract is [docs/grok-contract.md](docs/grok-contract.md). Each plugin names its own session line, for example Codex emits `codex-session: ` and Grok emits `grok-session: `. -Every peer `state: done` and Claude worker `transportStatus: done` is a transport result, not proof that the work is acceptable; the main session verifies the result before relying on it. The orchestrator records Codex acceptance through `/fusion:stats --record-acceptance --source main-loop`. Claude workers and Grok jobs collected through Fusion record acceptance through `/fusion:stats --record-worker-acceptance --source main-loop`. Terminal work without that evidence remains `unverified`. A collector timeout, dead job, or status error reports the Fusion task id, peer job id, literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. Optional `--reason` text is caller supplied and retained in private local state after limited credential shape redaction, so keep it short and free of sensitive data. +Every peer `state: done` and Claude worker `transportStatus: done` is a transport result, not proof that the work is acceptable; the main session verifies the result before relying on it. Terminal work without a verified verdict remains `unverified`. A collector timeout, dead job, or status error reports the Fusion task id, peer job id, literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. `/fusion:stats --record =` settles collected results; a rejected verdict requires `--reason` through the raw args transport, and a judgment shaped rejection may add `--failure-kind` with one of `intent_override`, `scope_rewrite`, `wrong_approach`, or `style_mismatch`. ## Safety model @@ -87,7 +89,7 @@ Roles bind to alias tiers, not to specific models, so a same tier release (Sonne The plugin's effectiveness claims are benchmarked, not asserted. The claims and the full protocol live in [bench/METHODOLOGY.md](bench/METHODOLOGY.md): C1 quota displacement (routing bills fewer tokens to the Claude subscription than a vanilla session at equal task success, split into a Claude tiers only arm, a peer offload arm, and an advisor profile arm where the main session runs on Sonnet and consults stronger tiers on demand), C2 wall clock compression on plan shaped tasks versus a protocol enforced sequential baseline, and C3 typed failure surfacing, scoped to the shared companion outcome contract the test suite already covers. The harness ships in [bench/](bench/): a runner that isolates each condition in its own Claude configuration directory and keeps verifiers outside the model visible worktree, a strict JSONL record schema, a summary script that refuses to compare snapshots from different task manifests, a redaction step for published transcripts, and a manifest tool that content hashes every task before any results exist. -No results are published yet. The task suite currently holds one of the planned 8 to 12 tasks, so no snapshot would meet the methodology's own publication gate; the first dated reference snapshot ships once the pool is complete, with raw per run data committed alongside the summary table. Until then the status is plain: the procedure is public and runnable, the numbers do not exist. Readers can run the harness themselves with `node bench/run.mjs --task --condition --repetition --results --claude-config ` after building condition profiles per [bench/conditions/README.md](bench/conditions/README.md). +No results are published yet. The task suite currently holds nine tasks (T01 to T09) of the planned 8 to 12. The first dated reference snapshot ships once the pool is complete, with raw per run data committed alongside the summary table. Until then the status is plain: the procedure is public and runnable, the numbers do not exist. Readers can run the harness themselves with `node bench/run.mjs --task --condition --repetition --results --claude-config ` after building condition profiles per [bench/conditions/README.md](bench/conditions/README.md). ## Data and uninstall diff --git a/bench/METHODOLOGY.md b/bench/METHODOLOGY.md index 4e57277..bff67e1 100644 --- a/bench/METHODOLOGY.md +++ b/bench/METHODOLOGY.md @@ -29,7 +29,7 @@ This benchmark does not isolate model coding quality from orchestration quality. ## Task suite -Tasks live under bench/tasks/T01-slug/ (and similarly numbered directories) with three parts: brief.md, the task statement handed to the session verbatim; verify.sh, a binary pass or fail command whose exit code is the only success criterion, with no human scoring anywhere in the suite; and either a fixtures/ directory local to the task or a pointer to a shared fixture project under bench/fixtures/. The suite grows to 8 to 12 tasks before the first published snapshot and is expected to cover the plugin's routing surface: a multi file implementation task from a written spec, a seeded bug fix, a research question whose answer is machine checkable, a mechanical codemod across many files, at least three plan shaped tasks with three or more independent work packages each, which serve as the C2 probes, and at least one negative control task where the plugin is expected to provide no advantage, a small single file task where delegation is pure overhead; that task's purpose, to check that the harness does not manufacture an advantage where none exists, is stated openly in its brief. The current branch ships the first task only, T01-seeded-bug, with the remaining tasks landing before any snapshot is published. +Tasks live under bench/tasks/T01-slug/ (and similarly numbered directories) with three parts: brief.md, the task statement handed to the session verbatim; verify.sh, a binary pass or fail command whose exit code is the only success criterion, with no human scoring anywhere in the suite; and either a fixtures/ directory local to the task or a pointer to a shared fixture project under bench/fixtures/. The suite grows to 8 to 12 tasks before the first published snapshot and is expected to cover the plugin's routing surface: a multi file implementation task from a written spec, a seeded bug fix, a research question whose answer is machine checkable, a mechanical codemod across many files, at least three plan shaped tasks with three or more independent work packages each, which serve as the C2 probes, and at least one negative control task where the plugin is expected to provide no advantage, a small single file task where delegation is pure overhead; that task's purpose, to check that the harness does not manufacture an advantage where none exists, is stated openly in its brief; and a style constraint task that imposes an explicit style contract conflicting with model defaults and verifies constraint compliance separately from behavior; its purpose, measuring externally imposed style fidelity as distinct from output quality under a model's own defaults, is stated openly in its brief. The suite currently ships nine tasks, T01 through T09. Anti gaming controls on the manifest: the full task manifest, task ids together with a deterministic hash list for every file under each task directory, is committed and tagged before the first results run against it. The manifest hash is computed from sorted task ids, sorted relative file paths, and each file's SHA-256 content hash, so changes to fixtures, mutants, solutions, hidden tests, briefs, and verifiers all change the manifest. New tasks enter from a pre registered pool. Any change to a task after results exist against it requires minting a new task id rather than editing the existing one in place. External task contributions via pull request are welcome, and any externally contributed verifier requires review and sign off by a second maintainer before it is trusted to gate a task. diff --git a/bench/tasks/T09-style-fidelity/behavior.test.mjs b/bench/tasks/T09-style-fidelity/behavior.test.mjs new file mode 100644 index 0000000..c30c2f4 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/behavior.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { map_concurrent } from "./concurrent.js"; +import { app_error } from "./errors.js"; +import { retry_with_backoff } from "./retry.js"; +import { delay_ms } from "./util.js"; + +test("retry_with_backoff resolves after two failures", () => { + let calls = 0; + return retry_with_backoff(() => { + calls += 1; + if (calls < 3) { + return Promise.reject(app_error("TEMPORARY", "try again")); + } + return Promise.resolve("done"); + }, 3, 1).then((result) => { + assert.equal(result, "done"); + assert.equal(calls, 3); + }); +}); + +test("retry_with_backoff reports exhaustion after the requested attempts", () => { + let calls = 0; + return assert.rejects(retry_with_backoff(() => { + calls += 1; + return Promise.reject(app_error("TEMPORARY", "last failure")); + }, 3, 1), (error) => { + assert.equal(error.code, "E_RETRY_EXHAUSTED"); + assert.equal(error.message, "last failure"); + return true; + }).then(() => { + assert.equal(calls, 3); + }); +}); + +test("map_concurrent preserves order and honors the worker limit", () => { + let active = 0; + let high_water = 0; + return map_concurrent([1, 2, 3, 4], 2, (value) => { + active += 1; + high_water = Math.max(high_water, active); + return delay_ms(1).then(() => { + active -= 1; + return value * 2; + }); + }).then((results) => { + assert.deepEqual(results, [2, 4, 6, 8]); + assert.equal(high_water, 2); + assert.ok(high_water <= 2); + }); +}); + +test("map_concurrent resolves an empty input", () => map_concurrent([], 2, () => Promise.resolve("unused")).then((results) => { + assert.deepEqual(results, []); +})); + +test("map_concurrent propagates the worker error", () => assert.rejects(map_concurrent([1, 2], 2, (value) => { + if (value === 2) { + return Promise.reject(app_error("WORKER_FAILED", "worker failed")); + } + return delay_ms(1).then(() => value); +}), (error) => { + assert.equal(error.code, "E_WORKER_FAILED"); + return true; +})); diff --git a/bench/tasks/T09-style-fidelity/brief.md b/bench/tasks/T09-style-fidelity/brief.md new file mode 100644 index 0000000..567483d --- /dev/null +++ b/bench/tasks/T09-style-fidelity/brief.md @@ -0,0 +1,16 @@ +Add two new files to this project: `retry.js` and `concurrent.js`. + +`retry.js` must export `retry_with_backoff(operation, attempts, base_delay_ms)`. It calls `operation()` up to `attempts` times and resolves with the first success. Between failed attempts, wait for `base_delay_ms` doubled for each retry: base, twice the base, four times the base, and so on. When all attempts fail, reject with `app_error("RETRY_EXHAUSTED", ...)` carrying the last failure message. + +`concurrent.js` must export `map_concurrent(items, limit, worker)`. It resolves to results in input order, never runs more than `limit` workers at once, resolves to an empty array for empty input, and rejects with the worker's first error. + +STYLE CONTRACT + +This contract is binding even where it conflicts with the writer's habits. + +1. Exported function names MUST be snake_case. +2. The `async` and `await` keywords MUST NOT appear anywhere. Use explicit promise chains. +3. Class declarations MUST NOT appear. +4. Every constructed error MUST come from `app_error` in `./errors.js`. `new Error(` MUST NOT appear. +5. `.js` files MUST NOT contain comments. +6. Named exports only. `export default` MUST NOT appear. diff --git a/bench/tasks/T09-style-fidelity/check-style.mjs b/bench/tasks/T09-style-fidelity/check-style.mjs new file mode 100644 index 0000000..02e1770 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/check-style.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const [, , target_dir] = process.argv; + +if (process.argv.length !== 3 || !target_dir) { + console.error("usage: node check-style.mjs "); + process.exit(2); +} + +if (!fs.existsSync(target_dir) || !fs.statSync(target_dir).isDirectory()) { + console.error(`directory is not a directory: ${target_dir}`); + process.exit(2); +} + +function list_javascript_files(directory) { + const files = []; + const directories = [directory]; + + while (directories.length > 0) { + const current_directory = directories.pop(); + const entries = fs.readdirSync(current_directory, { withFileTypes: true }); + for (const entry of entries) { + const entry_path = path.join(current_directory, entry.name); + if (entry.isDirectory()) { + if (entry.name !== "node_modules") { + directories.push(entry_path); + } + continue; + } + if (entry.isFile() && entry.name.endsWith(".js")) { + files.push(entry_path); + } + } + } + + return files.sort((left, right) => left.localeCompare(right)); +} + +function find_violations(file_path) { + const violations = []; + const lines = fs.readFileSync(file_path, "utf8").split(/\r?\n/); + const relative_path = path.relative(target_dir, file_path).split(path.sep).join("/"); + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const line_number = index + 1; + if (/\basync\b/.test(line) || /\bawait\b/.test(line)) { + violations.push(`${relative_path}:${line_number}: async or await keyword`); + } + if (/\bclass\s/.test(line)) { + violations.push(`${relative_path}:${line_number}: class declaration`); + } + if (/new\s+Error\s*\(/.test(line)) { + violations.push(`${relative_path}:${line_number}: new Error construction`); + } + if (/\/\//.test(line) || /\/\*/.test(line)) { + violations.push(`${relative_path}:${line_number}: comment`); + } + if (/export\s+default\b/.test(line)) { + violations.push(`${relative_path}:${line_number}: default export`); + } + + const exports = line.matchAll(/\bexport\s+(?:function|const)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g); + for (const match of exports) { + if (!/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/.test(match[1])) { + violations.push(`${relative_path}:${line_number}: exported identifier is not snake_case`); + } + } + } + + return violations; +} + +const violations = list_javascript_files(target_dir).flatMap(find_violations); + +if (violations.length > 0) { + for (const violation of violations) { + console.error(violation); + } + process.exit(1); +} diff --git a/bench/tasks/T09-style-fidelity/fixtures/errors.js b/bench/tasks/T09-style-fidelity/fixtures/errors.js new file mode 100644 index 0000000..f05912a --- /dev/null +++ b/bench/tasks/T09-style-fidelity/fixtures/errors.js @@ -0,0 +1,5 @@ +export function app_error(code, message) { + const error = new globalThis.Error(message); + error.code = `E_${code}`; + return error; +} diff --git a/bench/tasks/T09-style-fidelity/fixtures/package.json b/bench/tasks/T09-style-fidelity/fixtures/package.json new file mode 100644 index 0000000..6990891 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/fixtures/package.json @@ -0,0 +1 @@ +{"type": "module"} diff --git a/bench/tasks/T09-style-fidelity/fixtures/util.js b/bench/tasks/T09-style-fidelity/fixtures/util.js new file mode 100644 index 0000000..7cf219b --- /dev/null +++ b/bench/tasks/T09-style-fidelity/fixtures/util.js @@ -0,0 +1,5 @@ +export function delay_ms(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/bench/tasks/T09-style-fidelity/mutant/concurrent.js b/bench/tasks/T09-style-fidelity/mutant/concurrent.js new file mode 100644 index 0000000..2aef560 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/mutant/concurrent.js @@ -0,0 +1,20 @@ +export async function map_concurrent(items, limit, worker) { + const results = new Array(items.length); + let next_index = 0; + + async function run_worker() { + while (next_index < items.length) { + const current_index = next_index; + next_index += 1; + results[current_index] = await worker(items[current_index], current_index); + } + } + + const workers = []; + const worker_count = Math.min(limit, items.length); + for (let index = 0; index < worker_count; index += 1) { + workers.push(run_worker()); + } + await Promise.all(workers); + return results; +} diff --git a/bench/tasks/T09-style-fidelity/mutant/errors.js b/bench/tasks/T09-style-fidelity/mutant/errors.js new file mode 100644 index 0000000..f05912a --- /dev/null +++ b/bench/tasks/T09-style-fidelity/mutant/errors.js @@ -0,0 +1,5 @@ +export function app_error(code, message) { + const error = new globalThis.Error(message); + error.code = `E_${code}`; + return error; +} diff --git a/bench/tasks/T09-style-fidelity/mutant/package.json b/bench/tasks/T09-style-fidelity/mutant/package.json new file mode 100644 index 0000000..6990891 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/mutant/package.json @@ -0,0 +1 @@ +{"type": "module"} diff --git a/bench/tasks/T09-style-fidelity/mutant/retry.js b/bench/tasks/T09-style-fidelity/mutant/retry.js new file mode 100644 index 0000000..a835716 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/mutant/retry.js @@ -0,0 +1,21 @@ +import { delay_ms } from "./util.js"; + +export async function retry_with_backoff(operation, attempts, base_delay_ms) { + let last_failure; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await operation(); + } catch (failure) { + last_failure = failure; + if (attempt < attempts - 1) { + await delay_ms(base_delay_ms * 2 ** attempt); + } + } + } + + // Preserve the expected error code for callers. + const error = new Error(last_failure.message); + error.code = "E_RETRY_EXHAUSTED"; + throw error; +} diff --git a/bench/tasks/T09-style-fidelity/mutant/util.js b/bench/tasks/T09-style-fidelity/mutant/util.js new file mode 100644 index 0000000..7cf219b --- /dev/null +++ b/bench/tasks/T09-style-fidelity/mutant/util.js @@ -0,0 +1,5 @@ +export function delay_ms(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/bench/tasks/T09-style-fidelity/selftest.sh b/bench/tasks/T09-style-fidelity/selftest.sh new file mode 100755 index 0000000..988685e --- /dev/null +++ b/bench/tasks/T09-style-fidelity/selftest.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY="${TASK_DIR}/verify.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +TMP_MUTANT="$(mktemp -d)" +trap 'rm -rf "$TMP_MUTANT" "$TMP_SOLUTION"' EXIT +TMP_SOLUTION="$(mktemp -d)" + +cp -R "${TASK_DIR}/mutant"/. "$TMP_MUTANT"/ +if bash "$VERIFY" "$TMP_MUTANT"; then + fail "mutant leg: verify.sh should reject the known bad solution" +fi + +cp -R "${TASK_DIR}/solution"/. "$TMP_SOLUTION"/ +if ! bash "$VERIFY" "$TMP_SOLUTION"; then + fail "solution leg: verify.sh should accept the reference fix" +fi + +echo "PASS" diff --git a/bench/tasks/T09-style-fidelity/solution/concurrent.js b/bench/tasks/T09-style-fidelity/solution/concurrent.js new file mode 100644 index 0000000..b2f08f0 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/solution/concurrent.js @@ -0,0 +1,34 @@ +import { app_error } from "./errors.js"; + +export function map_concurrent(items, limit, worker) { + if (items.length === 0) { + return Promise.resolve([]); + } + if (limit < 1) { + return Promise.reject(app_error("INVALID_LIMIT", "limit must be at least one")); + } + + const results = new Array(items.length); + const workers = []; + const worker_count = Math.min(limit, items.length); + let next_index = 0; + + function run_worker() { + const current_index = next_index; + next_index += 1; + return Promise.resolve() + .then(() => worker(items[current_index], current_index)) + .then((result) => { + results[current_index] = result; + if (next_index < items.length) { + return run_worker(); + } + return undefined; + }); + } + + for (let index = 0; index < worker_count; index += 1) { + workers.push(run_worker()); + } + return Promise.all(workers).then(() => results); +} diff --git a/bench/tasks/T09-style-fidelity/solution/errors.js b/bench/tasks/T09-style-fidelity/solution/errors.js new file mode 100644 index 0000000..f05912a --- /dev/null +++ b/bench/tasks/T09-style-fidelity/solution/errors.js @@ -0,0 +1,5 @@ +export function app_error(code, message) { + const error = new globalThis.Error(message); + error.code = `E_${code}`; + return error; +} diff --git a/bench/tasks/T09-style-fidelity/solution/package.json b/bench/tasks/T09-style-fidelity/solution/package.json new file mode 100644 index 0000000..6990891 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/solution/package.json @@ -0,0 +1 @@ +{"type": "module"} diff --git a/bench/tasks/T09-style-fidelity/solution/retry.js b/bench/tasks/T09-style-fidelity/solution/retry.js new file mode 100644 index 0000000..dc26629 --- /dev/null +++ b/bench/tasks/T09-style-fidelity/solution/retry.js @@ -0,0 +1,25 @@ +import { app_error } from "./errors.js"; +import { delay_ms } from "./util.js"; + +export function retry_with_backoff(operation, attempts, base_delay_ms) { + let remaining_attempts = attempts; + let delay_multiplier = 1; + let last_failure; + + function try_operation() { + return Promise.resolve() + .then(operation) + .catch((failure) => { + last_failure = failure; + remaining_attempts -= 1; + if (remaining_attempts === 0) { + return Promise.reject(app_error("RETRY_EXHAUSTED", last_failure.message)); + } + const delay = base_delay_ms * delay_multiplier; + delay_multiplier *= 2; + return delay_ms(delay).then(try_operation); + }); + } + + return try_operation(); +} diff --git a/bench/tasks/T09-style-fidelity/solution/util.js b/bench/tasks/T09-style-fidelity/solution/util.js new file mode 100644 index 0000000..7cf219b --- /dev/null +++ b/bench/tasks/T09-style-fidelity/solution/util.js @@ -0,0 +1,5 @@ +export function delay_ms(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/bench/tasks/T09-style-fidelity/verify.sh b/bench/tasks/T09-style-fidelity/verify.sh new file mode 100755 index 0000000..d8a22ed --- /dev/null +++ b/bench/tasks/T09-style-fidelity/verify.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: bash verify.sh " >&2 + exit 2 +fi + +WORKDIR="$1" +TASK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHECKER="${TASK_DIR}/check-style.mjs" +HIDDEN_TEST="${TASK_DIR}/behavior.test.mjs" + +if [[ ! -d "$WORKDIR" ]]; then + echo "workdir is not a directory: $WORKDIR" >&2 + exit 2 +fi + +if [[ ! -f "$CHECKER" ]]; then + echo "checker script missing: $CHECKER" >&2 + exit 2 +fi + +if [[ ! -f "$HIDDEN_TEST" ]]; then + echo "hidden test missing: $HIDDEN_TEST" >&2 + exit 2 +fi + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +cp -R "$WORKDIR"/. "$TMP"/ + +if ! node "$CHECKER" "$TMP"; then + exit 1 +fi + +cp "$HIDDEN_TEST" "$TMP"/ + +cd "$TMP" +exec node --test diff --git a/bench/tasks/manifest.json b/bench/tasks/manifest.json index cec1abd..3853775 100644 --- a/bench/tasks/manifest.json +++ b/bench/tasks/manifest.json @@ -1,5 +1,5 @@ { - "manifestHash": "21185f22eb2940fa69974d417450b56e48004b39dd31bb931a7ef179e0ef4269", + "manifestHash": "e0d1c68370ef0a682e23da0850d081e91365a964c95bf6386e67085114168967", "tasks": [ { "id": "T01-seeded-bug", @@ -732,6 +732,84 @@ "sha256": "8fdebce8b9211b1e8d3455a86db3217568abe5417fb340c7c8a6cbc94cc69bf9" } ] + }, + { + "id": "T09-style-fidelity", + "taskSha256": "1ee2b23797abb00de485f8163a3e92fa5f62130a1b54b47d1d805e1eb886251f", + "files": [ + { + "path": "behavior.test.mjs", + "sha256": "6e558e68c51b2e34f4fcb2df56082bf38f846283cfa80d5192eb8278f0b37909" + }, + { + "path": "brief.md", + "sha256": "865c133ecd66e54de323c36311612d2d2df0e5df4151c30b8512801159b4cabe" + }, + { + "path": "check-style.mjs", + "sha256": "6e8ed043ccc51f847398380dfe4f382c07784bc88393180a356664a35aa52927" + }, + { + "path": "fixtures/errors.js", + "sha256": "e66e091e7340d9811e914ab97fdd9f4eb685ed7b9a1c93446dd837567d195971" + }, + { + "path": "fixtures/package.json", + "sha256": "48e847860dd4f0907ce91e346dbb00a4a12213163ad3d80a953c343eb9a11979" + }, + { + "path": "fixtures/util.js", + "sha256": "f47cdc0e49a3899622c73b1eedf27052bc8013742bae3af7aa476ed5ced4e430" + }, + { + "path": "mutant/concurrent.js", + "sha256": "d5d60ff35bcd2f89d26bc3c848bc90bb54dcb70c1fc64cd5cebac7c26cc358ba" + }, + { + "path": "mutant/errors.js", + "sha256": "e66e091e7340d9811e914ab97fdd9f4eb685ed7b9a1c93446dd837567d195971" + }, + { + "path": "mutant/package.json", + "sha256": "48e847860dd4f0907ce91e346dbb00a4a12213163ad3d80a953c343eb9a11979" + }, + { + "path": "mutant/retry.js", + "sha256": "8a335070acc6cf899bffb5952b2ea124b0900b16c8a533ef93085552d6ac24e0" + }, + { + "path": "mutant/util.js", + "sha256": "f47cdc0e49a3899622c73b1eedf27052bc8013742bae3af7aa476ed5ced4e430" + }, + { + "path": "selftest.sh", + "sha256": "5fbf94d686aa4418cb1ced471e34e3efa212bd6b7a0a20acb61ad0ef01ddbdc9" + }, + { + "path": "solution/concurrent.js", + "sha256": "17671d91d81948e837cca4c3a488d958a1fa2d97c9dee99d1f0667c418105b2c" + }, + { + "path": "solution/errors.js", + "sha256": "e66e091e7340d9811e914ab97fdd9f4eb685ed7b9a1c93446dd837567d195971" + }, + { + "path": "solution/package.json", + "sha256": "48e847860dd4f0907ce91e346dbb00a4a12213163ad3d80a953c343eb9a11979" + }, + { + "path": "solution/retry.js", + "sha256": "fd5ada4343973e6f9e2a07eb6703de67718c3c024d1a76de7c44442be04428f9" + }, + { + "path": "solution/util.js", + "sha256": "f47cdc0e49a3899622c73b1eedf27052bc8013742bae3af7aa476ed5ced4e430" + }, + { + "path": "verify.sh", + "sha256": "87d124e1ada0b02e1bffc18ae1b6995a37fd7fcf3c9839e4ce0ba3e4e52c57a3" + } + ] } ] } diff --git a/docs/codex-contract.md b/docs/codex-contract.md index 6aebd2d..6b4c4d4 100644 --- a/docs/codex-contract.md +++ b/docs/codex-contract.md @@ -117,4 +117,6 @@ Protocol fixtures cover every supported event type, unknown extensions, malforme Codex records are the primary source for transport status, delivery, semantic status, model request, resolved model and effort, token usage, thread identity, Claude session identity, repository identity, and job duration. Without a state override, Fusion reads only the canonical `codex-claude-code-fusion/state` root. The old `codex-openai-codex/state` root is included only through `/fusion:stats --include-legacy` or `FUSION_CODEX_INCLUDE_LEGACY=1`, is always read only, and is never copied or mutated. The new `/codex:result` command cannot collect an old plugin deliverable. `CODEX_COMPANION_DATA` selects only its own state root, just as the Fusion state overrides select only one root. Fusion observes those records without taking ownership of their lifecycle. A `done` Codex job remains `unverified` until the orchestrator checks its completion criteria or verification command and records `accepted` or `rejected`. `/fusion:stats --record-acceptance` writes that verdict through the companion's `record-acceptance` subcommand. Marking a job accepted after transport failure requires the explicit `--accept-failed-transport` override, and the stats report exposes acceptance anomalies. +`record-acceptance` requires `--reason` for a rejected verdict and accepts `--failure-kind` with one of `intent_override`, `scope_rewrite`, `wrong_approach`, or `style_mismatch` to classify judgment shaped rejections; the kind lands in `semanticFailureKind`. + `/codex:history` reads all workspaces from the canonical companion state root and shows job ids, thread ids, transport, delivery, semantic status, and resolved model and effort. Codex exec threads are also persisted in the local Codex session store and remain resumable by thread id. Codex Desktop filters its normal task list by interactive source kinds, so exec sourced threads are not guaranteed to appear in its sidebar. diff --git a/docs/grok-contract.md b/docs/grok-contract.md index 71995ca..88efc9d 100644 --- a/docs/grok-contract.md +++ b/docs/grok-contract.md @@ -99,3 +99,9 @@ Job statuses, liveness, rendered outcome footers, failure kind definitions, back - A missing grok binary fails fast with a message pointing at `/grok:setup`, and the `grok-rescue` agent returns a single `grok unavailable: ` line the orchestrator uses to stop routing to Grok for the session. - A bad `--cwd` fails before the companion creates a job record and maps to the shared `input` failure kind. + +## X content access + +Live web consults reach x.com post content through the generic `web_fetch` tool; a controlled probe on 2026-07-26 retrieved a full post body directly from an x.com status URL while the Codex web surface returned empty HTML for the same URL, so X reads are treated as a Grok lane monopoly in routing. The richer first party `x_search` backend tool (keyword, semantic, user, and thread retrieval) exists in the Grok CLI but rides the agent profile and SDK tool override surface, not the `--tools` built in allowlist this companion uses; it stays unwired until upstream documents headless enablement, and adding it blind would trip the forced builder tracing's fail closed gate. + +The Grok companion's `record-acceptance` requires `--reason` for a rejected verdict and accepts `--failure-kind` with one of `intent_override`, `scope_rewrite`, `wrong_approach`, or `style_mismatch`; the kind lands in `semanticFailureKind`. diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index d30f872..eabe52d 100644 --- a/plugins/codex/.claude-plugin/plugin.json +++ b/plugins/codex/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "codex", "displayName": "Codex Companion", - "version": "0.0.39", + "version": "0.0.40", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", "author": { "name": "Harry Yep" diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index f199a0a..7f1350f 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -97,6 +97,7 @@ const TRANSPORT_MAX_AGE_MS = 60 * 60 * 1000; const RECORD_ACCEPTANCE_JOB_ID_PATTERN = /^[a-f0-9]{32}$/; const RECORD_ACCEPTANCE_VALUES = new Set(["accepted", "rejected", "unverified"]); const RECORD_ACCEPTANCE_SOURCES = new Set(["collector", "main-loop", "stats"]); +const SEMANTIC_FAILURE_KINDS = new Set(["intent_override", "scope_rewrite", "wrong_approach", "style_mismatch"]); const SOL_FOREGROUND_WRITE_WARNING = "warning: sol p90 wall clock exceeds the 600s foreground cap. Split the brief or name gpt-5.6-terra."; let activeCommandArgv = null; @@ -117,7 +118,7 @@ function printUsage() { " node scripts/codex-companion.mjs status [job-id] [--all] [--cwd ] [--json]", " node scripts/codex-companion.mjs result [--wait] [--wait-timeout-ms ] [--cwd ] [--json]", " node scripts/codex-companion.mjs cancel [--cwd ] [--json]", - " node scripts/codex-companion.mjs record-acceptance --job-id <32 lowercase hex> --acceptance [--reason ] [--source ] [--accept-failed-transport]", + " node scripts/codex-companion.mjs record-acceptance --job-id <32 lowercase hex> --acceptance [--reason ] [--failure-kind ] [--source ] [--accept-failed-transport]", " node scripts/codex-companion.mjs history [--json]", " node scripts/codex-companion.mjs setup [--cwd ] [--json]" ].join("\n") + "\n" @@ -161,9 +162,9 @@ function recordAcceptanceArgs(argv) { options.acceptFailedTransport = true; continue; } - const key = token === "--job-id" ? "jobId" : token === "--acceptance" ? "acceptance" : token === "--reason" ? "reason" : token === "--source" ? "source" : null; + const key = token === "--job-id" ? "jobId" : token === "--acceptance" ? "acceptance" : token === "--reason" ? "reason" : token === "--failure-kind" ? "failureKind" : token === "--source" ? "source" : null; if (!key) { - throw new CompanionError("The record-acceptance command accepts only --job-id, --acceptance, --reason, --source, and --accept-failed-transport.", "input"); + throw new CompanionError("The record-acceptance command accepts only --job-id, --acceptance, --reason, --failure-kind, --source, and --accept-failed-transport.", "input"); } if (Object.hasOwn(options, key)) { throw new CompanionError(`${token} may be provided only once.`, "input"); @@ -181,6 +182,15 @@ function recordAcceptanceArgs(argv) { if (!RECORD_ACCEPTANCE_VALUES.has(options.acceptance)) { throw new CompanionError("The --acceptance option must be accepted, rejected, or unverified.", "input"); } + if (options.failureKind !== undefined && !SEMANTIC_FAILURE_KINDS.has(options.failureKind)) { + throw new CompanionError("The --failure-kind option must be intent_override, scope_rewrite, wrong_approach, or style_mismatch.", "input"); + } + if (options.failureKind !== undefined && options.acceptance !== "rejected") { + throw new CompanionError("The --failure-kind option is valid only with --acceptance rejected.", "input"); + } + if (options.acceptance === "rejected" && !options.reason?.trim()) { + throw new CompanionError("A rejected acceptance requires --reason.", "input"); + } options.source ??= "stats"; if (!RECORD_ACCEPTANCE_SOURCES.has(options.source)) { throw new CompanionError("The --source option must be collector, main-loop, or stats.", "input"); @@ -2060,7 +2070,7 @@ async function handleCancel(rawArgv) { } async function handleRecordAcceptance(argv) { - const { acceptance, acceptFailedTransport, jobId, reason, source } = recordAcceptanceArgs(argv); + const { acceptance, acceptFailedTransport, failureKind, jobId, reason, source } = recordAcceptanceArgs(argv); const found = findJobRecordById(resolveDataDir(), jobId); if (!found) { throw new CompanionError(`No job record found for ${jobId}.`, "input"); @@ -2079,9 +2089,10 @@ async function handleRecordAcceptance(argv) { }; } return { - ...(reason === undefined ? {} : { semanticFailureMessage: reason }), acceptanceRecordedAt: nowIso(), acceptanceSource: source, + semanticFailureKind: failureKind ?? null, + semanticFailureMessage: reason, semanticStatus: acceptance }; }, { allowTerminal: true }); diff --git a/plugins/codex/scripts/lib/state.mjs b/plugins/codex/scripts/lib/state.mjs index 6795a69..b8da340 100644 --- a/plugins/codex/scripts/lib/state.mjs +++ b/plugins/codex/scripts/lib/state.mjs @@ -21,6 +21,7 @@ const STATE_MAX_RECORDS_ENV = "CODEX_COMPANION_HISTORY_MAX_RECORDS"; const JOB_STATUSES = new Set(["running", "done", "error", "cancelled"]); const DELIVERY_MODES = new Set(["foreground", "manual", "managed"]); const SEMANTIC_STATUSES = new Set(["accepted", "rejected", "unverified"]); +const SEMANTIC_FAILURE_KINDS = new Set(["intent_override", "scope_rewrite", "wrong_approach", "style_mismatch"]); const ACCEPTANCE_SOURCES = new Set(["collector", "main-loop", "stats"]); const JOB_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const WORKSPACE_SLUG_PATTERN = /^.+-[a-f0-9]{16}$/; @@ -434,6 +435,9 @@ function assertJobRecord(record) { if (record.semanticStatus != null && !SEMANTIC_STATUSES.has(record.semanticStatus)) { throw new TypeError("Job semantic status is invalid."); } + if (record.semanticFailureKind != null && record.semanticFailureKind !== "policy" && !SEMANTIC_FAILURE_KINDS.has(record.semanticFailureKind)) { + throw new TypeError("Job semantic failure kind is invalid."); + } if (record.acceptanceSource != null && !ACCEPTANCE_SOURCES.has(record.acceptanceSource)) { throw new TypeError("Job acceptance source is invalid."); } diff --git a/plugins/fusion/.claude-plugin/plugin.json b/plugins/fusion/.claude-plugin/plugin.json index fc72815..7515772 100644 --- a/plugins/fusion/.claude-plugin/plugin.json +++ b/plugins/fusion/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "fusion", "displayName": "Fusion Orchestrator", - "version": "0.0.39", + "version": "0.0.40", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/rules-manifest.json b/plugins/fusion/rules-manifest.json index 27c6709..e94e942 100644 --- a/plugins/fusion/rules-manifest.json +++ b/plugins/fusion/rules-manifest.json @@ -30,6 +30,7 @@ "dd2c1475815da92b62b28ab9cf6599df7ccab7fa88617e367e8343a6ed515434", "e1feb3d17af193a1b8b34b090d4d504c594efb87bbe521ed20f948aba4567e8a", "eef493e5fd41b00653684fba75e80556ff51480dd96a785be560f2e072ad60ff", - "f49bc53461a6710b4c1eb7c830a02bcf90c90a0943ca9e91a44f32a167f32bc9" + "f49bc53461a6710b4c1eb7c830a02bcf90c90a0943ca9e91a44f32a167f32bc9", + "fc54ccbfb4844c20ae6ec688357c4f64c735e8a41220ea9490ee02e9043a374c" ] } diff --git a/plugins/fusion/rules/orchestration.md b/plugins/fusion/rules/orchestration.md index bcf9005..511edcd 100644 --- a/plugins/fusion/rules/orchestration.md +++ b/plugins/fusion/rules/orchestration.md @@ -14,7 +14,7 @@ Collection and verification are completion conditions, not reasons to keep execu Run the session like the founder of a well staffed startup: you decide, employees execute. The lane hierarchy is intentionally asymmetric. -- The main session owns ambiguity resolution, decomposition, integration, semantic acceptance, final judgment, and user communication. It may perform one bounded micro step and read only verification, but it never executes a work package. +- The main session owns ambiguity resolution, decomposition, integration, semantic acceptance, final judgment, and user communication. It also authors judgment dense cores under the authorship rule in the routing section; that authorship is control plane work, not a work package. It may perform one bounded micro step and read only verification, but it never executes a work package. - Claude workers are native specialists, not independent model diversity. `fusion:deep-reasoner` is a read only adviser for difficult judgment; `fusion:fast-worker` executes resolved briefs that need the Claude Code tool surface or privacy boundary; `fusion:trivial-worker` is an exact tiny package fallback when no eligible peer lane fits. - Codex is the primary implementation lane and the default deep external reviewer. - Grok is a complementary specialist and burst lane, not a general implementation default. Its protected roles are `burst`, `independence`, `live-web`, and `large-context`; it remains implementation capable inside those roles. @@ -54,11 +54,15 @@ Posture never relaxes the output invariant: every dispatch still requires collec Priorities when routing axes conflict for anything that ships, in order: correctness and safety first, then user facing quality (taste), then latency, then quota cost. Cost is a tie breaker only, never the deciding factor between correct and incorrect or tasteful and sloppy. +Routing is capability first: a package routes by the capability its success depends on, not by its surface task type. Before applying any row below, name the load bearing capability: intent fidelity and taste, data or platform access, long horizon mechanical execution, context breadth, the Claude tool surface or privacy boundary, or sheer volume. Capabilities attach to concrete surfaces (model, tool set, sandbox, authentication), not to vendor brands, so a capability claim is verified against the lane's actual surface when the routing decision depends on it. + +A capability monopoly hard routes: when exactly one lane possesses the load bearing capability, that lane takes the package and the monopoly overrides every task type default, role economics, and cost preference. Standing monopolies: X and live platform content reads belong to Grok `live-web` even when the package is digest shaped; hooks, subagent files, MCP, and the privacy boundary belong to Claude workers; judgment dense authorship belongs to the main session under the authorship rule below. When several lanes hold the capability, the priority order above decides among them. Lane defaults and protected roles are cached capability observations: when a fresh observation and a cached default disagree, capability wins for the package at hand and the mismatch is reported as a routing policy edit candidate rather than silently absorbed. + | Engine | Lane | Intelligence | Taste | Cost | Notes | |---|---|---:|---:|---:|---| -| grok-4.5 | grok | 4 | 3 | 5 | Opus class coding, 500k context, fast and cheap; taste unvalidated pending bench B2 | -| gpt-5.6-sol | codex | 4 | 3 | 3 | Flagship successor to retired gpt-5.5 at identical API price; leads terminal and agentic benchmarks, ties grok-4.5 on SWE-Bench Pro; taste provisional pending bench B2; wall clock measured 2026-07-10: completed jobs mean ~11 min at n=7; single flight per workspace; ultra is a Codex-side multi-agent mode that is out of policy | +| grok-4.5 | grok | 4 | 3 | 5 | Opus class coding, 500k context, fast and cheap; taste unvalidated, style fidelity bench planned as bench task T09; X and live platform reads verified as a lane monopoly 2026-07-26 | +| gpt-5.6-sol | codex | 4 | 3 | 3 | Flagship successor to retired gpt-5.5 at identical API price; leads terminal and agentic benchmarks, ties grok-4.5 on SWE-Bench Pro; taste provisional, style fidelity bench planned as bench task T09; vendor system card 2026-07-09 documents intent overreach through permissive instruction reading, so ship design intent as MUST NOT gates and exemplars and prefer terra when external style constraints dominate; wall clock measured 2026-07-10: completed jobs mean ~11 min at n=7; single flight per workspace; ultra is a Codex-side multi-agent mode that is out of policy | | gpt-5.6-terra | codex | 4 | 3 | 4 | Half price codex quick tier; gpt-5.5 class per vendor; Codex credit rate unpublished as of 2026-07-10; name it explicitly in quick scoped codex briefs | | gpt-5.6-luna | codex | 3 | 3 | 5 | Fast and affordable codex volume tier per vendor, GA 2026-07-09; scores provisional pending pilot; name it explicitly in briefs | | grok-composer-2.5-fast | grok | 3 | 2 | 5 | Ultra fast codemod tier from the live grok listing; unbenched pilot, scores provisional; name it explicitly in briefs | @@ -81,25 +85,34 @@ An isolated Codex worktree changes the execution workspace, not merely the prose | Spec grade: explicit completion criteria, output contract, boundaries, verification command | Codex primary implementation lane; Grok only inside a protected role | A foreground Codex companion call is capped at 10 minutes by the Bash tool. Codex stays foreground by default regardless of complexity, estimated duration, review size, or model. If a package is unlikely to fit the cap, split it into bounded packages. If it cannot be split, bypass Codex and route a bounded isolated package to Grok under `burst`, or use the matching Claude fallback. Never add `--background` as an implicit sizing decision. Only an incoming user request that already includes `--background` may detach Codex, and a receipt from a job created by Fusion orchestration requires one same turn bounded collection attempt through fusion:job-collector. Both forcing an oversized package into the foreground and silently detaching it are routing errors | | Quick scoped package: edits with a recipe, codemods, small fixes across a few files | Codex quick tier, gpt-5.6-terra, by default; Grok under `burst` | A package that fits this row routes to the named Codex volume lane even when Grok is idle and follows the admission ladder above when that workspace is occupied. The dispatch passes `--model gpt-5.6-terra --effort xhigh` as leading companion options in the rescue direct prompt, and the brief header line repeats them for audit; prose inside the brief never selects a model. A Grok burst brief names low effort, or the account's fast coding SKU from the live model listing, because the lane default is its current flagship. A Grok package with a long verification chain or a change surface likely to approach its write turn budget of 60 turns instead consolidates into a bounded spec grade Codex brief, carries an explicit `--max-turns` override, or routes to `fusion:fast-worker`. A max turns death is a routing error, not a retry candidate. Fixture heavy test authoring routes to the Codex quick tier or `fusion:fast-worker` by default. A Grok brief for fixture heavy test authoring is admissible only when the fixtures arrive pre built in the brief, because fixture construction burns Grok turns and stalls `apply_patch` | | Needs the Claude Code tool surface (hooks, subagent files, MCP), the Claude privacy boundary, or a resolved brief with no eligible peer lane | fusion:fast-worker | Claude execution fallback, not the default for spec grade or quick scoped work | -| Trivial and high volume light work: single file renames, small doc fixes, short mechanical checks, drafts, review comment triage, and research digest backup | Codex volume tier, gpt-5.6-luna, by default; Grok under `burst`; fallback fusion:trivial-worker | A package that fits this row routes to the named Codex volume lane even when Grok is idle and follows the admission ladder above when that workspace is occupied. Research digests, review triage, doc summaries, and mechanical checks default to gpt-5.6-luna at effort xhigh. Independent bounded packages overflow to Grok under `burst` when the Codex slot is busy. Leaving the volume tiers idle while this work runs on premium lanes is a routing defect. The dispatch passes `--model gpt-5.6-luna --effort xhigh` as leading companion options in the rescue direct prompt, and the brief header line repeats them for audit; prose inside the brief never selects a model. A Grok burst brief names low effort; batch codemods may name the fast SKU from the live listing, currently grok-composer-2.5-fast. Live web research uses Grok consult under `live-web`; very large context reads use `large-context`. Use `fusion:trivial-worker` only when Claude-only tools or privacy are required, or no eligible peer lane remains | +| Trivial and high volume light work: single file renames, small doc fixes, short mechanical checks, drafts, review comment triage, and research digest backup | Codex volume tier, gpt-5.6-luna, by default; Grok under `burst`; fallback fusion:trivial-worker | A package that fits this row routes to the named Codex volume lane even when Grok is idle and follows the admission ladder above when that workspace is occupied. Research digests, review triage, doc summaries, and mechanical checks default to gpt-5.6-luna at effort xhigh. A package whose source material is X or another live platform read leaves this row for Grok under `live-web` regardless of its digest shape; platform access is a lane monopoly and no volume tier can substitute. Independent bounded packages overflow to Grok under `burst` when the Codex slot is busy. Leaving the volume tiers idle while this work runs on premium lanes is a routing defect. The dispatch passes `--model gpt-5.6-luna --effort xhigh` as leading companion options in the rescue direct prompt, and the brief header line repeats them for audit; prose inside the brief never selects a model. A Grok burst brief names low effort; batch codemods may name the fast SKU from the live listing, currently grok-composer-2.5-fast. Live web research uses Grok consult under `live-web`; very large context reads use `large-context`. Use `fusion:trivial-worker` only when Claude-only tools or privacy are required, or no eligible peer lane remains | | Review shaped work: PR and issue verification, reproducing reported bugs | /grok:review for the fast mechanical sweep; /codex:adversarial-review for depth | Interactive judgment and taste calls stay in the main loop | | Codebase search, inventory, "where is X" | built in Explore agent | Simple scoped searches may pin the Explore agent to haiku through the Agent tool's model parameter | | Hard reasoning: architecture, root cause on stubborn bugs, correctness, concurrency, security, high stakes quality | fusion:deep-reasoner | Read only adviser on Fable at maximum effort; it recommends and the main session decides | -| Design and planning | main loop from Explore reconnaissance | The built in Plan agent has no automatic routing seat; use it only when the user explicitly requests that surface, while high stakes design goes to /fusion:panel | +| Design and planning | main loop from Explore reconnaissance, under the judgment dense authorship rule | The built in Plan agent has no automatic routing seat; use it only when the user explicitly requests that surface, while high stakes design goes to /fusion:panel; an agreed design leaves the main session as exemplar and contract, never as prose aspiration | | Independent second opinion, alternative diagnosis or implementation, code review | codex:codex-rescue (or /codex:review), grok:grok-rescue (or /grok:task, /grok:review) | Prefer over fusion:deep-reasoner when a non Claude perspective adds value | | Judging results, reconciling disagreement, revising the plan, semantic acceptance, user communication | main loop | Control plane work; never a delegated implementation package | -Implementation from an approved plan: one brief per work package with the plan section as the spec, split into packages, parallel when independent, routed by the table above. Never route execution to the generic claude catch all agent: it burns top tier quota on worker tier work and would move decomposition outside the control plane. Search goes to Explore, live web research goes to grok consult with --web, and research digest backup goes to gpt-5.6-luna at effort xhigh. The built in Plan agent is demoted: routine planning happens in the main loop from Explore reconnaissance, and high stakes design goes to /fusion:panel. +Implementation from an approved plan: one brief per work package with the plan section as the spec, split into packages, parallel when independent, routed by the table above. Never route execution to the generic claude catch all agent: it burns top tier quota on worker tier work and would move decomposition outside the control plane. Search goes to Explore, live web research and any digest over X or live platform sources go to grok consult with --web, and research digest backup over static sources goes to gpt-5.6-luna at effort xhigh. The built in Plan agent is demoted: routine planning happens in the main loop from Explore reconnaissance, and high stakes design goes to /fusion:panel. ### User facing quality floor UI, copy, API design, docs, error messages, and public naming require a taste review before final acceptance: the main loop, codex adversarial review, or fusion:deep-reasoner. Merely functional is not done for these surfaces; when the deliverable is generated content, verification includes reading the actual artifact against this floor, since a passing verification command alone is not done. +### Judgment dense authorship + +Taste and intent fidelity are capabilities of the top tier orchestrator, and no delegated lane carries them at that tier: peer engines revert to their own priors where prose intent and prior disagree, and worker tiers sit below the orchestrator on judgment. The orchestrator seat is a role, not a capability ceiling. + +- A package whose deliverable is judgment dense (public naming, API shape, UX copy, rules and policy text, a design's first exemplar) is authored in the main session whenever a faithful brief would have to contain the deliverable itself; at that point writing the brief is the work, and delegating it adds a lossy translation for no capacity gain. This authorship is control plane work, not a work package: it is bounded by the inline write guard, scoped to the single judgment dense artifact, and does not consume the micro step. +- The judgment density to volume ratio decides the split: author the smallest artifact that pins the judgment (the decision, the exemplar file, the interface, the named list), then dispatch everything whose shape that artifact now determines. Authorship never extends to the mechanical spread; the moment the core exists, propagation, tests, and fallout leave the main loop as ordinary packages. +- Design intent travels to peers as contract, not prose: an exemplar file to mirror, MUST NOT gates, type signatures, lint rules, snapshot fixtures. Intent that cannot yet be compiled into a checkable constraint has not finished being designed and stays in the main session. A peer brief that carries taste requirements only as prose aspiration is not ready to dispatch. +- Acceptance telemetry measures verifier passing and is blind to taste by construction; lane acceptance rates are never evidence of taste. Until a style fidelity bench reports, taste routing rests on this rule, the quality floor, and recorded judgment failure kinds. + ## What to delegate, what to keep -Difficulty is not the delegation boundary; ambiguity is. Delegate mechanical work freely even when hard: migrations, dependency or API removal, test authoring subject to the fixture heavy routing caveat, boilerplate integration. Never delegate interpretation of ambiguous intent, cross cutting product or UX decisions, or any task whose brief itself required judgment to write; resolve the ambiguity first, then delegate the resolved version. When a change touches an unfamiliar surface, reconnaissance and execution are two dispatches: Explore agents go out first, and the implementation brief waits for their conclusions. +Difficulty is not the delegation boundary; ambiguity is. Delegate mechanical work freely even when hard: migrations, dependency or API removal, test authoring subject to the fixture heavy routing caveat, boilerplate integration. Never delegate interpretation of ambiguous intent, cross cutting product or UX decisions, or any task whose brief itself required judgment to write; resolve the ambiguity first, then delegate the resolved version. When the resolved version would be the deliverable itself, delegation ends: that is judgment dense authorship and it stays in the main session under the authorship rule. When a change touches an unfamiliar surface, reconnaissance and execution are two dispatches: Explore agents go out first, and the implementation brief waits for their conclusions. -Interactive design iteration is a carve out, not a loophole: while the user is online iterating on UI or copy, the taste judgment turns stay in the main loop. Once a direction is agreed, the mechanical propagation (applying the agreed design across remaining files, running tests, fixing fallout) is dispatched as a package. Taste being main loop work is never a reason to also do the mechanical spread inline. +Design iteration follows the judgment dense authorship rule whether or not the user is online: the taste judgment turns and the core artifact stay in the main loop. Once a direction is agreed, the mechanical propagation (applying the agreed design across remaining files, running tests, fixing fallout) is dispatched as a package, and the agreed core rides in each brief as exemplar and contract. Taste being main loop work is never a reason to also do the mechanical spread inline. ## Question policy @@ -115,8 +128,8 @@ Codex owns ordinary delegated implementation by default. Grok is a complementary Peers are executors with model aware lanes. Codex is the primary builder for bounded, long horizon, multi step implementation. Grok may receive spec grade packages only when one protected role explains the fit. Live model listings and /fusion:config are authoritative for the model and effort inside a lane, but capability scores never change lane ownership by themselves. A brief names the effort or model when correctness or latency warrants it; /fusion:config is the write path for changing defaults and scores, while changing primary ownership requires an explicit routing policy edit. -- codex rewards spec grade briefs: completion criteria, an output contract, boundaries, a verification command, and an explicit requirement to return the real result in the same turn. Pass the prompt directly to `codex:codex-rescue`; do not create a transport payload with Write. The companion adapter owns any private staging needed by its transport. A current workspace brief may be direct natural language. A worktree implementation brief instead uses `--write --cwd "" -- ` as the complete direct prompt so the companion records that worktree as both its cwd and workspace root. Every Codex rescue and companion call stays foreground by default. Complexity, estimated duration, review size, and model choice never authorize implicit background execution. Split a package that cannot fit the 10 minute foreground cap. If it cannot be split, bypass Codex and route a bounded isolated package to Grok under `burst`, or use the matching Claude fallback. Only an incoming user request that already includes `--background` may detach Codex. Fusion:job-collector makes one same turn collection attempt, bounded to 540000ms so its foreground Bash call finishes inside the tool timeout. If that attempt times out, report the still running job and its result command honestly; do not claim collection or completion. A brief that cannot be explicit and bounded is not ready for Codex. If Codex is unavailable, a bounded, isolated implementation package may move to Grok under `burst`; use `fusion:fast-worker` when the package needs Claude tools or privacy, exceeds Grok's safe write or turn boundaries, or cannot be independently isolated; use `fusion:deep-reasoner` only for read only adversarial analysis. The Codex lane is single flight per canonical workspace and always follows the admission ladder rather than blanket overflow. Three or more quick packages touching the same subsystem with long horizon shape consolidate into one bounded spec grade brief or split only where independent. Consolidation reshapes package boundaries for verifiability and does not lower the fleet decision's count; the three package threshold is evaluated over the goal's resolved decomposition, not over what remains after consolidation. For peer implementation packages, verification confirms that tests were not deleted, skipped, or weakened to make the package pass. Codex live web search requires explicit `--web`; workspace network access additionally requires `--write --network`. -- grok is the complementary specialist and burst lane. Use `/grok:task --write` for bounded implementation under `burst` or `independence`; use read only consult with `--web` under `live-web`; use read only file analysis under `large-context`. Consult, write, and review all use `--sandbox strict`, with no silent downgrade to workspace. Consult exposes only file read, list, and search tools, plus web search and fetch when requested. Write uses a fixed list containing `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`; Grok has no `write` tool id, and `search_replace` creates new files. Command-pattern denies for common direct grok, claude, and codex calls do not cover absolute paths, aliases or functions, or indirect scripts, so they are not a hard nested-execution boundary while `run_terminal_cmd` remains enabled. Hard prevention requires removing that tool or an OS-level executable or network policy. Every ordinary call uses bare `--disallowed-tools Agent`; every mode also denies `search_tool`, `use_tool`, `ask_user_question`, and MCP tools. Upstream parses `--no-subagents`, but the single-turn and agent resolvers do not forward it, while the interactive TUI does apply it; the hard Agent tool deny and `GROK_SUBAGENTS=0` are the effective headless controls. The child sets all eighteen `GROK_CLAUDE_*_ENABLED`, `GROK_CURSOR_*_ENABLED`, and `GROK_CODEX_*_ENABLED` bridge variables to false; upstream currently consumes the six Claude and six Cursor cells plus the Codex sessions cell and reserves the other five Codex cells, and the child also pins `GROK_MANAGED_MCPS_ENABLED=false` because that variable has highest precedence over `[managed_mcps]` in `~/.grok/config.toml` and remote settings. It removes inherited Claude plugin and companion variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, broadly scrubs secrets while preserving xAI authentication, and disables automatic updates with `--no-auto-update`. Cross-session memory is forced off unless a direct user-selected ordinary task explicitly passes `--memory`; automatically routed briefs, review, and stop gate cannot enable it. Upstream minimum-version enforcement may still force an update and remains a residual boundary. Every managed run preflights `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`; consult adds `--permission-mode` and `--allow`, no-web adds `--disable-web-search`, write adds `--always-approve`, review adds `--json-schema`, and an ordinary run requires `--no-wait-for-background` or the bounded wait flag. Forced builder tracing initializes after stdin is consumed, rejects fallback or unmatched warnings as soon as observed, and rejects a successful close without positive tool allowlist applied evidence; it is not a pre-prompt or pre-side-effect attestation. Codex also exposes opt in web search, but Grok remains the preferred live research lane. Design decisions ride in a Grok write brief only once the capability table scores its taste at 4 or higher; until scored, the taste floor applies and design decisions stay out. +- codex rewards spec grade briefs: completion criteria, an output contract, boundaries, a verification command, and an explicit requirement to return the real result in the same turn. Pass the prompt directly to `codex:codex-rescue`; do not create a transport payload with Write. The companion adapter owns any private staging needed by its transport. A current workspace brief may be direct natural language. A worktree implementation brief instead uses `--write --cwd "" -- ` as the complete direct prompt so the companion records that worktree as both its cwd and workspace root. Every Codex rescue and companion call stays foreground by default. Complexity, estimated duration, review size, and model choice never authorize implicit background execution. Split a package that cannot fit the 10 minute foreground cap. If it cannot be split, bypass Codex and route a bounded isolated package to Grok under `burst`, or use the matching Claude fallback. Only an incoming user request that already includes `--background` may detach Codex. Fusion:job-collector makes one same turn collection attempt, bounded to 540000ms so its foreground Bash call finishes inside the tool timeout. If that attempt times out, report the still running job and its result command honestly; do not claim collection or completion. A brief that cannot be explicit and bounded is not ready for Codex. Codex briefs stay lean and gate shaped: hard constraints ride as MUST NOT lines and checkable contracts (exemplar files, signatures, named tests) rather than accumulated prose, persistence emphasizing language stays out beyond the single same turn delivery requirement, and style or architecture intent expressed only as prose is undeliverable to this lane. If Codex is unavailable, a bounded, isolated implementation package may move to Grok under `burst`; use `fusion:fast-worker` when the package needs Claude tools or privacy, exceeds Grok's safe write or turn boundaries, or cannot be independently isolated; use `fusion:deep-reasoner` only for read only adversarial analysis. The Codex lane is single flight per canonical workspace and always follows the admission ladder rather than blanket overflow. Three or more quick packages touching the same subsystem with long horizon shape consolidate into one bounded spec grade brief or split only where independent. Consolidation reshapes package boundaries for verifiability and does not lower the fleet decision's count; the three package threshold is evaluated over the goal's resolved decomposition, not over what remains after consolidation. For peer implementation packages, verification confirms that tests were not deleted, skipped, or weakened to make the package pass. Codex live web search requires explicit `--web`; workspace network access additionally requires `--write --network`. +- grok is the complementary specialist and burst lane. Use `/grok:task --write` for bounded implementation under `burst` or `independence`; use read only consult with `--web` under `live-web`; use read only file analysis under `large-context`. Consult, write, and review all use `--sandbox strict`, with no silent downgrade to workspace. Consult exposes only file read, list, and search tools, plus web search and fetch when requested. Write uses a fixed list containing `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`; Grok has no `write` tool id, and `search_replace` creates new files. Command-pattern denies for common direct grok, claude, and codex calls do not cover absolute paths, aliases or functions, or indirect scripts, so they are not a hard nested-execution boundary while `run_terminal_cmd` remains enabled. Hard prevention requires removing that tool or an OS-level executable or network policy. Every ordinary call uses bare `--disallowed-tools Agent`; every mode also denies `search_tool`, `use_tool`, `ask_user_question`, and MCP tools. Upstream parses `--no-subagents`, but the single-turn and agent resolvers do not forward it, while the interactive TUI does apply it; the hard Agent tool deny and `GROK_SUBAGENTS=0` are the effective headless controls. The child sets all eighteen `GROK_CLAUDE_*_ENABLED`, `GROK_CURSOR_*_ENABLED`, and `GROK_CODEX_*_ENABLED` bridge variables to false; upstream currently consumes the six Claude and six Cursor cells plus the Codex sessions cell and reserves the other five Codex cells, and the child also pins `GROK_MANAGED_MCPS_ENABLED=false` because that variable has highest precedence over `[managed_mcps]` in `~/.grok/config.toml` and remote settings. It removes inherited Claude plugin and companion variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, broadly scrubs secrets while preserving xAI authentication, and disables automatic updates with `--no-auto-update`. Cross-session memory is forced off unless a direct user-selected ordinary task explicitly passes `--memory`; automatically routed briefs, review, and stop gate cannot enable it. Upstream minimum-version enforcement may still force an update and remains a residual boundary. Every managed run preflights `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`; consult adds `--permission-mode` and `--allow`, no-web adds `--disable-web-search`, write adds `--always-approve`, review adds `--json-schema`, and an ordinary run requires `--no-wait-for-background` or the bounded wait flag. Forced builder tracing initializes after stdin is consumed, rejects fallback or unmatched warnings as soon as observed, and rejects a successful close without positive tool allowlist applied evidence; it is not a pre-prompt or pre-side-effect attestation. Codex also exposes opt in web search, but Grok remains the preferred live research lane. Design decisions stay out of Grok write briefs under the judgment dense authorship rule; reopening that requires a routing policy edit backed by a style fidelity bench result, not a capability score alone. - Cross engine review by default: the other peer or fusion:deep-reasoner reviews a substantial package before merge; /codex:adversarial-review challenges a design, /grok:review is the fast pass. - A plan with three or more independent packages uses every lane whose ownership or protected role fits, splitting proportionally rather than queueing everything on fusion:fast-worker; this proportional split is exactly what /fusion:ultra performs. Idle capacity alone never manufactures a Grok role. Multi source research fans out one track to a peer by default. - Balance check: on a delegating project, peer lanes carry a meaningful share of implementation output. A session where Claude worker lanes absorb spec grade or quick scoped packages while Codex sits idle is a routing defect. After two eligible packages have gone to Claude workers while Codex sits idle, route the next ordinary implementation package to Codex. Route to idle Grok only when a protected role fits and its circuit breaker is closed. @@ -138,7 +151,7 @@ These fire from plain language, not only from a typed slash command. Match the u ## Delegation rules -Every brief is self contained: goal, constraints, relevant paths, and what done looks like. Implementation and change briefs carry a verification command. Consult, research, and review briefs instead carry explicit coverage or acceptance criteria, with collection review as their verification. Subagents never see this conversation; a brief states the outcome and the checks, never the solution. Every Claude worker brief is a compact envelope that starts with `fusion-brief: v1` and includes `context-mode: isolated`, `goal:`, `scope:`, either `verification:` or `acceptance:`, and an optional `sizing:` field of `small`, `standard`, or `large` that scales the worker's tier budgets by half, one, or two while an environment override still takes precedence. The runtime guard rejects oversized briefs, missing fields, and requests to inherit the parent transcript. +Every brief is self contained: goal, constraints, relevant paths, and what done looks like. Implementation and change briefs carry a verification command. Consult, research, and review briefs instead carry explicit coverage or acceptance criteria, with collection review as their verification. Subagents never see this conversation; a brief states the outcome and the checks, never the solution. Every Claude worker brief is a compact envelope that starts with `fusion-brief: v1` and includes `context-mode: isolated`, `goal:`, `scope:`, either `verification:` or `acceptance:`, an optional `sizing:` field of `small`, `standard`, or `large` that scales the worker's tier budgets by half, one, or two while an environment override still takes precedence, and an optional `package-type:` field of `implementation`, `consult`, `review`, `research`, or `design` that tags the ledger record for routing telemetry. The runtime guard rejects oversized briefs, missing fields, and requests to inherit the parent transcript. - Briefs for data or repo work arrive pre staged: every brief includes exact paths and, for data work, one sample record or schema excerpt, so the worker starts executing instead of discovering; fixture heavy test work arrives with fixtures pre built or routes to a Codex or Claude lane. - A brief's output contract asks for a verdict envelope; long artifacts such as unified diffs and full test logs are written to a file whose path the envelope names, because an inlined report is carried in the orchestrator's context for the rest of the session while a path costs one line. @@ -147,10 +160,10 @@ Every brief is self contained: goal, constraints, relevant paths, and what done - Peer sandboxes cannot run process dependent suites or reach user toolchains; a peer implementation brief states that such failures are reported as environment findings with passing counts, and the orchestrator reruns the exact verification in a full environment before acceptance. - Before a fleet wave over an uncommitted baseline, the orchestrator records a checkpoint so isolated worktrees fork from the accumulated state; when no commit is authorized, the baseline rides as an applied and staged patch in each worktree and the package delta is exactly the unstaged diff. - Independent packages and independent sections of one task are decomposed and dispatched in a single message; sequential dispatch of independent work is a defect. Repeated width one dispatch turns on a goal with multiple independent packages are the signature of that defect, and the narrow wave watch in `/fusion:stats` surfaces it. Repeated narrow waves on a fleet shaped goal without a visible `fleet-decline: ` line are the same defect. -- Agent scheduling, companion delivery, engine process supervision, and Grok's native background tool waiting are separate layers. Every ordinary Agent is foreground delivery. The harness launches Agent calls asynchronously by default, but a launch receipt is not delivery: completion arrives as a task notification, which arms mandatory collection. Every dispatch still requires collection and verification before the package counts as done. Launch independent Agents together in one tool message for concurrency, but do not set `run_in_background` and do not accept an async launch receipt as delivery. `grok:grok-review-runner` is the sole managed exception allowed to use Agent `run_in_background`. Codex and Grok wrapper Agents always remain foreground. Only an incoming user request that explicitly contains companion `--background` authorizes the companion child to detach inside its foreground wrapper and return a manual receipt; it never authorizes background wrapper delivery. Fusion records every Claude worker as an owned task; if Claude Code still launches one asynchronously, the Stop hook blocks turn completion and requires `TaskOutput` collection, or `TaskStop` when a lifecycle budget has expired. Codex companion Bash calls stay foreground with the 10 minute tool timeout. Grok companion calls also remain foreground inside their owning Agent; expected duration does not authorize managed detachment. Ordinary Grok calls prefer `--no-wait-for-background` because their hard `Agent` deny also removes the native task lifecycle; a binary without that flag gets an explicit `--background-wait-timeout` shorter than the companion's outer deadline. That bounded internal wait is not companion detachment. The CLI child running in its own supervised process group is never by itself a user background job. The main loop never runs a work package or launches a detached shell. The coordinate micro step and single triage edit are not work packages. +- Agent scheduling, companion delivery, engine process supervision, and Grok's native background tool waiting are separate layers. Every ordinary Agent is foreground delivery. The harness launches Agent calls asynchronously by default, but a launch receipt is not delivery: completion arrives as a task notification, which arms mandatory collection. Every dispatch still requires collection and verification before the package counts as done. Launch independent Agents together in one tool message for concurrency, but do not set `run_in_background` and do not accept an async launch receipt as delivery. `grok:grok-review-runner` is the sole managed exception allowed to use Agent `run_in_background`. Codex and Grok wrapper Agents always remain foreground. Only an incoming user request that explicitly contains companion `--background` authorizes the companion child to detach inside its foreground wrapper and return a manual receipt; it never authorizes background wrapper delivery. Fusion records every Claude worker as an owned task; if Claude Code still launches one asynchronously, the Stop hook blocks turn completion and requires `TaskOutput` collection, or `TaskStop` when a lifecycle budget has expired. Codex companion Bash calls stay foreground with the 10 minute tool timeout. Grok companion calls also remain foreground inside their owning Agent; expected duration does not authorize managed detachment. Ordinary Grok calls prefer `--no-wait-for-background` because their hard `Agent` deny also removes the native task lifecycle; a binary without that flag gets an explicit `--background-wait-timeout` shorter than the companion's outer deadline. That bounded internal wait is not companion detachment. The CLI child running in its own supervised process group is never by itself a user background job. The main loop never runs a work package or launches a detached shell. The coordinate micro step, the single triage edit, and judgment dense authorship under the authorship rule are not work packages. - Heartbeat rule: a legitimate watch style wakeup on delegated work in flight emits one short user visible status line naming what is still in flight and when the next check happens. A wakeup with only tool calls and no visible text is a silent turn and is banned, and so is a hand rolled Bash or ScheduleWakeup polling loop used in place of fusion:job-collector. A wakeup with in flight work emits its status line and otherwise takes no tool action; informationless tool calls on advisory wakeups are a defect. - Same turn collection attempt: this obligation applies to a manual receipt that crosses into the main session from a detached job launched by Fusion orchestration. Follow it in the same turn through fusion:job-collector with a closed direct prompt containing exactly one standalone `engine: codex|grok` line and one standalone `job: <32 lowercase hexadecimal characters>` line. Do not describe or repeat either identity elsewhere in the prompt. The lifecycle binds the collected marker to this dispatch identity. The collector resolves the engine companion itself, performs global job lookup without a working-directory selector, and invokes status plus blocking result through fixed argv without shell command strings. Its single collection window is at most 540000ms. A timeout, dead job, or status error leaves the package uncollected and must reach the user with the Fusion task id, peer job id, the literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. Direct Codex and Grok slash commands with explicit `--background` return manual receipts for the user to inspect and collect through their status and result commands; monitors are notification only. A dispatch whose receipt is never followed by same-goal collection and verification is a contract failure. -- Transport completion is not semantic acceptance. The main session verifies every peer and Claude worker result before relying on it. Settlement is batched per wave: collect and verify every result in a wave, then record all verdicts in one `/fusion:stats --record` call with multiple `=` pairs, where each id is the fusion task id or the engine job id. Per package settlement turns are reserved for waves of one. One settlement validates every pair before writing and is idempotent to rerun after a partial transport failure; it writes the worker ledger and linked engine record for every recorded verdict. Strict record forms may pass as direct arguments, and anything carrying free text still uses the raw-args transport. Record accepted only when the verification command or explicit acceptance criteria pass. Record `rejected` when the collected result or verification fails. Leave it `unverified` only when no reliable judgment was possible. Every collected result is settled in the wave in which it is collected. `unverified` is temporary and must not survive the goal's completion. Stats default every terminal job without a recorded judgment to `unverified`; never infer acceptance from `state: done`, `delivery: complete`, or any transport status. +- Transport completion is not semantic acceptance. The main session verifies every peer and Claude worker result before relying on it. Settlement is batched per wave: collect and verify every result in a wave, then record all verdicts in one `/fusion:stats --record` call with multiple `=` pairs, where each id is the fusion task id or the engine job id. Per package settlement turns are reserved for waves of one. One settlement validates every pair before writing and is idempotent to rerun after a partial transport failure; it writes the worker ledger and linked engine record for every recorded verdict. Strict record forms may pass as direct arguments, and anything carrying free text still uses the raw-args transport. Record accepted only when the verification command or explicit acceptance criteria pass. Record `rejected` when the collected result or verification fails. A rejected verdict carries `--reason` and, when the cause is judgment shaped rather than mechanical, a `--failure-kind` of `intent_override`, `scope_rewrite`, `wrong_approach`, or `style_mismatch`, so intent and taste failures become measurable instead of living only in free text. Leave it `unverified` only when no reliable judgment was possible. Every collected result is settled in the wave in which it is collected. `unverified` is temporary and must not survive the goal's completion. Stats default every terminal job without a recorded judgment to `unverified`; never infer acceptance from `state: done`, `delivery: complete`, or any transport status. - A non deliverable final message starts an obligation, not an outcome: a background receipt created by Fusion and a truncated run ending in forward looking narration or missing verification are both unfinished. Resume a truncated, non forwarder agent with SendMessage to finish and report with verification; for a background receipt created by Fusion, dispatch fusion:job-collector instead. A foreground Codex receipt remains a contract failure under the preceding rule. - Related follow up packages reuse the existing worker thread via SendMessage instead of cold starting a new agent, unless the thread rotation rules in the fusion plugin's troubleshooting rules (shipped with the plugin, consulted on demand) say otherwise. - Parallel packages declare each other: every brief names sibling packages' files as intended in flight changes and forbids reverting, restoring, or cleaning anything outside its own list. A worker's end state check covers its own files only. diff --git a/plugins/fusion/rules/troubleshooting.md b/plugins/fusion/rules/troubleshooting.md index 4060bcd..59726f6 100644 --- a/plugins/fusion/rules/troubleshooting.md +++ b/plugins/fusion/rules/troubleshooting.md @@ -58,7 +58,7 @@ Codex exec refuses to start outside a directory with an ancestor `.git` entry. T ## Acceptance and worker lifecycle -`/fusion:stats --record` writes the verdict back to the Codex job record through the companion's `record-acceptance` subcommand. Recording `accepted` for a job whose transport failed requires the explicit `--accept-failed-transport` override. The stats report includes an acceptance anomalies block when transport and acceptance evidence conflict or when other acceptance integrity checks find a problem. +`/fusion:stats --record` writes the verdict back to the Codex job record through the companion's `record-acceptance` subcommand. Rejected verdicts require `--reason` via the raw args transport, and judgment shaped rejections may add `--failure-kind` (`intent_override`, `scope_rewrite`, `wrong_approach`, `style_mismatch`). Recording `accepted` for a job whose transport failed requires the explicit `--accept-failed-transport` override. The stats report includes an acceptance anomalies block when transport and acceptance evidence conflict or when other acceptance integrity checks find a problem. Worker lifecycle defaults are 1200000ms of wall clock, 300000ms of stall time, 240000 uncached tokens, and 60 turns. The output token budget is unchanged. The Stop hook adds a non-blocking advisory when collected workers remain acceptance unverified. diff --git a/plugins/fusion/scripts/fusion-stats.mjs b/plugins/fusion/scripts/fusion-stats.mjs index aa79b7a..d6d1028 100644 --- a/plugins/fusion/scripts/fusion-stats.mjs +++ b/plugins/fusion/scripts/fusion-stats.mjs @@ -27,6 +27,7 @@ const PRUNE_EVIDENCE = Symbol("pruneEvidence"); const FUSION_TASK_ID_PATTERN = /^fusion-[0-9a-f]{24}$/; const ENGINE_JOB_ID_PATTERN = /^[0-9a-f]{32}$/; const RECORD_VERDICTS = new Set(["accepted", "rejected", "unverified"]); +const SEMANTIC_FAILURE_KINDS = new Set(["intent_override", "scope_rewrite", "wrong_approach", "style_mismatch"]); const RECORD_SOURCES = new Set(["collector", "main-loop"]); const FUSION_ACCEPTANCE_EPOCH_ENV = "FUSION_ACCEPTANCE_EPOCH"; const DEFAULT_ACCEPTANCE_EPOCH = "2026-07-22T00:00:00Z"; @@ -2465,7 +2466,9 @@ function parseRecordArguments(argv, { allowPerPairReasons = false } = {}) { const records = []; let source = null; let reason = null; + let failureKind = null; const reasonsById = new Map(); + const failureKindsById = new Map(); let asJson = false; let acceptFailedTransport = false; for (let index = 0; index < argv.length; index += 1) { @@ -2491,6 +2494,25 @@ function parseRecordArguments(argv, { allowPerPairReasons = false } = {}) { index += 2; continue; } + if (token === "--failure-kind-for") { + const id = argv[index + 1]; + const pairFailureKind = argv[index + 2]; + if (!allowPerPairReasons) { + throw new TypeError("--failure-kind-for is available only through the raw-args transport."); + } + if (!SEMANTIC_FAILURE_KINDS.has(pairFailureKind)) { + throw new TypeError("The --failure-kind value must be one of intent_override, scope_rewrite, wrong_approach, style_mismatch."); + } + if ((!FUSION_TASK_ID_PATTERN.test(id) && !ENGINE_JOB_ID_PATTERN.test(id)) || failureKindsById.has(id)) { + throw new TypeError("--failure-kind-for requires one record id and one failure kind."); + } + if (records.at(-1)?.id !== id) { + throw new TypeError("--failure-kind-for must immediately follow its --record pair."); + } + failureKindsById.set(id, pairFailureKind); + index += 2; + continue; + } if (token === "--source") { if (source !== null || !RECORD_SOURCES.has(argv[index + 1])) { throw new TypeError("--source must be collector or main-loop."); @@ -2507,6 +2529,18 @@ function parseRecordArguments(argv, { allowPerPairReasons = false } = {}) { index += 1; continue; } + if (token === "--failure-kind") { + const candidate = argv[index + 1]; + if (!SEMANTIC_FAILURE_KINDS.has(candidate)) { + throw new TypeError("The --failure-kind value must be one of intent_override, scope_rewrite, wrong_approach, style_mismatch."); + } + if (failureKind !== null) { + throw new TypeError("Duplicate --failure-kind."); + } + failureKind = candidate; + index += 1; + continue; + } if (token === "--json" && !asJson) { asJson = true; continue; @@ -2529,11 +2563,21 @@ function parseRecordArguments(argv, { allowPerPairReasons = false } = {}) { if (reason !== null && reasonsById.size > 0) { throw new TypeError("Use either --reason or --reason-for, not both."); } + if (failureKind !== null && records.length !== 1) { + throw new TypeError("--failure-kind can be used only when exactly one --record pair is present. Use --failure-kind-for for each rejected pair in a batch."); + } + if (failureKind !== null && failureKindsById.size > 0) { + throw new TypeError("Use either --failure-kind or --failure-kind-for, not both."); + } for (const record of records) { if (record.verdict !== "rejected" && reasonsById.has(record.id)) { throw new TypeError(`--reason-for applies only to rejected record ${record.id}.`); } record.reason = reason ?? reasonsById.get(record.id) ?? null; + record.failureKind = failureKind ?? failureKindsById.get(record.id) ?? null; + if (record.verdict !== "rejected" && record.failureKind !== null) { + throw new TypeError(`--failure-kind applies only to rejected record ${record.id}.`); + } if (record.verdict === "rejected") { if (record.reason === null) { throw new TypeError(`Rejected verdict for ${record.id} requires --reason for a single pair or --reason-for for a batch.`); @@ -2600,26 +2644,26 @@ function writeRecordConfirmation({ kind, engine = null, jobId = null, worker = n stdout.write(`Recorded ${worker.acceptance} for Fusion worker task ${worker.taskId}.\n`); } -function settleWorkerRecord({ taskId, verdict, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { +function settleWorkerRecord({ taskId, verdict, source, reason, failureKind, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { const current = readWorkerRecord(taskId, env); const peerJobId = isTerminalWorkerStatus(current?.transportStatus) && typeof current.peerJobId === "string" && ENGINE_JOB_ID_PATTERN.test(current.peerJobId) ? current.peerJobId : null; if (!peerJobId) { - const settlement = recordWorkerAcceptance({ taskId, acceptance: verdict, env, source, reason, acceptFailedTransport }); + const settlement = recordWorkerAcceptance({ taskId, acceptance: verdict, env, source, reason, failureKind, acceptFailedTransport }); const worker = settlement.record; writeRecordConfirmation({ kind: "worker", worker, queued: settlement.queued, asJson, stdout }); return [worker]; } - validateWorkerAcceptance({ record: current, taskId, acceptance: verdict, source, reason, acceptFailedTransport }); + validateWorkerAcceptance({ record: current, taskId, acceptance: verdict, source, reason, failureKind, acceptFailedTransport }); const engine = current.peerEngine === "codex" || current.peerEngine === "grok" ? current.peerEngine : resolveEngineJob(peerJobId, env); try { - recordEngineAcceptance({ engine, jobId: peerJobId, acceptance: verdict, source, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + recordEngineAcceptance({ engine, jobId: peerJobId, acceptance: verdict, source, reason, failureKind, acceptFailedTransport, workspaceRoot, asJson, env }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Engine settlement failed for ${engine === "codex" ? "Codex" : "Grok"} job ${peerJobId}: ${message}`); } let settlement; try { - settlement = recordWorkerAcceptance({ taskId, acceptance: verdict, env, source, reason, acceptFailedTransport }); + settlement = recordWorkerAcceptance({ taskId, acceptance: verdict, env, source, reason, failureKind, acceptFailedTransport }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Engine settlement succeeded for ${engine === "codex" ? "Codex" : "Grok"} job ${peerJobId}, but Fusion worker task ${taskId} was not settled: ${message}`); @@ -2634,43 +2678,43 @@ function settleWorkerRecord({ taskId, verdict, source, reason, acceptFailedTrans return [worker, { engine, jobId: peerJobId, acceptance: verdict }]; } -function settleEngineRecord({ jobId, verdict, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { +function settleEngineRecord({ jobId, verdict, source, reason, failureKind, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { const engine = resolveEngineJob(jobId, env); const confirmation = { engine, jobId, acceptance: verdict }; - recordEngineAcceptance({ engine, jobId, acceptance: verdict, source, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + recordEngineAcceptance({ engine, jobId, acceptance: verdict, source, reason, failureKind, acceptFailedTransport, workspaceRoot, asJson, env }); writeRecordConfirmation({ kind: "engine", engine, jobId, worker: confirmation, asJson, stdout }); const writes = [confirmation]; for (const record of readWorkerRecords(env).filter((candidate) => candidate.peerJobId === jobId)) { - const settlement = recordWorkerAcceptance({ taskId: record.taskId, acceptance: verdict, env, source, reason, acceptFailedTransport }); + const settlement = recordWorkerAcceptance({ taskId: record.taskId, acceptance: verdict, env, source, reason, failureKind, acceptFailedTransport }); writeRecordConfirmation({ kind: "worker", worker: settlement.record, queued: settlement.queued, asJson, stdout }); writes.push(settlement.record); } return writes; } -function validateWorkerSettlement({ taskId, verdict, source, reason, acceptFailedTransport, env }) { +function validateWorkerSettlement({ taskId, verdict, source, reason, failureKind, acceptFailedTransport, env }) { const current = readWorkerRecord(taskId, env); - validateWorkerAcceptance({ record: current, taskId, acceptance: verdict, source, reason, acceptFailedTransport }); + validateWorkerAcceptance({ record: current, taskId, acceptance: verdict, source, reason, failureKind, acceptFailedTransport }); const peerJobId = isTerminalWorkerStatus(current?.transportStatus) && typeof current.peerJobId === "string" && ENGINE_JOB_ID_PATTERN.test(current.peerJobId) ? current.peerJobId : null; if (peerJobId && current.peerEngine !== "codex" && current.peerEngine !== "grok") { resolveEngineJob(peerJobId, env); } } -function validateEngineSettlement({ jobId, verdict, source, reason, acceptFailedTransport, env }) { +function validateEngineSettlement({ jobId, verdict, source, reason, failureKind, acceptFailedTransport, env }) { resolveEngineJob(jobId, env); for (const record of readWorkerRecords(env).filter((candidate) => candidate.peerJobId === jobId)) { - validateWorkerAcceptance({ record, taskId: record.taskId, acceptance: verdict, source, reason, acceptFailedTransport }); + validateWorkerAcceptance({ record, taskId: record.taskId, acceptance: verdict, source, reason, failureKind, acceptFailedTransport }); } } function validateRecordSettlements({ records, source, acceptFailedTransport, env }) { - for (const { id, verdict, reason = null, acceptFailedTransport: pairAcceptFailedTransport } of records) { + for (const { id, verdict, reason = null, failureKind = null, acceptFailedTransport: pairAcceptFailedTransport } of records) { const pairOverride = acceptFailedTransport || pairAcceptFailedTransport; if (FUSION_TASK_ID_PATTERN.test(id)) { - validateWorkerSettlement({ taskId: id, verdict, source, reason, acceptFailedTransport: pairOverride, env }); + validateWorkerSettlement({ taskId: id, verdict, source, reason, failureKind, acceptFailedTransport: pairOverride, env }); } else { - validateEngineSettlement({ jobId: id, verdict, source, reason, acceptFailedTransport: pairOverride, env }); + validateEngineSettlement({ jobId: id, verdict, source, reason, failureKind, acceptFailedTransport: pairOverride, env }); } } } @@ -2679,13 +2723,13 @@ function settleRecords({ records, source, acceptFailedTransport = false, asJson, validateRecordSettlements({ records, source, acceptFailedTransport, env }); const writes = []; const errors = []; - for (const { id, verdict, reason = null, acceptFailedTransport: pairAcceptFailedTransport } of records) { + for (const { id, verdict, reason = null, failureKind = null, acceptFailedTransport: pairAcceptFailedTransport } of records) { const pairOverride = acceptFailedTransport || pairAcceptFailedTransport; try { if (FUSION_TASK_ID_PATTERN.test(id)) { - writes.push(...settleWorkerRecord({ taskId: id, verdict, source, reason, acceptFailedTransport: pairOverride, asJson, workspaceRoot, env, stdout })); + writes.push(...settleWorkerRecord({ taskId: id, verdict, source, reason, failureKind, acceptFailedTransport: pairOverride, asJson, workspaceRoot, env, stdout })); } else { - writes.push(...settleEngineRecord({ jobId: id, verdict, source, reason, acceptFailedTransport: pairOverride, asJson, workspaceRoot, env, stdout })); + writes.push(...settleEngineRecord({ jobId: id, verdict, source, reason, failureKind, acceptFailedTransport: pairOverride, asJson, workspaceRoot, env, stdout })); } } catch (error) { const failure = { id, error }; diff --git a/plugins/fusion/scripts/lib/engine-acceptance.mjs b/plugins/fusion/scripts/lib/engine-acceptance.mjs index 746af59..f418288 100644 --- a/plugins/fusion/scripts/lib/engine-acceptance.mjs +++ b/plugins/fusion/scripts/lib/engine-acceptance.mjs @@ -95,12 +95,12 @@ function grokAcceptanceSubcommandUnavailable(result) { return /\b(?:unknown|unsupported|unrecognized)\s+(?:subcommand|command)\b/i.test(message) || /\brecord-acceptance\b.*\b(?:unknown|unsupported|unavailable|not found|not supported)\b/i.test(message) || /\b(?:cannot find module|ERR_MODULE_NOT_FOUND)\b/i.test(message); } -function recordCodexCompanionAcceptance({ jobId, acceptance, source, reason, acceptFailedTransport, workspaceRoot, env }) { +function recordCodexCompanionAcceptance({ jobId, acceptance, source, reason, failureKind, acceptFailedTransport, workspaceRoot, env }) { const bin = newestCodexCompanion(env); if (!bin) { return { updated: false }; } - const argv = [bin, "record-acceptance", "--job-id", jobId, "--acceptance", acceptance, ...(source ? ["--source", source] : []), ...(reason ? ["--reason", reason] : []), ...(acceptFailedTransport ? ["--accept-failed-transport"] : [])]; + const argv = [bin, "record-acceptance", "--job-id", jobId, "--acceptance", acceptance, ...(source ? ["--source", source] : []), ...(reason ? ["--reason", reason] : []), ...(failureKind ? ["--failure-kind", failureKind] : []), ...(acceptFailedTransport ? ["--accept-failed-transport"] : [])]; const result = spawnSync(process.execPath, argv, { cwd: workspaceRoot, encoding: "utf8", env }); if (!result.error && result.status === 0) { return { updated: true }; @@ -111,12 +111,12 @@ function recordCodexCompanionAcceptance({ jobId, acceptance, source, reason, acc throw new Error(companionFailureMessage(result) || "Codex acceptance record update failed."); } -function recordGrokCompanionAcceptance({ jobId, acceptance, reason, acceptFailedTransport, workspaceRoot, asJson, env }) { +function recordGrokCompanionAcceptance({ jobId, acceptance, reason, failureKind, acceptFailedTransport, workspaceRoot, asJson, env }) { const bin = newestGrokCompanion(env); if (!bin) { throw new GrokPluginUpgradeRequiredError("The Grok job was found, but its companion is unavailable. Upgrade the Grok plugin and retry."); } - const argv = [bin, "record-acceptance", "--job-id", jobId, "--acceptance", acceptance, ...(reason ? ["--reason", reason] : []), ...(acceptFailedTransport ? ["--accept-failed-transport"] : []), ...(asJson ? ["--json"] : [])]; + const argv = [bin, "record-acceptance", "--job-id", jobId, "--acceptance", acceptance, ...(reason ? ["--reason", reason] : []), ...(failureKind ? ["--failure-kind", failureKind] : []), ...(acceptFailedTransport ? ["--accept-failed-transport"] : []), ...(asJson ? ["--json"] : [])]; const result = spawnSync(process.execPath, argv, { cwd: workspaceRoot, encoding: "utf8", env }); if (!result.error && result.status === 0) { return { updated: true }; @@ -127,11 +127,11 @@ function recordGrokCompanionAcceptance({ jobId, acceptance, reason, acceptFailed throw new Error(companionFailureMessage(result) || "Grok acceptance record update failed."); } -export function recordEngineAcceptance({ engine, jobId, acceptance, source, reason, acceptFailedTransport = false, workspaceRoot, asJson = false, env = process.env, requireUpdate = true }) { +export function recordEngineAcceptance({ engine, jobId, acceptance, source, reason, failureKind, acceptFailedTransport = false, workspaceRoot, asJson = false, env = process.env, requireUpdate = true }) { if (engine === "grok") { - return recordGrokCompanionAcceptance({ jobId, acceptance, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + return recordGrokCompanionAcceptance({ jobId, acceptance, reason, failureKind, acceptFailedTransport, workspaceRoot, asJson, env }); } - const result = recordCodexCompanionAcceptance({ jobId, acceptance, source, reason, acceptFailedTransport, workspaceRoot, env }); + const result = recordCodexCompanionAcceptance({ jobId, acceptance, source, reason, failureKind, acceptFailedTransport, workspaceRoot, env }); if (!result.updated && requireUpdate) { throw new Error("Codex job record was not updated because the companion or subcommand is unavailable."); } diff --git a/plugins/fusion/scripts/lib/worker-state.mjs b/plugins/fusion/scripts/lib/worker-state.mjs index 6c9d71e..95b46c3 100644 --- a/plugins/fusion/scripts/lib/worker-state.mjs +++ b/plugins/fusion/scripts/lib/worker-state.mjs @@ -17,6 +17,7 @@ const TASK_OUTPUT_TRANSCRIPT_CHUNK_BYTES = 64 * 1024; const TASK_OUTPUT_TRANSCRIPT_MAX_LINE_BYTES = 1024 * 1024; const TERMINAL_STATUSES = new Set(["done", "incomplete", "failed", "cancelled", "owner_ended"]); const ACCEPTANCE_STATES = new Set(["accepted", "rejected", "unverified"]); +const SEMANTIC_FAILURE_KINDS = new Set(["intent_override", "scope_rewrite", "wrong_approach", "style_mismatch"]); export const WORKER_COLLECTION_METHODS = Object.freeze({ SUBAGENT_STOP: "subagent_stop", TASK_NOTIFICATION: "task_notification", @@ -302,6 +303,7 @@ export function createWorkerRecord(record, env = process.env) { userBackgroundAuthorized: record.userBackgroundAuthorized === true, transportStatus: "dispatching", acceptance: "unverified", + acceptanceFailureKind: record.acceptanceFailureKind ?? null, ...(PEER_JOB_FOOTER_AGENT_TYPES.has(record.agentType) ? { peerJobId: null } : {}), failureKind: null, deliveryMode: null, @@ -683,7 +685,7 @@ export function backfillWorkerTaskOutputTelemetry(record, transcriptPath) { }; } -function validatedAcceptanceFields(acceptance, source, reason) { +function validatedAcceptanceFields(acceptance, source, reason, failureKind) { if (!ACCEPTANCE_STATES.has(acceptance)) { throw new TypeError("Fusion worker acceptance must be accepted, rejected, or unverified."); } @@ -700,7 +702,13 @@ function validatedAcceptanceFields(acceptance, source, reason) { .trim() .slice(0, 240) : null; - return { safeReason }; + if (failureKind != null && !SEMANTIC_FAILURE_KINDS.has(failureKind)) { + throw new TypeError("Fusion worker acceptance failure kind must be one of intent_override, scope_rewrite, wrong_approach, style_mismatch."); + } + if (failureKind != null && acceptance !== "rejected") { + throw new TypeError("Fusion worker acceptance failure kind applies only to rejected acceptance."); + } + return { safeReason, safeFailureKind: failureKind ?? null }; } function workerAcceptanceGate(record, taskId, acceptance, acceptFailedTransport) { @@ -720,13 +728,14 @@ function acceptanceRecordedAt(now) { return now instanceof Date ? now.toISOString() : typeof now === "string" && !Number.isNaN(Date.parse(now)) ? now : new Date().toISOString(); } -function settleWorkerAcceptance(record, { acceptance, source, reason }, now) { +function settleWorkerAcceptance(record, { acceptance, source, reason, failureKind }, now) { const { pendingVerdict: _pendingVerdict, pendingVerdictError: _pendingVerdictError, ...settled } = record; return { ...settled, acceptance, acceptanceSource: source, acceptanceReason: reason, + acceptanceFailureKind: failureKind, acceptanceRecordedAt: acceptanceRecordedAt(now), awaitingVerdict: false, awaitingVerdictArmedAt: null @@ -747,7 +756,7 @@ export function applyQueuedVerdict(record, now) { if (!record?.pendingVerdict || !isTerminalWorkerStatus(record.transportStatus)) { return record; } - const { acceptance, source, reason, acceptFailedTransport = false } = record.pendingVerdict; + const { acceptance, source, reason, failureKind, acceptFailedTransport = false } = record.pendingVerdict; if (acceptance === "accepted" && record.transportStatus !== "done") { const { pendingVerdict: _pendingVerdict, ...unsettled } = record; return { @@ -758,9 +767,9 @@ export function applyQueuedVerdict(record, now) { }; } try { - const { safeReason } = validatedAcceptanceFields(acceptance, source, reason); + const { safeReason, safeFailureKind } = validatedAcceptanceFields(acceptance, source, reason, failureKind); workerAcceptanceGate(record, record.taskId, acceptance, acceptFailedTransport); - return settleWorkerAcceptance(record, { acceptance, source, reason: safeReason }, now); + return settleWorkerAcceptance(record, { acceptance, source, reason: safeReason, failureKind: safeFailureKind }, now); } catch (error) { return { ...record, @@ -777,7 +786,7 @@ export function isSettledWorker(record) { return record.acceptanceRecordedAt != null && record.awaitingVerdict !== true; } -function updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, validateRecord, acceptFailedTransport = false, queueIfNonTerminal = false }) { +function updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, safeFailureKind, validateRecord, acceptFailedTransport = false, queueIfNonTerminal = false }) { let queued = false; const updated = updateWorkerRecord(taskId, env, (record) => { if (!record) { @@ -795,34 +804,36 @@ function updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, v acceptance, source, reason: safeReason, + failureKind: safeFailureKind, queuedAt: new Date().toISOString(), ...(acceptFailedTransport ? { acceptFailedTransport: true } : {}) } }; } validateRecord(record); - return settleWorkerAcceptance(record, { acceptance, source, reason: safeReason }); + return settleWorkerAcceptance(record, { acceptance, source, reason: safeReason, failureKind: safeFailureKind }); }); return { record: updated, queued }; } -export function validateWorkerAcceptance({ record, taskId, acceptance, source = "main-loop", reason = null, acceptFailedTransport = false }) { - const { safeReason } = validatedAcceptanceFields(acceptance, source, reason); +export function validateWorkerAcceptance({ record, taskId, acceptance, source = "main-loop", reason = null, failureKind = null, acceptFailedTransport = false }) { + const { safeReason, safeFailureKind } = validatedAcceptanceFields(acceptance, source, reason, failureKind); if (!record) { throw new Error(`Fusion worker task ${taskId} was not found.`); } workerAcceptanceGate(record, taskId, acceptance, acceptFailedTransport); - return { safeReason }; + return { safeReason, safeFailureKind }; } -export function recordWorkerAcceptance({ taskId, acceptance, env = process.env, source = "main-loop", reason = null, acceptFailedTransport = false }) { - const { safeReason } = validatedAcceptanceFields(acceptance, source, reason); +export function recordWorkerAcceptance({ taskId, acceptance, env = process.env, source = "main-loop", reason = null, failureKind = null, acceptFailedTransport = false }) { + const { safeReason, safeFailureKind } = validatedAcceptanceFields(acceptance, source, reason, failureKind); const result = updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, + safeFailureKind, acceptFailedTransport, queueIfNonTerminal: true, validateRecord(record) { @@ -832,20 +843,21 @@ export function recordWorkerAcceptance({ taskId, acceptance, env = process.env, return workerAcceptanceResult(result.record, result.queued); } -export function recordCodexCollectorAcceptance({ taskId, jobId, sessionId, acceptance, env = process.env, source = "main-loop", reason = null }) { +export function recordCodexCollectorAcceptance({ taskId, jobId, sessionId, acceptance, env = process.env, source = "main-loop", reason = null, failureKind = null }) { if (typeof jobId !== "string" || !/^[a-f0-9]{32}$/.test(jobId)) { throw new TypeError("Codex collector job id is invalid."); } if (typeof sessionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) { throw new TypeError("Codex collector session id is invalid."); } - const { safeReason } = validatedAcceptanceFields(acceptance, source, reason); + const { safeReason, safeFailureKind } = validatedAcceptanceFields(acceptance, source, reason, failureKind); return updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, + safeFailureKind, validateRecord(record) { if (canonicalWorkerAgentType(record.agentType) !== "fusion:job-collector" || record.completionContract !== "collector") { throw new Error(`Fusion worker task ${taskId} is not a Codex collector.`); diff --git a/plugins/fusion/scripts/worker-lifecycle.mjs b/plugins/fusion/scripts/worker-lifecycle.mjs index 6ec6e6e..f03a76d 100644 --- a/plugins/fusion/scripts/worker-lifecycle.mjs +++ b/plugins/fusion/scripts/worker-lifecycle.mjs @@ -269,13 +269,13 @@ export function validateWorkerBrief(prompt, agentType, env = process.env) { } const packageTypeLines = prompt.split(/\r?\n/).filter((line) => /^package-type\s*:/i.test(line.trim())); if (packageTypeLines.length > 1) { - return { ok: false, reason: "Fusion worker brief `package-type:` may appear once and must be one of `implementation`, `consult`, `review`, or `research`." }; + return { ok: false, reason: "Fusion worker brief `package-type:` may appear once and must be one of `implementation`, `consult`, `review`, `research`, or `design`." }; } const packageType = packageTypeLines.length === 1 - ? packageTypeLines[0].match(/^package-type\s*:\s*(implementation|consult|review|research)\s*$/i)?.[1]?.toLowerCase() + ? packageTypeLines[0].match(/^package-type\s*:\s*(implementation|consult|review|research|design)\s*$/i)?.[1]?.toLowerCase() : /^verification:\s*\S/im.test(prompt) ? "implementation" : "consult"; if (!packageType) { - return { ok: false, reason: "Fusion worker brief `package-type:` must be one of `implementation`, `consult`, `review`, or `research`." }; + return { ok: false, reason: "Fusion worker brief `package-type:` must be one of `implementation`, `consult`, `review`, `research`, or `design`." }; } const sizingLines = prompt.split(/\r?\n/).filter((line) => /^sizing\s*:/i.test(line.trim())); if (sizingLines.length > 1) { @@ -961,6 +961,7 @@ function settleQueuedVerdict(record, now, env) { acceptance: settled.acceptance, source: pendingVerdict.source, reason: settled.acceptanceReason, + failureKind: settled.acceptanceFailureKind ?? null, acceptFailedTransport: pendingVerdict.acceptFailedTransport === true, workspaceRoot: typeof settled.workspaceRoot === "string" ? settled.workspaceRoot : process.cwd(), env diff --git a/plugins/fusion/skills/stats/SKILL.md b/plugins/fusion/skills/stats/SKILL.md index ca962ca..6cb7f88 100644 --- a/plugins/fusion/skills/stats/SKILL.md +++ b/plugins/fusion/skills/stats/SKILL.md @@ -1,13 +1,13 @@ --- name: stats description: Aggregates delegation statistics and records semantic acceptance. Use after collecting and verifying a delegated result to record it as accepted or rejected, or when asked how the lanes are performing. -argument-hint: '[--all] [--session [id]] [--trace] [--audit [--days ]] [--record =[:accept-failed-transport]]... [--source ] [--reason ] [--accept-failed-transport] [--json]' +argument-hint: '[--all] [--session [id]] [--trace] [--audit [--days ]] [--record =[:accept-failed-transport]]... [--source ] [--reason ] [--failure-kind ] [--failure-kind-for ] [--accept-failed-transport] [--json]' allowed-tools: Bash(node:*), Read, Write --- -Treat the complete raw request as opaque data and never place any part of it in Bash. Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/fusion-stats.mjs" transport-create` in one foreground Bash call, parse the returned JSON, accept only its 48 character lowercase hexadecimal token, and use `Read` once on the returned file, requiring it to be empty. Use `Write` to replace that same file with the raw request exactly as received. Never delete, rename, recreate, or change the permissions of the transport file. Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/fusion-stats.mjs" --raw-args-token TOKEN` in one foreground Bash call, replacing only `TOKEN` with the validated token. If Read fails, the file is not empty, or Write fails, run `node "${CLAUDE_PLUGIN_ROOT}/scripts/fusion-stats.mjs" transport-discard --raw-args-token TOKEN`. Never use shell redirection, encoding, command substitution, backticks, environment variables, or heredocs for the raw request. The sole carve-outs are two strict direct forms: a verdict settlement made only of repeatable `--record =[:accept-failed-transport]` pairs, optional `--source collector|main-loop`, and the value free `--json` and `--accept-failed-transport` flags; and a value free report or maintenance request made only of any combination of `--json`, `--audit`, `--all`, and `--prune-dead`, each at most once. Those arguments may be passed directly. Anything else, including every `--reason` text value and `--session `, still uses the raw-args transport exactly as described above. A nonterminal worker settlement prints `Queued accepted for ; applies when the record turns terminal.` with the selected verdict in place of `accepted`; queued accepted applies only when terminal transport is done, while any other terminal outcome clears it, records `pendingVerdictError`, and re-arms settlement. +Treat the complete raw request as opaque data and never place any part of it in Bash. Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/fusion-stats.mjs" transport-create` in one foreground Bash call, parse the returned JSON, accept only its 48 character lowercase hexadecimal token, and use `Read` once on the returned file, requiring it to be empty. Use `Write` to replace that same file with the raw request exactly as received. Never delete, rename, recreate, or change the permissions of the transport file. Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/fusion-stats.mjs" --raw-args-token TOKEN` in one foreground Bash call, replacing only `TOKEN` with the validated token. If Read fails, the file is not empty, or Write fails, run `node "${CLAUDE_PLUGIN_ROOT}/scripts/fusion-stats.mjs" transport-discard --raw-args-token TOKEN`. Never use shell redirection, encoding, command substitution, backticks, environment variables, or heredocs for the raw request. The sole carve-outs are two strict direct forms: a verdict settlement made only of repeatable `--record =[:accept-failed-transport]` pairs, optional `--source collector|main-loop`, and the value free `--json` and `--accept-failed-transport` flags; and a value free report or maintenance request made only of any combination of `--json`, `--audit`, `--all`, and `--prune-dead`, each at most once. Those arguments may be passed directly. Anything else, including every `--reason` text value, every `--failure-kind` and `--failure-kind-for` value, and `--session `, still uses the raw-args transport exactly as described above. A nonterminal worker settlement prints `Queued accepted for ; applies when the record turns terminal.` with the selected verdict in place of `accepted`; queued accepted applies only when terminal transport is done, while any other terminal outcome clears it, records `pendingVerdictError`, and re-arms settlement. -Present report output to the user as-is. It is already compact markdown; do not add prose around it. An unavailable engine section is expected when that peer's plugin is not installed or has no job state; do not treat it as an error. The underlying script accepts `--json` for machine readable output. `--audit` summarizes the last seven days of retained inline guard events by default; add `--all` for the full retained window or `--session ` for one session. `/fusion:stats --record` writes the verdict into the Codex job record through the companion's `record-acceptance` subcommand. An `accepted` verdict for a job whose transport failed requires `--accept-failed-transport`. The acceptance forms are used only after collection and verification; return the confirmation line without turning transport completion into acceptance on its own. Preserve the report's acceptance anomalies block when it appears. +Present report output to the user as-is. It is already compact markdown; do not add prose around it. An unavailable engine section is expected when that peer's plugin is not installed or has no job state; do not treat it as an error. The underlying script accepts `--json` for machine readable output. `--audit` summarizes the last seven days of retained inline guard events by default; add `--all` for the full retained window or `--session ` for one session. `/fusion:stats --record` writes the verdict into the Codex job record through the companion's `record-acceptance` subcommand. An `accepted` verdict for a job whose transport failed requires `--accept-failed-transport`. The acceptance forms are used only after collection and verification; return the confirmation line without turning transport completion into acceptance on its own. A judgment shaped rejection may carry `--failure-kind` with one of `intent_override`, `scope_rewrite`, `wrong_approach`, or `style_mismatch`; it rides the raw args transport like `--reason` and lands in the worker's `acceptanceFailureKind` and the engine record's `semanticFailureKind`. Preserve the report's acceptance anomalies block when it appears. The opaque raw request begins after the next newline and continues to the end of this command prompt: $ARGUMENTS diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 0d5b294..05dee52 100644 --- a/plugins/grok/.claude-plugin/plugin.json +++ b/plugins/grok/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "grok", "displayName": "Grok Companion", - "version": "0.0.39", + "version": "0.0.40", "description": "Local Grok CLI delegation: task, review, resumable history, background jobs, stats, and setup health checks.", "author": { "name": "Harry Yep" diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index 151f896..5f224f5 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -84,6 +84,7 @@ const DEFAULT_WAIT_POLL_MS = 2000; const DEFAULT_WAIT_TIMEOUT_MS = 570000; const RECORD_ACCEPTANCE_JOB_ID_PATTERN = /^[a-f0-9]{32}$/; const RECORD_ACCEPTANCE_VALUES = new Set(["accepted", "rejected", "unverified"]); +const SEMANTIC_FAILURE_KINDS = new Set(["intent_override", "scope_rewrite", "wrong_approach", "style_mismatch"]); const MAX_WAIT_TIMEOUT_MS = 570000; const CONTINUE_PROMPT = "Continue from the current thread state. Pick the next highest-value step and follow through until the task is resolved."; @@ -164,7 +165,7 @@ function printUsage() { " node scripts/grok-companion.mjs history [--all] [--limit ] [--cwd ] [--json]", " node scripts/grok-companion.mjs result [--cwd ] [--wait] [--wait-timeout-ms ] [--json]", " node scripts/grok-companion.mjs cancel [--cwd ] [--json]", - " node scripts/grok-companion.mjs record-acceptance --job-id <32 lowercase hex> --acceptance [--reason ] [--accept-failed-transport]", + " node scripts/grok-companion.mjs record-acceptance --job-id <32 lowercase hex> --acceptance [--reason ] [--failure-kind ] [--accept-failed-transport]", " node scripts/grok-companion.mjs stats [--all] [--cwd ] [--json]", " node scripts/grok-companion.mjs setup [--continuity ] [--enable-stop-gate] [--disable-stop-gate] [--json]", " node scripts/grok-companion.mjs stop-gate" @@ -183,9 +184,9 @@ function recordAcceptanceArgs(argv) { options.acceptFailedTransport = true; continue; } - const key = token === "--job-id" ? "jobId" : token === "--acceptance" ? "acceptance" : token === "--reason" ? "reason" : null; + const key = token === "--job-id" ? "jobId" : token === "--acceptance" ? "acceptance" : token === "--reason" ? "reason" : token === "--failure-kind" ? "failureKind" : null; if (!key) { - throw errorWithFailure("The record-acceptance command accepts only --job-id, --acceptance, --reason, and --accept-failed-transport.", "input"); + throw errorWithFailure("The record-acceptance command accepts only --job-id, --acceptance, --reason, --failure-kind, and --accept-failed-transport.", "input"); } if (Object.hasOwn(options, key)) { throw errorWithFailure(`${token} may be provided only once.`, "input"); @@ -203,6 +204,15 @@ function recordAcceptanceArgs(argv) { if (!RECORD_ACCEPTANCE_VALUES.has(options.acceptance)) { throw errorWithFailure("The --acceptance option must be accepted, rejected, or unverified.", "input"); } + if (options.failureKind !== undefined && !SEMANTIC_FAILURE_KINDS.has(options.failureKind)) { + throw errorWithFailure("The --failure-kind option must be intent_override, scope_rewrite, wrong_approach, or style_mismatch.", "input"); + } + if (options.failureKind !== undefined && options.acceptance !== "rejected") { + throw errorWithFailure("The --failure-kind option is valid only with --acceptance rejected.", "input"); + } + if (options.acceptance === "rejected" && !options.reason?.trim()) { + throw errorWithFailure("A rejected acceptance requires --reason.", "input"); + } return options; } @@ -3003,7 +3013,7 @@ async function handleStopGate() { } function handleRecordAcceptance(argv) { - const { acceptance, acceptFailedTransport, jobId, reason } = recordAcceptanceArgs(argv); + const { acceptance, acceptFailedTransport, failureKind, jobId, reason } = recordAcceptanceArgs(argv); const found = findJobRecordById(resolveDataDir(), jobId); if (!found) { throw errorWithFailure(`No job record found for ${jobId}.`, "input"); @@ -3025,7 +3035,8 @@ function handleRecordAcceptance(argv) { } return { ...current, - ...(reason === undefined ? {} : { semanticFailureMessage: reason }), + semanticFailureKind: failureKind ?? null, + semanticFailureMessage: reason, semanticStatus: acceptance }; }, { allowTerminal: true }); diff --git a/tests/codex-companion.test.mjs b/tests/codex-companion.test.mjs index 8b251d2..dcaf029 100644 --- a/tests/codex-companion.test.mjs +++ b/tests/codex-companion.test.mjs @@ -933,16 +933,55 @@ test("record-acceptance stores accepted and rejected semantic verdicts", (t) => let [record] = jobRecords(sandbox); assert.equal(record.semanticStatus, "accepted"); - const rejected = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected", "--reason", "Verification did not pass."], { + const rejected = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected", "--reason", "Verification did not pass.", "--failure-kind", "intent_override"], { cwd: sandbox.workDir, env: envFor(sandbox) }); assert.equal(rejected.status, 0, rejected.stderr); [record] = jobRecords(sandbox); assert.equal(record.semanticStatus, "rejected"); + assert.equal(record.semanticFailureKind, "intent_override"); assert.equal(record.semanticFailureMessage, "Verification did not pass."); }); +test("record-acceptance requires a reason for rejected verdicts", (t) => { + const sandbox = makeSandbox(t); + const id = "1".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + assert.equal(result.stderr, "A rejected acceptance requires --reason.\nstate: error\nfailure: input\n"); +}); + +test("record-acceptance permits failure kinds only for rejected verdicts", (t) => { + const sandbox = makeSandbox(t); + const id = "2".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "accepted", "--failure-kind", "intent_override"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--failure-kind option is valid only with --acceptance rejected/); + assert.match(result.stderr, /failure: input/); +}); + +test("record-acceptance rejects unknown semantic failure kinds", (t) => { + const sandbox = makeSandbox(t); + const id = "3".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected", "--reason", "Verification did not pass.", "--failure-kind", "unknown"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /intent_override, scope_rewrite, wrong_approach, or style_mismatch/); + assert.match(result.stderr, /failure: input/); +}); + test("record-acceptance stamps default stats provenance", (t) => { const sandbox = makeSandbox(t); const id = "e".repeat(32); diff --git a/tests/codex-state.test.mjs b/tests/codex-state.test.mjs index 984e40d..b408e6d 100644 --- a/tests/codex-state.test.mjs +++ b/tests/codex-state.test.mjs @@ -637,6 +637,17 @@ test("structurally invalid records are preserved for forward compatibility and f assert.deepStrictEqual(listJobRecords(sandbox.dataDir, sandbox.workDir), []); }); +test("semantic failure kinds accept legacy policy records and reject unknown values", (t) => { + const sandbox = makeSandbox(t); + const legacy = makeRecord(sandbox, { id: "legacy-policy", semanticFailureKind: "policy" }); + assert.equal(legacy.record.semanticFailureKind, "policy"); + + const invalidFile = jobFilePath(sandbox.dataDir, sandbox.workDir, "invalid-semantic-failure-kind"); + const invalid = { ...createJobRecord({ cwd: sandbox.workDir, id: "invalid-semantic-failure-kind" }), semanticFailureKind: "arbitrary" }; + fs.writeFileSync(invalidFile, `${JSON.stringify(invalid)}\n`, { mode: 0o600 }); + assert.equal(readJobRecordFile(invalidFile), null); +}); + test("global lookup rejects an id that is ambiguous across workspaces", (t) => { const sandbox = makeSandbox(t); const otherWorkDir = path.join(sandbox.root, "other-work"); diff --git a/tests/fusion-raw-args-transport.test.mjs b/tests/fusion-raw-args-transport.test.mjs index e63a880..9938235 100644 --- a/tests/fusion-raw-args-transport.test.mjs +++ b/tests/fusion-raw-args-transport.test.mjs @@ -153,3 +153,32 @@ test("the stats CLI forwards --accept-failed-transport from staged arguments unc assert.strictEqual(result.status, 0, result.stderr); assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "main-loop", "--accept-failed-transport"]); }); + +test("the stats CLI forwards --failure-kind-for from staged arguments", (t) => { + const directory = sandbox(t); + const companion = path.join(directory, "codex-companion.mjs"); + const argsFile = path.join(directory, "companion-args.json"); + const jobId = "b".repeat(32); + fs.writeFileSync(companion, 'import fs from "node:fs";\nfs.writeFileSync(process.env.FUSION_TEST_CODEX_COMPANION_ARGS, JSON.stringify(process.argv.slice(2)));\n'); + const env = { + ...process.env, + CLAUDE_CODE_SESSION_ID: "fusion-stats-transport-semantic-failure-test", + FUSION_CODEX_STATE: path.join(directory, "missing-codex"), + FUSION_CODEX_COMPANION: companion, + FUSION_GROK_COMPANION: path.join(directory, "missing-grok.mjs"), + FUSION_DATA_DIR: path.join(directory, "fusion"), + FUSION_TEST_CODEX_COMPANION_ARGS: argsFile, + FUSION_WORKER_STATE_DIR: path.join(directory, "missing-workers") + }; + const created = spawnSync(process.execPath, [SCRIPT, "transport-create"], { cwd: directory, env, encoding: "utf8" }); + assert.strictEqual(created.status, 0, created.stderr); + const transport = JSON.parse(created.stdout); + const jobsDirectory = path.join(env.FUSION_CODEX_STATE, "workspace", "jobs"); + fs.mkdirSync(jobsDirectory, { recursive: true }); + fs.writeFileSync(path.join(jobsDirectory, `${jobId}.json`), JSON.stringify({ id: jobId, workspaceRoot: directory, status: "done" })); + fs.writeFileSync(transport.file, `--record ${jobId}=rejected --failure-kind-for ${jobId} scope_rewrite --reason "The output did not satisfy the requested scope."`); + + const result = spawnSync(process.execPath, [SCRIPT, "--raw-args-token", transport.token], { cwd: directory, env, encoding: "utf8" }); + assert.strictEqual(result.status, 0, result.stderr); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected", "--source", "main-loop", "--reason", "The output did not satisfy the requested scope.", "--failure-kind", "scope_rewrite"]); +}); diff --git a/tests/fusion-stats.test.mjs b/tests/fusion-stats.test.mjs index 9eee77e..00ba460 100644 --- a/tests/fusion-stats.test.mjs +++ b/tests/fusion-stats.test.mjs @@ -1600,9 +1600,10 @@ test("--record resolves a bare Codex job with a raw transport reason and settles createTerminalWorker({ taskId, env, workspaceRoot: dir, peerEngine: "codex", peerJobId: jobId }); const reason = "The result did not satisfy the requested behavior."; + const failureKind = "wrong_approach"; const result = run( { cwd: dir, codexState: stateRoot }, - ["--record", `${jobId}=rejected`, "--reason", reason, "--json"], + ["--record", `${jobId}=rejected`, "--reason", reason, "--failure-kind", failureKind, "--json"], { ...env, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } ); @@ -1611,9 +1612,10 @@ test("--record resolves a bare Codex job with a raw transport reason and settles { kind: "engine", engine: "codex", jobId, acceptance: "rejected" }, { kind: "worker", taskId, acceptance: "rejected" } ]); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected", "--source", "main-loop", "--reason", reason]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected", "--source", "main-loop", "--reason", reason, "--failure-kind", failureKind]); assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "rejected"); assert.strictEqual(readWorkerRecord(taskId, env).acceptanceReason, reason); + assert.strictEqual(readWorkerRecord(taskId, env).acceptanceFailureKind, failureKind); }); test("--record refuses a rejected strict direct argument", (t) => { @@ -1635,6 +1637,60 @@ test("--record refuses a rejected strict direct argument", (t) => { assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "unverified"); }); +test("--record with --failure-kind requires the raw-args transport", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const taskId = `fusion-${"e".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId, env, workspaceRoot: dir }); + + const result = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${taskId}=rejected`, "--reason", "The output did not meet the requested scope.", "--failure-kind", "scope_rewrite"], + env + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /Fusion stats requests must be supplied through --raw-args-token\./); + assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "unverified"); +}); + +test("--record rejects semantic failure kinds on non-rejected verdicts", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const taskId = `fusion-${"f".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId, env, workspaceRoot: dir }); + + const result = run( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${taskId}=accepted`, "--failure-kind", "intent_override"], + env + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`--failure-kind applies only to rejected record ${taskId}`)); + assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "unverified"); +}); + +test("--record rejects invalid semantic failure kinds", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const taskId = `fusion-${"0".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId, env, workspaceRoot: dir }); + + const result = run( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${taskId}=rejected`, "--reason", "The output did not meet the requested scope.", "--failure-kind", "not_a_kind"], + env + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, /The --failure-kind value must be one of intent_override, scope_rewrite, wrong_approach, style_mismatch\./); + assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "unverified"); +}); + test("--record resolves a bare Grok job and settles matching worker rows", (t) => { const dir = sandbox(t); const grokData = path.join(dir, "grok-data"); @@ -1677,26 +1733,31 @@ test("--record rejects an engine id that exists in both peer states", (t) => { assert.match(result.stderr, /exists in both Codex and Grok state/); }); -test("--record batches mixed verdicts with per-pair rejection reasons", (t) => { +test("--record batches mixed verdicts with per-pair rejection reasons and failure kinds", (t) => { const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); const firstTaskId = `fusion-${"2".repeat(24)}`; const secondTaskId = `fusion-${"3".repeat(24)}`; + const jobId = "4".repeat(32); + const argsFile = path.join(dir, "codex-companion-args.json"); + const companion = writeCodexAcceptanceCompanion(dir); const env = { FUSION_WORKER_STATE_DIR: stateDir }; createTerminalWorker({ taskId: firstTaskId, env, workspaceRoot: dir }); - createTerminalWorker({ taskId: secondTaskId, env, workspaceRoot: dir }); + createTerminalWorker({ taskId: secondTaskId, env, workspaceRoot: dir, peerEngine: "codex", peerJobId: jobId }); const result = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record", `${firstTaskId}=accepted`, "--record", `${secondTaskId}=rejected`, "--reason-for", secondTaskId, "The result did not satisfy the requested behavior."], - env + ["--record", `${firstTaskId}=accepted`, "--record", `${secondTaskId}=rejected`, "--reason-for", secondTaskId, "The result did not satisfy the requested behavior.", "--failure-kind-for", secondTaskId, "scope_rewrite"], + { ...env, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } ); assert.strictEqual(result.status, 0, result.stderr); - assert.strictEqual(result.stdout, `Recorded accepted for Fusion worker task ${firstTaskId}.\nRecorded rejected for Fusion worker task ${secondTaskId}.\n`); + assert.strictEqual(result.stdout, `Recorded accepted for Fusion worker task ${firstTaskId}.\nRecorded rejected for Fusion worker task ${secondTaskId}.\nRecorded rejected for Codex job ${jobId}.\n`); assert.strictEqual(readWorkerRecord(firstTaskId, env).acceptance, "accepted"); assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptance, "rejected"); assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptanceReason, "The result did not satisfy the requested behavior."); + assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptanceFailureKind, "scope_rewrite"); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "rejected", "--source", "main-loop", "--reason", "The result did not satisfy the requested behavior.", "--failure-kind", "scope_rewrite"]); }); test("--record rejects a raw batch with a rejected pair that has no reason before writing", (t) => { diff --git a/tests/fusion-worker-lifecycle.test.mjs b/tests/fusion-worker-lifecycle.test.mjs index 20b5bf6..d9a5485 100644 --- a/tests/fusion-worker-lifecycle.test.mjs +++ b/tests/fusion-worker-lifecycle.test.mjs @@ -384,7 +384,7 @@ test("trivial worker limits raise token budgets and retain environment overrides }); test("package-type envelopes validate, default, and persist their byte length", (t) => { - for (const packageType of ["implementation", "consult", "review", "research"]) { + for (const packageType of ["implementation", "consult", "review", "research", "design"]) { const prompt = `${brief()}package-type: ${packageType}\n`; const validation = validateWorkerBrief(prompt, "fusion:fast-worker"); assert.strictEqual(validation.ok, true); @@ -4380,7 +4380,7 @@ test("queued accepted verdicts re-arm settlement after cancelled and incomplete assert.match(incompleteRecord.pendingVerdictError, /transport status is incomplete/); }); -test("a queued accepted done transition records the linked engine verdict", (t) => { +test("a queued rejected done transition records the linked engine verdict with a semantic failure kind", (t) => { const box = sandbox(t); const peerJobId = "d".repeat(32); const argsFile = path.join(box.root, "codex-companion-args.json"); @@ -4395,7 +4395,7 @@ test("a queued accepted done transition records the linked engine verdict", (t) tool_response: { isAsync: true, status: "async_launched", agentId: "queued-peer-notification", outputFile: workerTranscript } }); updateWorkerRecord(record(box).taskId, envFor(box), (current) => ({ ...current, peerEngine: "codex", peerJobId, peerTransportStatus: "done" })); - recordWorkerAcceptance({ taskId: record(box).taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + recordWorkerAcceptance({ taskId: record(box).taskId, acceptance: "rejected", env: envFor(box), source: "main-loop", reason: "The result did not meet the requested behavior.", failureKind: "wrong_approach" }); fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: "\nqueued-peer-notification\ncompleted\n" } })}\n`, "utf8"); const stopped = run(box, { @@ -4410,9 +4410,10 @@ test("a queued accepted done transition records the linked engine verdict", (t) assert.strictEqual(stopped.stdout, ""); const settled = record(box); assert.strictEqual(settled.transportStatus, "done"); - assert.strictEqual(settled.acceptance, "accepted"); + assert.strictEqual(settled.acceptance, "rejected"); + assert.strictEqual(settled.acceptanceFailureKind, "wrong_approach"); assert.strictEqual(settled.engineSettlementError, undefined); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", peerJobId, "--acceptance", "accepted", "--source", "main-loop"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", peerJobId, "--acceptance", "rejected", "--source", "main-loop", "--reason", "The result did not meet the requested behavior.", "--failure-kind", "wrong_approach"]); }); test("the peer wrapper SubagentStop matcher reaches job footer collection", (t) => { diff --git a/tests/fusion-worker-state.test.mjs b/tests/fusion-worker-state.test.mjs index d809a15..bc5d1ea 100644 --- a/tests/fusion-worker-state.test.mjs +++ b/tests/fusion-worker-state.test.mjs @@ -82,16 +82,40 @@ test("applyQueuedVerdict settles queued rejected on a failed terminal", () => { acceptance: "rejected", source: "main-loop", reason: "verification failed", + failureKind: "style_mismatch", queuedAt: "2026-07-22T00:00:00.000Z" } }), "2026-07-22T00:03:00.000Z"); assert.strictEqual(settled.acceptance, "rejected"); + assert.strictEqual(settled.acceptanceFailureKind, "style_mismatch"); assert.strictEqual(settled.acceptanceRecordedAt, "2026-07-22T00:03:00.000Z"); assert.strictEqual(settled.awaitingVerdict, false); assert.strictEqual(settled.pendingVerdict, undefined); }); +test("worker acceptance preserves semantic failure kinds through settlement and queued verdicts", (t) => { + const directory = sandbox(t); + const env = { FUSION_WORKER_STATE_DIR: path.join(directory, "worker-state") }; + const settledTaskId = "fusion-semantic-settled"; + const queuedTaskId = "fusion-semantic-queued"; + const baseline = createWorkerRecord({ taskId: settledTaskId, sessionId: "session-semantic", dispatchToolUseId: "tool-semantic", agentType: "fusion:fast-worker", workspaceRoot: directory, limits: {} }, env); + assert.strictEqual(baseline.acceptanceFailureKind, null); + updateWorkerRecord(settledTaskId, env, (record) => ({ ...record, transportStatus: "done" })); + + const settled = recordWorkerAcceptance({ taskId: settledTaskId, acceptance: "rejected", env, failureKind: "intent_override" }); + assert.strictEqual(settled.record.acceptanceFailureKind, "intent_override"); + + createWorkerRecord({ taskId: queuedTaskId, sessionId: "session-semantic", dispatchToolUseId: "tool-semantic-queued", agentType: "fusion:fast-worker", workspaceRoot: directory, limits: {} }, env); + const queued = recordWorkerAcceptance({ taskId: queuedTaskId, acceptance: "rejected", env, failureKind: "scope_rewrite" }); + assert.strictEqual(queued.queued, true); + assert.strictEqual(queued.record.pendingVerdict.failureKind, "scope_rewrite"); + + const applied = updateWorkerRecord(queuedTaskId, env, (record) => applyQueuedVerdict({ ...record, transportStatus: "done" }, "2026-07-22T00:05:00.000Z")); + assert.strictEqual(applied.acceptanceFailureKind, "scope_rewrite"); + assert.strictEqual(applied.pendingVerdict, undefined); +}); + test("pending settlement clears after recordWorkerAcceptance and applyQueuedVerdict", (t) => { const directory = sandbox(t); const env = { FUSION_WORKER_STATE_DIR: path.join(directory, "worker-state") }; diff --git a/tests/grok-record-acceptance.test.mjs b/tests/grok-record-acceptance.test.mjs index 8948866..1264c06 100644 --- a/tests/grok-record-acceptance.test.mjs +++ b/tests/grok-record-acceptance.test.mjs @@ -45,16 +45,55 @@ test("record-acceptance stores accepted and rejected semantic verdicts", (t) => let [record] = jobRecords(sandbox.dataDir); assert.equal(record.semanticStatus, "accepted"); - const rejected = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected", "--reason", "Verification did not pass."], { + const rejected = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected", "--reason", "Verification did not pass.", "--failure-kind", "intent_override"], { cwd: sandbox.workDir, env: envFor(sandbox) }); assert.equal(rejected.status, 0, rejected.stderr); [record] = jobRecords(sandbox.dataDir); assert.equal(record.semanticStatus, "rejected"); + assert.equal(record.semanticFailureKind, "intent_override"); assert.equal(record.semanticFailureMessage, "Verification did not pass."); }); +test("record-acceptance requires a reason for rejected verdicts", (t) => { + const sandbox = makeSandbox(t); + const id = "1".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + assert.equal(result.stderr, "A rejected acceptance requires --reason.\nstate: error\nfailure: input\n"); +}); + +test("record-acceptance permits failure kinds only for rejected verdicts", (t) => { + const sandbox = makeSandbox(t); + const id = "2".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "accepted", "--failure-kind", "intent_override"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--failure-kind option is valid only with --acceptance rejected/); + assert.match(result.stderr, /failure: input/); +}); + +test("record-acceptance rejects unknown semantic failure kinds", (t) => { + const sandbox = makeSandbox(t); + const id = "3".repeat(32); + seedTerminalJob(sandbox, { id, status: "done" }); + const result = runCompanion(["record-acceptance", "--job-id", id, "--acceptance", "rejected", "--reason", "Verification did not pass.", "--failure-kind", "unknown"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /intent_override, scope_rewrite, wrong_approach, or style_mismatch/); + assert.match(result.stderr, /failure: input/); +}); + test("record-acceptance blocks accepted verdicts on failed transport by default", (t) => { const sandbox = makeSandbox(t); const id = "b".repeat(32); diff --git a/tests/raw-transport-surface.test.mjs b/tests/raw-transport-surface.test.mjs index deb14f5..8348734 100644 --- a/tests/raw-transport-surface.test.mjs +++ b/tests/raw-transport-surface.test.mjs @@ -93,7 +93,7 @@ test("every executable private transport surface follows its declared Write tran assert.match(content, /sole carve-outs are two strict direct forms: a verdict settlement/, file); assert.match(content, /repeatable `--record =\[:accept-failed-transport\]` pairs, optional `--source collector\|main-loop`, and the value free `--json` and `--accept-failed-transport` flags/, file); assert.match(content, /a value free report or maintenance request made only of any combination of `--json`, `--audit`, `--all`, and `--prune-dead`, each at most once/, file); - assert.match(content, /Anything else, including every `--reason` text value and `--session `, still uses the raw-args transport exactly as described above\./, file); + assert.match(content, /Anything else, including every `--reason` text value, every `--failure-kind` and `--failure-kind-for` value, and `--session `, still uses the raw-args transport exactly as described above\./, file); } } });