From d258c492c902fbfef3cf7ab69189f8fc15e76912 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Wed, 22 Jul 2026 15:37:54 +0800 Subject: [PATCH 1/7] refactor(fusion): centralize peer acceptance settlement and drop legacy codex plugin state --- plugins/fusion/rules/troubleshooting.md | 18 +- plugins/fusion/scripts/breaker-check.mjs | 25 +- plugins/fusion/scripts/codex-jobs-monitor.mjs | 61 +- plugins/fusion/scripts/fusion-stats.mjs | 504 +++-------- .../fusion/scripts/lib/codex-state-roots.mjs | 5 +- .../fusion/scripts/lib/engine-acceptance.mjs | 139 ++++ plugins/fusion/scripts/lib/worker-state.mjs | 143 +++- plugins/fusion/skills/doctor/SKILL.md | 8 +- plugins/fusion/skills/stats/SKILL.md | 6 +- tests/breaker-check.test.mjs | 145 +--- tests/codex-jobs-monitor.test.mjs | 204 ++--- tests/fusion-raw-args-transport.test.mjs | 14 +- tests/fusion-stats.test.mjs | 784 ++++-------------- tests/fusion-worker-state.test.mjs | 117 +++ tests/raw-transport-surface.test.mjs | 2 +- 15 files changed, 757 insertions(+), 1418 deletions(-) create mode 100644 plugins/fusion/scripts/lib/engine-acceptance.mjs create mode 100644 tests/fusion-worker-state.test.mjs diff --git a/plugins/fusion/rules/troubleshooting.md b/plugins/fusion/rules/troubleshooting.md index 44115ec..4060bcd 100644 --- a/plugins/fusion/rules/troubleshooting.md +++ b/plugins/fusion/rules/troubleshooting.md @@ -56,27 +56,25 @@ The fleet mode state file has no writer within the plugin, so disable the fleet Codex exec refuses to start outside a directory with an ancestor `.git` entry. The `projects` trust map in `config.toml` is not consulted by exec. Only `--skip-git-repo-check` or the dangerous bypass flag skips this gate. The companion forwards `--skip-git-repo-check`, and its failure output names that remedy. A directory that is trusted in configuration is not therefore a valid exec directory without an ancestor Git entry. -Codex CLI 0.144.4 requires `supports_reasoning_summaries` in `models_cache.json` without a serde default. Codex 0.145.0 alpha releases renamed the field to `supports_reasoning_summary_parameter` and added a default. A newer generation binary that shares `CODEX_HOME`, including ChatGPT.app's bundled codex-cli 0.145.0-alpha.18 at `Contents/Resources/codex`, can rewrite the cache in the new schema. A 0.144.4 exec session then logs non-fatal `failed to renew cache TTL: missing field` messages on each etag renewal. Startup refetch self-heals the file. The durable fix is version alignment among all Codex binaries sharing the same `CODEX_HOME`. - ## Acceptance and worker lifecycle -`/fusion:stats --record-acceptance` 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. 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. ## Grok headless guardrails -Treat Grok's parsed flags and its effective headless behavior as different things. 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 single-turn resolver also does not forward `--no-ask-user`, `--no-plan`, `--no-memory`, or `--worktree-ref`. Every non-best-of-n call therefore needs a bare `--disallowed-tools Agent`, `GROK_SUBAGENTS=0`, and explicit denies for `ask_user_question`, `search_tool`, `use_tool`, and all MCP tools. The companion must overwrite inherited memory state with `GROK_MEMORY=0` by default and may set it to `1` only for an ordinary user-selected task with explicit `--memory`; review, stop gate, best-of-n, and automatically routed Fusion briefs never enable it. 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_CODE_*`, `CLAUDE_PLUGIN_*`, and `GROK_COMPANION_*` variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, and broadly scrubs secret-bearing variables except required xAI authentication. It also passes `--no-auto-update`; upstream minimum-version enforcement can still force an update, so do not treat that flag as a complete network or executable immutability guarantee. +Treat Grok's parsed flags and its effective headless behavior as different things. 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 single-turn resolver also does not forward `--no-ask-user`, `--no-plan`, `--no-memory`, or `--worktree-ref`. Every call therefore needs a bare `--disallowed-tools Agent`, `GROK_SUBAGENTS=0`, and explicit denies for `ask_user_question`, `search_tool`, `use_tool`, and all MCP tools. The companion must overwrite inherited memory state with `GROK_MEMORY=0` by default and may set it to `1` only for an ordinary user-selected task with explicit `--memory`; review, stop gate, and automatically routed Fusion briefs never enable it. 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_CODE_*`, `CLAUDE_PLUGIN_*`, and `GROK_COMPANION_*` variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, and broadly scrubs secret-bearing variables except required xAI authentication. It also passes `--no-auto-update`; upstream minimum-version enforcement can still force an update, so do not treat that flag as a complete network or executable immutability guarantee. -Every run forces `RUST_LOG=xai_grok_agent::builder=debug,xai_grok_sandbox=warn`. The `xai_grok_sandbox=warn` directive carries upstream sandbox failure warnings to the owned stderr stream. Require builder tracing on every managed run. Upstream initializes it only after reading the complete stdin prompt, so it cannot attest the allowlist before prompt delivery or every possible side effect. Terminate with verified cleanup as soon as a fallback or unmatched warning appears, and reject a nominally successful close unless positive `tools allowlist applied` evidence was observed. An unmappable `--tools` entry can make Grok retain its full tool surface, and an unmatched disallowed rule can mean the boundary never existed. Consult also has explicit Bash, Edit, Write, MCP, Agent, and meta-tool denies; write is already a user-authorized modification surface. Do not retry by removing or weakening a restriction. The write list is `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`, plus explicitly enabled web tools and Agent only for best-of-n. There is no `write` tool id; `search_replace` can create a file. Command-pattern denies for common direct grok, claude, and codex calls can be bypassed with absolute paths, aliases or functions, or indirect scripts. They reduce accidental recursion but are not hard confinement while `run_terminal_cmd` remains available; that requires removing the terminal tool or an OS-level executable or network policy. +Every run forces `RUST_LOG=xai_grok_agent::builder=debug,xai_grok_sandbox=warn`. The `xai_grok_sandbox=warn` directive carries upstream sandbox failure warnings to the owned stderr stream. Require builder tracing on every managed run. Upstream initializes it only after reading the complete stdin prompt, so it cannot attest the allowlist before prompt delivery or every possible side effect. Terminate with verified cleanup as soon as a fallback or unmatched warning appears, and reject a nominally successful close unless positive `tools allowlist applied` evidence was observed. An unmappable `--tools` entry can make Grok retain its full tool surface, and an unmatched disallowed rule can mean the boundary never existed. Consult also has explicit Bash, Edit, Write, MCP, Agent, and meta-tool denies; write is already a user-authorized modification surface. Do not retry by removing or weakening a restriction. The write list is `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`, plus explicitly enabled web tools. There is no `write` tool id; `search_replace` can create a file. Command-pattern denies for common direct grok, claude, and codex calls can be bypassed with absolute paths, aliases or functions, or indirect scripts. They reduce accidental recursion but are not hard confinement while `run_terminal_cmd` remains available; that requires removing the terminal tool or an OS-level executable or network policy. -Capability failure is mode specific. Every managed run needs `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`. Consult additionally needs `--permission-mode` and `--allow`; a no-web run needs `--disable-web-search`; write needs `--always-approve`; review needs `--json-schema`; best-of-n needs `--best-of-n` plus `--background-wait-timeout`; an ordinary call needs `--no-wait-for-background` or the bounded wait flag. A missing flag fails preflight for that mode with failure kind `setup`. Never retry the unchanged task, omit the feature silently, change the request, or downgrade strict to workspace; upgrade or repair the CLI and rerun `/grok:setup`. +Capability failure is mode specific. Every managed run needs `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`. Consult additionally needs `--permission-mode` and `--allow`; a no-web run needs `--disable-web-search`; write needs `--always-approve`; review needs `--json-schema`; an ordinary call needs `--no-wait-for-background` or the bounded wait flag. A missing flag fails preflight for that mode with failure kind `setup`. Never retry the unchanged task, omit the feature silently, change the request, or downgrade strict to workspace; upgrade or repair the CLI and rerun `/grok:setup`. -Grok waits for Bash, monitor, or subagent background work after the first headless turn by default, with an upstream default that can exceed Fusion's foreground deadline. Ordinary calls hard deny Agent and prefer `--no-wait-for-background`; a binary without that flag gets an explicit `--background-wait-timeout` below the companion timeout. Best-of-n keeps Agent and requires that bounded timeout. If the outer wrapper times out first, classify and clean up the owned process; do not report that native waiting as a detached companion job, and do not add companion `--background` unless the incoming user request supplied it. +Grok waits for Bash, monitor, or subagent background work after the first headless turn by default, with an upstream default that can exceed Fusion's foreground deadline. Ordinary calls hard deny Agent and prefer `--no-wait-for-background`; a binary without that flag gets an explicit `--background-wait-timeout` below the companion timeout. If the outer wrapper times out first, classify and clean up the owned process; do not report that native waiting as a detached companion job, and do not add companion `--background` unless the incoming user request supplied it. -Consult, write, review, and best-of-n all require `--sandbox strict`. Before each prompt, the companion creates a unique private `TMPDIR`, snapshots the shared `sandbox-events.jsonl`, launches Grok, and waits for a new `ProfileApplied` event that contains that private path and proves the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. Upstream records have no run id or pid, so the private path correlates positive evidence; unrelated `ProfileApplied` and `ApplyFailed` records are not attributed, and failure events are only auxiliary diagnostics. The matching event proves profile and configuration application, not actual platform network isolation. A matching owned-stderr warning, handshake timeout, missing or malformed matching evidence, shared-log disappearance or rotation, or a matching field mismatch is a `sandbox` failure that terminates the owned process group. Stderr cannot prove successful enforcement. Never downgrade to workspace. +Consult, write, and review all require `--sandbox strict`. Before each prompt, the companion creates a unique private `TMPDIR`, snapshots the shared `sandbox-events.jsonl`, launches Grok, and waits for a new `ProfileApplied` event that contains that private path and proves the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. Upstream records have no run id or pid, so the private path correlates positive evidence; unrelated `ProfileApplied` and `ApplyFailed` records are not attributed, and failure events are only auxiliary diagnostics. The matching event proves profile and configuration application, not actual platform network isolation. A matching owned-stderr warning, handshake timeout, missing or malformed matching evidence, shared-log disappearance or rotation, or a matching field mismatch is a `sandbox` failure that terminates the owned process group. Stderr cannot prove successful enforcement. Never downgrade to workspace. -Strict's upstream `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`. The run-private `TMPDIR` is the adapter's preferred and handshake-verified temporary path, not the only writable temporary surface. Strict is not an operating system read-only profile. Its child network request is enforced through seccomp on Linux, while the current macOS network blocker is a no-op; write and best-of-n shell commands can reach the network on macOS, and `--web` controls only built in web tools. A compiler, package manager, SDK, or other user toolchain outside readable system roots can disappear in write or best-of-n mode. Split or reroute the package, or put the required tool inside an approved readable path; never weaken the sandbox silently. The consult tool deny set and compatibility isolation remain required after successful sandbox application. +Strict's upstream `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`. The run-private `TMPDIR` is the adapter's preferred and handshake-verified temporary path, not the only writable temporary surface. Strict is not an operating system read-only profile. Its child network request is enforced through seccomp on Linux, while the current macOS network blocker is a no-op; write shell commands can reach the network on macOS, and `--web` controls only built in web tools. A compiler, package manager, SDK, or other user toolchain outside readable system roots can disappear in write mode. Split or reroute the package, or put the required tool inside an approved readable path; never weaken the sandbox silently. The consult tool deny set and compatibility isolation remain required after successful sandbox application. The entire Grok home is a credential and native configuration trust boundary. The sandbox permits consult `read_file` to reach `~/.grok/auth.json`, config, and sessions, while write terminal commands may receive the xAI authentication environment that the scrubber deliberately preserves. The companion layers best-effort Read denies over `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` through absolute paths and standard `**/.grok` patterns. Raw path variants, symbolic links, and shell or indirect scripts can bypass those tool-level rules, so environment scrubbing and path-pattern denies do not isolate filesystem credentials. These Read denies govern model tool calls and do not block upstream's internal first-turn memory injection when an ordinary task explicitly enables memory; injected snippets enter the model context sent to xAI. Model-facing `search_tool`, `use_tool`, and MCP denies do not prove that native MCP servers, plugins, or hooks under `~/.grok` did not start during agent construction or had no side effects; those bridge variables disable compatibility imports, not native Grok configuration. Do not delegate untrusted content under the assumption that strict hides Grok credentials, memory, or native startup. Narrower exposure requires a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization. @@ -88,7 +86,7 @@ Use an orchestrator-created Git worktree and launch Grok with that real path as ## Collection repair -A manual receipt returned by a peer Agent to Fusion orchestration is valid but not collected. Dispatch fusion:job-collector before the turn ends 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 collector result to this dispatch identity. The collector resolves the companion, performs global job lookup without a working-directory selector, and invokes status plus blocking result through fixed argv without shell command strings. It makes one foreground attempt of at most 540000ms. A timeout, dead job, or status error must report the Fusion task id, peer job id, literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. A successful Grok collection remains semantically unverified until the main loop records `/fusion:stats --record-worker-acceptance --source main-loop`. Codex and Grok wrapper Agents always remain foreground; user requested companion `--background` runs only inside that foreground wrapper and returns a manual receipt. `grok:grok-review-runner` is the sole managed exception allowed to use Agent `run_in_background`. Direct Codex or Grok commands with explicit `--background` return manual receipts whose users inspect progress through status and collect through result; monitors are notification only. Expected duration never authorizes managed detachment. A foreground Codex or Grok final containing only a receipt is a contract failure. Do not normalize unauthorized detachment by collecting it; cancel any discovered job, then retry one bounded foreground package or route it elsewhere. When an agent references completed content invisible to the parent, resume that agent once via SendMessage and demand the full deliverable verbatim in its reply, explicitly stating that the parent receives only the final message. +A manual receipt returned by a peer Agent to Fusion orchestration is valid but not collected. Dispatch fusion:job-collector before the turn ends 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 collector result to this dispatch identity. The collector resolves the companion, performs global job lookup without a working-directory selector, and invokes status plus blocking result through fixed argv without shell command strings. It makes one foreground attempt of at most 540000ms. A timeout, dead job, or status error must report the Fusion task id, peer job id, literal state `uncollected`, and the exact `/codex:result ` or `/grok:result ` command. A successful Grok collection remains semantically unverified until the main loop records `/fusion:stats --record =`. Codex and Grok wrapper Agents always remain foreground; user requested companion `--background` runs only inside that foreground wrapper and returns a manual receipt. `grok:grok-review-runner` is the sole managed exception allowed to use Agent `run_in_background`. Direct Codex or Grok commands with explicit `--background` return manual receipts whose users inspect progress through status and collect through result; monitors are notification only. Expected duration never authorizes managed detachment. A foreground Codex or Grok final containing only a receipt is a contract failure. Do not normalize unauthorized detachment by collecting it; cancel any discovered job, then retry one bounded foreground package or route it elsewhere. When an agent references completed content invisible to the parent, resume that agent once via SendMessage and demand the full deliverable verbatim in its reply, explicitly stating that the parent receives only the final message. ## Died process detection diff --git a/plugins/fusion/scripts/breaker-check.mjs b/plugins/fusion/scripts/breaker-check.mjs index 36c53c3..b815acf 100644 --- a/plugins/fusion/scripts/breaker-check.mjs +++ b/plugins/fusion/scripts/breaker-check.mjs @@ -72,10 +72,8 @@ function readJobRecords(stateRoot) { continue; } try { - const recordFile = path.join(jobsDir, entry.name); - const record = JSON.parse(fs.readFileSync(recordFile, "utf8")); + const record = JSON.parse(fs.readFileSync(path.join(jobsDir, entry.name), "utf8")); if (record && typeof record === "object" && !Array.isArray(record)) { - Object.defineProperty(record, "_fusionRecordFile", { value: recordFile }); records.push(record); } } catch { @@ -145,20 +143,7 @@ function grokFailure(record, now, lookbackMs) { if (!GROK_FAILURE_STATUSES.has(record.status)) { return null; } - const recordedFailureKind = normalizedRecordedFailureKind(record, record.failureKind); - let failureKind = recordedFailureKind; - const recoverLegacyFailure = !recordedFailureKind || recordedFailureKind === "error"; - if (recoverLegacyFailure) { - failureKind = codexFailureKind(recordFailureText(record)); - } - if (recoverLegacyFailure && !supportedBreakerFailure(record, failureKind) && typeof record._fusionRecordFile === "string") { - try { - const log = fs.readFileSync(record._fusionRecordFile.replace(/\.json$/, ".log"), "utf8"); - failureKind = codexFailureKind(log.slice(-64 * 1024)); - } catch { - void 0; - } - } + const failureKind = normalizedRecordedFailureKind(record, record.failureKind); const timestamp = finishedAtMs(record.finishedAt); if (!supportedBreakerFailure(record, failureKind) || timestamp == null || !isWithinLookback(timestamp, now, lookbackMs)) { return null; @@ -182,11 +167,7 @@ function codexFailure(record, now, lookbackMs) { if (!CODEX_FAILURE_STATUSES.has(record.status)) { return null; } - const recordedFailureKind = normalizedRecordedFailureKind(record, record.failureKind); - const recoverLegacyFailure = !recordedFailureKind || recordedFailureKind === "error"; - const failureKind = recoverLegacyFailure - ? codexFailureKind(recordFailureText(record)) - : recordedFailureKind; + const failureKind = normalizedRecordedFailureKind(record, record.failureKind); const timestamp = finishedAtMs(record.finishedAt ?? record.completedAt ?? record.updatedAt); if (!supportedBreakerFailure(record, failureKind) || timestamp == null || !isWithinLookback(timestamp, now, lookbackMs)) { return null; diff --git a/plugins/fusion/scripts/codex-jobs-monitor.mjs b/plugins/fusion/scripts/codex-jobs-monitor.mjs index acb35cc..a271deb 100644 --- a/plugins/fusion/scripts/codex-jobs-monitor.mjs +++ b/plugins/fusion/scripts/codex-jobs-monitor.mjs @@ -20,9 +20,7 @@ import { workspaceRootsShareRepository } from "./fusion-stats.mjs"; -const CURRENT_TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); -const LEGACY_TERMINAL_STATUSES = new Set(["completed", "failed"]); -const TERMINAL_STATUSES = new Set([...CURRENT_TERMINAL_STATUSES, ...LEGACY_TERMINAL_STATUSES]); +const TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); const DEFAULT_POLL_INTERVAL_MS = 15000; const INTERVAL_ENV = "CODEX_JOBS_MONITOR_INTERVAL_MS"; const PS_COMMAND_ENV = "CODEX_JOBS_MONITOR_PS_COMMAND"; @@ -544,18 +542,15 @@ function maybeCaptureModelAudit(record, seenJobIds, sidecarPath, env = process.e function terminalObservations(record, env = process.env, rolloutIndex = null) { const workspaceRoot = record?.workspaceRoot; - const usesLegacyContract = LEGACY_TERMINAL_STATUSES.has(record?.status) || (record?.status === "cancelled" && !record?.finishedAt && record?.completedAt); - const rollout = usesLegacyContract - ? inspectCodexRollout(record, env, rolloutIndex) - : { - availability: "unavailable", - reason: record?.tokenUsageUnavailableReason ?? "job_record_unavailable", - threadId: record?.threadId ?? record?.result?.threadId ?? null, - turnId: record?.turnId ?? record?.result?.turnId ?? null, - model: null, - effort: null, - tokenUsage: null - }; + const rollout = { + availability: "unavailable", + reason: record?.tokenUsageUnavailableReason ?? "job_record_unavailable", + threadId: record?.threadId ?? record?.result?.threadId ?? null, + turnId: record?.turnId ?? record?.result?.turnId ?? null, + model: null, + effort: null, + tokenUsage: null + }; const resolvedModel = nonEmptyRequestField(record?.resolvedModel) ? String(record.resolvedModel).trim() : null; const resolvedEffort = nonEmptyRequestField(record?.resolvedEffort) ? String(record.resolvedEffort).trim() : null; const requestedModel = nonEmptyRequestField(record?.request?.model) ? String(record.request.model).trim() : null; @@ -660,34 +655,6 @@ function quarantineMalformed(file) { return quarantine; } -function legacyTerminalRecord(key, workspaceRoot) { - const separator = key.lastIndexOf(":"); - if (separator <= 0) { - return null; - } - const transportStatus = key.slice(separator + 1); - if (!TERMINAL_STATUSES.has(transportStatus)) { - return null; - } - return { - schemaVersion: 1, - jobId: key.slice(0, separator), - transportStatus, - workspaceRoot, - repositoryKey: fusionRepositoryKey(workspaceRoot), - sessionId: null, - kind: "unknown", - createdAt: null, - startedAt: null, - finishedAt: null, - observedAt: null, - model: null, - effort: null, - tokenUsage: null, - tokenUsageAvailability: "unavailable" - }; -} - function loadAnnounced(file, workspaceRoot) { let text; try { @@ -714,14 +681,6 @@ function loadAnnounced(file, workspaceRoot) { }); } } - for (const key of announced) { - if (!records.has(key)) { - const record = legacyTerminalRecord(key, workspaceRoot); - if (record) { - records.set(key, record); - } - } - } return { exists: true, malformed: false, needsMigration: Array.isArray(raw) || raw?.schemaVersion !== TERMINAL_LEDGER_SCHEMA_VERSION, announced, records }; } catch { quarantineMalformed(file); diff --git a/plugins/fusion/scripts/fusion-stats.mjs b/plugins/fusion/scripts/fusion-stats.mjs index 1265df3..d889bd2 100644 --- a/plugins/fusion/scripts/fusion-stats.mjs +++ b/plugins/fusion/scripts/fusion-stats.mjs @@ -8,15 +8,17 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { readAuditEvents as readGuardAuditEvents, resolveStateDir as resolveGuardStateDir, stateFile as guardStateFile } from "./inline-delegation-guard.mjs"; import { resolveCodexStateDir, resolveCodexStateRoots } from "./lib/codex-state-roots.mjs"; +import { recordEngineAcceptance } from "./lib/engine-acceptance.mjs"; import { consumeRawArgsTransport, createRawArgsTransport, resolveRawArgsTransport } from "./lib/raw-args-transport.mjs"; -import { canonicalWorkerAgentType, isTerminalWorkerStatus, readWorkerRecords, recordCodexCollectorAcceptance, recordWorkerAcceptance } from "./lib/worker-state.mjs"; +import { canonicalWorkerAgentType, isTerminalWorkerStatus, readWorkerRecord, readWorkerRecords, recordWorkerAcceptance, validateWorkerAcceptance } from "./lib/worker-state.mjs"; + +export { GrokPluginUpgradeRequiredError, newestCodexCompanion, newestGrokCompanion } from "./lib/engine-acceptance.mjs"; const GROK_DATA_DIR_ENV = "GROK_COMPANION_DATA"; const FUSION_DATA_DIR_ENV = "FUSION_DATA_DIR"; -const CODEX_TERMINAL_STATUSES = new Set(["done", "error", "cancelled", "completed", "failed"]); +const CODEX_TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); const GROK_TERMINAL_STATUSES = new Set(["done", "error", "cancelled"]); const CODEX_ACCEPTANCE_STATES = new Set(["accepted", "rejected", "unverified"]); -const ACCEPTANCE_FILENAME = "acceptance.jsonl"; const TOKEN_USAGE_FILENAME = "token-usage.jsonl"; const TERMINAL_LEDGER_PREFIX = "codex-jobs-monitor-announced"; const OBSERVATION_LOCK_STALE_MS = 30000; @@ -27,14 +29,6 @@ const ENGINE_JOB_ID_PATTERN = /^[0-9a-f]{32}$/; const RECORD_VERDICTS = new Set(["accepted", "rejected", "unverified"]); const RECORD_SOURCES = new Set(["collector", "main-loop"]); -export class GrokPluginUpgradeRequiredError extends Error { - constructor(message) { - super(message); - this.name = "GrokPluginUpgradeRequiredError"; - this.code = "GROK_PLUGIN_UPGRADE_REQUIRED"; - } -} - export function fusionWorkspaceKey(workspaceRoot) { return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 16); } @@ -55,10 +49,6 @@ export function tokenUsageSidecarPath(workspaceRoot, env = process.env) { return path.join(resolveFusionDataDir(env), "observations", fusionWorkspaceKey(workspaceRoot), TOKEN_USAGE_FILENAME); } -export function acceptanceSidecarPath(workspaceRoot, env = process.env) { - return path.join(resolveFusionDataDir(env), "observations", fusionWorkspaceKey(workspaceRoot), ACCEPTANCE_FILENAME); -} - function observationTimestamp(observation) { const parsed = Date.parse(observation?.observedAt ?? observation?.recordedAt ?? ""); return Number.isFinite(parsed) ? parsed : Number.NEGATIVE_INFINITY; @@ -127,16 +117,9 @@ export function normalizeCollectionMethod(value) { return normalized; } -function semanticAcceptance(raw, acceptanceObservation = null) { +function semanticAcceptance(raw) { const recorded = raw?.acceptance; - if (recorded === "accepted" || recorded === "rejected") { - return recorded; - } - const observed = acceptanceObservation?.acceptance; - if (CODEX_ACCEPTANCE_STATES.has(observed)) { - return observed; - } - return "unverified"; + return recorded === "accepted" || recorded === "rejected" ? recorded : "unverified"; } function acceptanceProvenance(raw) { @@ -205,10 +188,6 @@ export function loadTokenUsageObservations(sidecarPath) { return loadLatestJsonlByJobId(sidecarPath, "observedAt"); } -export function loadAcceptanceObservations(sidecarPath) { - return loadLatestJsonlByJobId(sidecarPath, "recordedAt"); -} - function waitForObservationLock() { const signal = new Int32Array(new SharedArrayBuffer(4)); Atomics.wait(signal, 0, 0, 10); @@ -281,13 +260,6 @@ export function appendTokenUsageObservation(sidecarPath, observation) { }); } -function appendAcceptanceObservation(sidecarPath, observation) { - return appendJsonlObservation(sidecarPath, observation, () => { - const current = loadAcceptanceObservations(sidecarPath).get(observation.jobId); - return !current || ["acceptance", "workspaceRoot", "repositoryKey", "sessionId", "source", "reason"].some((field) => (current[field] ?? null) !== (observation[field] ?? null)); - }); -} - export function resolveGitWorkspaceRoot(cwd) { const resolved = path.resolve(cwd); const result = spawnSync("git", ["-C", resolved, "rev-parse", "--show-toplevel"], { encoding: "utf8" }); @@ -350,184 +322,6 @@ export function workspaceRootsShareRepository(leftRoot, rightRoot, cache = new M return workspaceRootInScope(leftRoot, rightRoot) || workspaceRootInScope(rightRoot, leftRoot); } -function validatedIdentifier(value, label, pattern, maximumLength) { - const normalized = nonEmptyString(value); - if (!normalized || normalized.length > maximumLength || !pattern.test(normalized)) { - throw new TypeError(`${label} is invalid.`); - } - return normalized; -} - -function validatedIsoTimestamp(value) { - const normalized = nonEmptyString(value); - const parsed = normalized ? Date.parse(normalized) : Number.NaN; - if (!normalized || !Number.isFinite(parsed) || new Date(parsed).toISOString() !== normalized) { - throw new TypeError("Codex acceptance recordedAt must be an ISO timestamp."); - } - return normalized; -} - -function sanitizedAcceptanceReason(value) { - const normalized = nonEmptyString(value); - if (!normalized) { - return null; - } - if (normalized.length > 240 || /[\r\n\u0000-\u001f\u007f]/.test(normalized)) { - throw new TypeError("Codex acceptance reason must be a single non-sensitive line of at most 240 characters."); - } - return normalized - .replace(/sk-[A-Za-z0-9_-]{16,}/g, "[redacted]") - .replace(/(?:ghp_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9]{20,}/g, "[redacted]") - .replace(/xai-[A-Za-z0-9]{16,}/g, "[redacted]") - .replace(/Bearer\s+\S{20,}/gi, "Bearer [redacted]"); -} - -function prepareCodexAcceptance({ jobId, acceptance, workspaceRoot = process.cwd(), env = process.env, sessionId = env.CLAUDE_CODE_SESSION_ID || null, reason = null, source = "collector", recordedAt = new Date().toISOString() }) { - const normalizedJobId = validatedIdentifier(jobId, "Codex job id", /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, 128); - if (!CODEX_ACCEPTANCE_STATES.has(acceptance)) { - throw new TypeError(`Codex acceptance must be one of ${[...CODEX_ACCEPTANCE_STATES].join(", ")}.`); - } - const normalizedRoot = resolveGitWorkspaceRoot(workspaceRoot); - const normalizedSessionId = sessionId == null ? null : validatedIdentifier(sessionId, "Codex session id", /^[A-Za-z0-9][A-Za-z0-9._:-]*$/, 128); - const normalizedSource = validatedIdentifier(source, "Codex acceptance source", /^[a-z][a-z0-9._-]*$/, 32); - const normalizedReason = sanitizedAcceptanceReason(reason); - const observation = { - schemaVersion: 1, - engine: "codex", - jobId: normalizedJobId, - acceptance, - workspaceRoot: normalizedRoot, - repositoryKey: fusionRepositoryKey(normalizedRoot), - sessionId: normalizedSessionId, - source: normalizedSource, - recordedAt: validatedIsoTimestamp(recordedAt), - ...(normalizedReason ? { reason: normalizedReason } : {}) - }; - return { observation, normalizedRoot, normalizedSessionId, normalizedSource, normalizedReason, env }; -} - -export function recordCodexAcceptance(options) { - const { observation, normalizedRoot, normalizedSessionId, normalizedSource, normalizedReason, env } = prepareCodexAcceptance(options); - if (!appendAcceptanceObservation(acceptanceSidecarPath(normalizedRoot, env), observation)) { - throw new Error("Codex acceptance ledger is busy; retry the write."); - } - for (const record of normalizedSessionId == null ? [] : readWorkerRecords(env).filter((candidate) => candidate.sessionId === normalizedSessionId && canonicalWorkerAgentType(candidate.agentType) === "fusion:job-collector" && candidate.completionContract === "collector" && candidate.peerEngine === "codex" && candidate.peerJobId === observation.jobId && isTerminalWorkerStatus(candidate.transportStatus))) { - recordCodexCollectorAcceptance({ taskId: record.taskId, jobId: observation.jobId, sessionId: normalizedSessionId, acceptance: observation.acceptance, env, source: normalizedSource, reason: normalizedReason }); - } - return observation; -} - -export function newestGrokCompanion(env = process.env) { - const override = typeof env.FUSION_GROK_COMPANION === "string" ? env.FUSION_GROK_COMPANION.trim() : ""; - if (override && path.isAbsolute(override) && regularFile(override)) { - return override; - } - const base = path.join(configuredHome(env), ".claude", "plugins", "cache", "claude-code-fusion", "grok"); - try { - const candidates = fs - .readdirSync(base) - .map((version) => path.join(base, version, "scripts", "grok-companion.mjs")) - .filter((candidate) => fs.existsSync(candidate)) - .map((candidate) => ({ candidate, mtime: fs.statSync(candidate).mtimeMs })) - .sort((left, right) => right.mtime - left.mtime); - if (candidates.length > 0) { - return candidates[0].candidate; - } - } catch { - void 0; - } - const sibling = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "grok", "scripts", "grok-companion.mjs"); - return fs.existsSync(sibling) ? sibling : null; -} - -function configuredHome(env = process.env) { - const candidate = typeof env.HOME === "string" ? env.HOME.trim() : ""; - return candidate && path.isAbsolute(candidate) ? candidate : os.homedir(); -} - -function regularFile(file) { - try { - return fs.statSync(file).isFile(); - } catch { - return false; - } -} - -export function newestCodexCompanion(env = process.env) { - const override = typeof env.FUSION_CODEX_COMPANION === "string" ? env.FUSION_CODEX_COMPANION.trim() : ""; - if (override && path.isAbsolute(override) && regularFile(override)) { - return override; - } - const base = path.join(configuredHome(env), ".claude", "plugins", "cache", "claude-code-fusion", "codex"); - try { - const candidates = fs - .readdirSync(base) - .map((version) => path.join(base, version, "scripts", "codex-companion.mjs")) - .filter((candidate) => fs.existsSync(candidate)) - .map((candidate) => ({ candidate, mtime: fs.statSync(candidate).mtimeMs })) - .sort((left, right) => right.mtime - left.mtime || left.candidate.localeCompare(right.candidate)); - if (candidates.length > 0) { - return candidates[0].candidate; - } - } catch { - void 0; - } - const sibling = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "codex", "scripts", "codex-companion.mjs"); - return fs.existsSync(sibling) ? sibling : null; -} - -function companionFailureMessage(result) { - return [result.stderr, result.stdout, result.error?.message].filter((value) => typeof value === "string" && value.trim()).join("\n").trim(); -} - -function codexAcceptanceSubcommandUnavailable(result) { - if (result.error) { - return true; - } - const message = companionFailureMessage(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 grokAcceptanceSubcommandUnavailable(result) { - if (result.error) { - return false; - } - const message = companionFailureMessage(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 }) { - 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 result = spawnSync(process.execPath, argv, { cwd: workspaceRoot, encoding: "utf8", env }); - if (!result.error && result.status === 0) { - return { updated: true }; - } - if (codexAcceptanceSubcommandUnavailable(result)) { - return { updated: false }; - } - throw new Error(companionFailureMessage(result) || "Codex acceptance record update failed."); -} - -function recordGrokCompanionAcceptance({ jobId, acceptance, reason, 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 result = spawnSync(process.execPath, argv, { cwd: workspaceRoot, encoding: "utf8", env }); - if (!result.error && result.status === 0) { - return; - } - if (grokAcceptanceSubcommandUnavailable(result)) { - throw new GrokPluginUpgradeRequiredError("The installed Grok plugin does not support record-acceptance. Upgrade the Grok plugin and retry."); - } - throw new Error(companionFailureMessage(result) || "Grok acceptance record update failed."); -} - export function grokStats({ all = false, env = process.env, cwd = process.cwd() } = {}) { return fileBasedEngineStats(FILE_ENGINE_DESCRIPTORS.grok, { all, env, cwd }); } @@ -654,28 +448,6 @@ function terminalLedgerRecord(raw, { workspaceRoot = null, workspaceKey = null, }; } -function legacyTerminalLedgerRecords(keys, workspaceKey, repositoryKey = null) { - const records = []; - for (const key of keys) { - if (typeof key !== "string") { - continue; - } - const separator = key.lastIndexOf(":"); - if (separator <= 0) { - continue; - } - const record = terminalLedgerRecord( - { jobId: key.slice(0, separator), transportStatus: key.slice(separator + 1) }, - { workspaceKey, repositoryKey } - ); - if (record) { - record._fusionEvidence = "legacy-terminal-ledger"; - records.push(record); - } - } - return records; -} - export function readCodexTerminalLedgerFiles(stateRoot) { const records = []; let entries; @@ -689,10 +461,6 @@ export function readCodexTerminalLedgerFiles(stateRoot) { const workspaceKey = terminalLedgerWorkspaceKey(file); try { const raw = JSON.parse(fs.readFileSync(file, "utf8")); - if (Array.isArray(raw)) { - records.push(...legacyTerminalLedgerRecords(raw, workspaceKey)); - continue; - } const scopeRoot = nonEmptyString(raw?.workspaceRoot); const repositoryKey = nonEmptyString(raw?.repositoryKey); if (Array.isArray(raw?.records)) { @@ -702,8 +470,6 @@ export function readCodexTerminalLedgerFiles(stateRoot) { records.push(record); } } - } else if (Array.isArray(raw?.keys)) { - records.push(...legacyTerminalLedgerRecords(raw.keys, workspaceKey, repositoryKey)); } } catch { void 0; @@ -714,11 +480,7 @@ export function readCodexTerminalLedgerFiles(stateRoot) { function readCodexJobEvidence(stateRoot, options = {}) { const roots = Object.hasOwn(options, "env") ? resolveCodexStateRoots(options.env) : [stateRoot]; - const compatibilityOverride = Object.hasOwn(options, "env") && !nonEmptyString(options.env.FUSION_CODEX_STATE) && Boolean(nonEmptyString(options.env.FUSION_CODEX_STATE_DIR)); - return roots.flatMap((root) => { - const legacy = compatibilityOverride || path.basename(path.dirname(path.resolve(root))) === "codex-openai-codex"; - return [...readWorkspaceJobFiles(root), ...readCodexTerminalLedgerFiles(root)].map((record) => legacy ? { ...record, _fusionLegacySource: true } : record); - }); + return roots.flatMap((root) => [...readWorkspaceJobFiles(root), ...readCodexTerminalLedgerFiles(root)]); } function readGrokJobEvidence(stateRoot) { @@ -736,7 +498,7 @@ function preferJobCopy(current, candidate, isTerminal) { if (currentTerminal !== candidateTerminal) { return candidateTerminal ? candidate : current; } - const evidenceRank = { state: 2, "terminal-ledger": 1, "legacy-terminal-ledger": 0 }; + const evidenceRank = { state: 2, "terminal-ledger": 1 }; const currentRank = evidenceRank[current?._fusionEvidence] ?? 0; const candidateRank = evidenceRank[candidate?._fusionEvidence] ?? 0; if (currentRank !== candidateRank) { @@ -775,9 +537,6 @@ function sameJobScope(left, right, repositoryCache) { const rightRepositoryKey = nonEmptyString(right?._fusionRepositoryKey); const leftRoot = nonEmptyString(left?.workspaceRoot); const rightRoot = nonEmptyString(right?.workspaceRoot); - if (left?._fusionLegacySource && right?._fusionLegacySource && leftRoot && rightRoot && path.resolve(leftRoot) === path.resolve(rightRoot)) { - return true; - } if (leftRepositoryKey && rightRepositoryKey) { return leftRepositoryKey === rightRepositoryKey; } @@ -1266,7 +1025,6 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en const byEvidence = {}; const auditCache = new Map(); const tokenObservations = descriptor.usesTokenUsageObservations ? loadAllObservationCandidates(env, TOKEN_USAGE_FILENAME, loadTokenUsageObservations) : new Map(); - const acceptanceObservations = descriptor.id === "codex" ? loadAllObservationCandidates(env, ACCEPTANCE_FILENAME, loadAcceptanceObservations) : new Map(); const observationRepositoryCache = new Map(); let tokenTotals = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 }; let tokenAggregationOverflow = false; @@ -1304,16 +1062,15 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en } if (descriptor.id === "codex") { const jobId = nonEmptyString(raw?.id); - const acceptanceObservation = jobId ? scopedObservationForJob(acceptanceObservations, raw, observationRepositoryCache) : null; if (CODEX_TERMINAL_STATUSES.has(job.status)) { - bump(byAcceptance, semanticAcceptance(raw, acceptanceObservation)); + bump(byAcceptance, semanticAcceptance(raw)); } else { pendingTransportJobs += 1; } - if (jobId && job.status === "error" && semanticAcceptance(raw, acceptanceObservation) === "accepted") { + if (jobId && job.status === "error" && semanticAcceptance(raw) === "accepted") { acceptedWithErrorTransport.push(jobId); } - if (jobId && job.status === "done" && semanticAcceptance(raw, acceptanceObservation) === "unverified") { + if (jobId && job.status === "done" && semanticAcceptance(raw) === "unverified") { doneWithoutAcceptance.push(jobId); } } @@ -1392,7 +1149,6 @@ export function fileBasedEngineStats(descriptor, { all = false, env = process.en evidence: { bySource: byEvidence, recoveredTerminalJobs: byEvidence["terminal-ledger"] ?? 0, - recoveredLegacyTerminalJobs: byEvidence["legacy-terminal-ledger"] ?? 0, isLowerBound: true } } @@ -1933,16 +1689,13 @@ function sessionScopedEngineStats(descriptor, sessionId, env) { } } const scoped = selectPreferredJobs(jobs, descriptor, (raw) => descriptor.sessionOf(raw) === sessionId); - const acceptanceObservations = descriptor.id === "codex" ? loadAllObservationCandidates(env, ACCEPTANCE_FILENAME, loadAcceptanceObservations) : new Map(); - const observationRepositoryCache = new Map(); totalJobs = scoped.length; for (const raw of scoped) { const status = raw.status ?? "unknown"; bump(byStatus, status); if (descriptor.id === "codex") { if (CODEX_TERMINAL_STATUSES.has(status)) { - const acceptanceObservation = scopedObservationForJob(acceptanceObservations, raw, observationRepositoryCache); - bump(byAcceptance, semanticAcceptance(raw, acceptanceObservation)); + bump(byAcceptance, semanticAcceptance(raw)); } else { pendingTransportJobs += 1; } @@ -2240,8 +1993,8 @@ function renderEngine(lines, name, stats) { lines.push(`Observed tokens: ${stats.usage.totalTokens} total, ${stats.usage.inputTokens} input, ${stats.usage.cacheReadInputTokens} cached input, ${stats.usage.outputTokens} output${reasoning}`); } } - if (stats.evidence?.recoveredTerminalJobs || stats.evidence?.recoveredLegacyTerminalJobs) { - lines.push("", `Recovered from retained terminal ledgers: ${stats.evidence.recoveredTerminalJobs + stats.evidence.recoveredLegacyTerminalJobs}`); + if (stats.evidence?.recoveredTerminalJobs) { + lines.push("", `Recovered from retained terminal ledgers: ${stats.evidence.recoveredTerminalJobs}`); } renderWorkerIdentities(lines, stats.identities); } @@ -2454,14 +2207,11 @@ function unsettledEngineEntries(descriptor, env) { return []; } const scoped = selectPreferredJobs(jobs, descriptor, () => true); - const acceptanceObservations = descriptor.id === "codex" ? loadAllObservationCandidates(env, ACCEPTANCE_FILENAME, loadAcceptanceObservations) : new Map(); - const observationRepositoryCache = new Map(); return scoped.flatMap((raw) => { if (!descriptor.isTerminal(raw)) { return []; } - const acceptanceObservation = descriptor.id === "codex" ? scopedObservationForJob(acceptanceObservations, raw, observationRepositoryCache) : null; - if (semanticAcceptance(raw, acceptanceObservation) !== "unverified") { + if (semanticAcceptance(raw) !== "unverified") { return []; } const id = nonEmptyString(raw?.id); @@ -2529,14 +2279,17 @@ function parseRecordPair(value) { throw new TypeError("--record requires an = pair."); } const id = value.slice(0, separator); - const verdict = value.slice(separator + 1); + const rawVerdict = value.slice(separator + 1); + const overrideSuffix = ":accept-failed-transport"; + const acceptFailedTransport = rawVerdict.endsWith(overrideSuffix); + const verdict = acceptFailedTransport ? rawVerdict.slice(0, -overrideSuffix.length) : rawVerdict; if (!FUSION_TASK_ID_PATTERN.test(id) && !ENGINE_JOB_ID_PATTERN.test(id)) { throw new TypeError("--record id must be a Fusion task id or a 32 character engine job id."); } if (!RECORD_VERDICTS.has(verdict)) { throw new TypeError("--record verdict must be accepted, rejected, or unverified."); } - return { id, verdict }; + return { id, verdict, acceptFailedTransport }; } function parseRecordArguments(argv) { @@ -2591,66 +2344,87 @@ function parseRecordArguments(argv) { } function isStrictDirectRecordArguments(argv) { - if (!argv.includes("--record") || argv.includes("--reason")) { - return false; - } try { - parseRecordArguments(argv); - return true; + let records = 0; + const flags = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--record") { + parseRecordPair(argv[index + 1]); + records += 1; + index += 1; + continue; + } + if (token === "--source") { + const value = argv[index + 1]; + if ((value !== "collector" && value !== "main-loop") || flags.has(token)) { + return false; + } + flags.add(token); + index += 1; + continue; + } + if (token === "--json" || token === "--accept-failed-transport") { + if (flags.has(token)) { + return false; + } + flags.add(token); + continue; + } + return false; + } + return records > 0; } catch { return false; } } -function recordEngineAcceptance({ engine, jobId, verdict, source, reason, acceptFailedTransport = false, workspaceRoot, asJson, env }) { - if (engine === "grok") { - recordGrokCompanionAcceptance({ - jobId, - acceptance: verdict, - reason, - acceptFailedTransport, - workspaceRoot, - asJson, - env - }); - return true; - } - const companion = recordCodexCompanionAcceptance({ - jobId, - acceptance: verdict, - source, - reason, - acceptFailedTransport, - workspaceRoot, - env - }); - if (!companion.updated) { - throw new Error("Codex job record was not updated because the companion or subcommand is unavailable."); - } - return true; -} - -function writeRecordConfirmation({ kind, engine = null, jobId = null, worker = null, asJson, stdout }) { +function writeRecordConfirmation({ kind, engine = null, jobId = null, worker = null, queued = false, asJson, stdout }) { if (asJson) { - stdout.write(`${JSON.stringify(kind === "engine" ? { kind, engine, jobId, acceptance: worker?.acceptance } : { kind, taskId: worker.taskId, acceptance: worker.acceptance })}\n`); + stdout.write(`${JSON.stringify(kind === "engine" ? { kind, engine, jobId, acceptance: worker?.acceptance } : { kind, taskId: worker.taskId, acceptance: queued ? worker.pendingVerdict?.acceptance : worker.acceptance, ...(queued ? { queued: true } : {}) })}\n`); return; } if (kind === "engine") { stdout.write(`Recorded ${worker.acceptance} for ${engine === "codex" ? "Codex" : "Grok"} job ${jobId}.\n`); return; } + if (queued) { + stdout.write(`Queued ${worker.pendingVerdict?.acceptance} for ${worker.taskId}; applies when the record turns terminal.\n`); + return; + } stdout.write(`Recorded ${worker.acceptance} for Fusion worker task ${worker.taskId}.\n`); } function settleWorkerRecord({ taskId, verdict, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { - const worker = recordWorkerAcceptance({ taskId, acceptance: verdict, env, source, reason, acceptFailedTransport }); - writeRecordConfirmation({ kind: "worker", worker, asJson, stdout }); - const peerJobId = typeof worker.peerJobId === "string" && ENGINE_JOB_ID_PATTERN.test(worker.peerJobId) ? worker.peerJobId : null; + 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 worker = settlement.record; + writeRecordConfirmation({ kind: "worker", worker, queued: settlement.queued, asJson, stdout }); + return [worker]; + } + validateWorkerAcceptance({ record: current, taskId, acceptance: verdict, source, reason, 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 }); + } 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 }); + } 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}`); + } + const worker = settlement.record; + if (settlement.queued) { + writeRecordConfirmation({ kind: "worker", worker, asJson, stdout }); return [worker]; } - const engine = worker.peerEngine === "codex" || worker.peerEngine === "grok" ? worker.peerEngine : resolveEngineJob(peerJobId, env); - recordEngineAcceptance({ engine, jobId: peerJobId, verdict, source, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + writeRecordConfirmation({ kind: "worker", worker, asJson, stdout }); writeRecordConfirmation({ kind: "engine", engine, jobId: peerJobId, worker, asJson, stdout }); return [worker, { engine, jobId: peerJobId, acceptance: verdict }]; } @@ -2658,38 +2432,45 @@ function settleWorkerRecord({ taskId, verdict, source, reason, acceptFailedTrans function settleEngineRecord({ jobId, verdict, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { const engine = resolveEngineJob(jobId, env); const confirmation = { engine, jobId, acceptance: verdict }; - recordEngineAcceptance({ engine, jobId, verdict, source, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + recordEngineAcceptance({ engine, jobId, acceptance: verdict, source, reason, 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 worker = recordWorkerAcceptance({ taskId: record.taskId, acceptance: verdict, env, source, reason, acceptFailedTransport }); - writeRecordConfirmation({ kind: "worker", worker, asJson, stdout }); - writes.push(worker); + const settlement = recordWorkerAcceptance({ taskId: record.taskId, acceptance: verdict, env, source, reason, acceptFailedTransport }); + writeRecordConfirmation({ kind: "worker", worker: settlement.record, queued: settlement.queued, asJson, stdout }); + writes.push(settlement.record); } return writes; } -function settleRecords({ records, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout }) { +function settleRecords({ records, source, reason, acceptFailedTransport = false, asJson, workspaceRoot, env, stdout, stderr }) { const writes = []; - for (const { id, verdict } of records) { - if (FUSION_TASK_ID_PATTERN.test(id)) { - writes.push(...settleWorkerRecord({ taskId: id, verdict, source, reason, acceptFailedTransport, asJson, workspaceRoot, env, stdout })); - } else { - writes.push(...settleEngineRecord({ jobId: id, verdict, source, reason, acceptFailedTransport, asJson, workspaceRoot, env, stdout })); + const errors = []; + for (const { id, verdict, 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 })); + } else { + writes.push(...settleEngineRecord({ jobId: id, verdict, source, reason, acceptFailedTransport: pairOverride, asJson, workspaceRoot, env, stdout })); + } + } catch (error) { + const failure = { id, error }; + errors.push(failure); + stderr.write(`Failed to settle ${id}: ${error instanceof Error ? error.message : String(error)}\n`); } } - return writes; + return { writes, errors }; } export function main(argv = process.argv.slice(2), { env = process.env, cwd = process.cwd(), stdout = process.stdout, stderr = process.stderr } = {}) { const asJson = argv.includes("--json"); - const effectiveEnv = argv.includes("--include-legacy") ? { ...env, FUSION_CODEX_INCLUDE_LEGACY: "1" } : env; if (argv.includes("--audit")) { const all = argv.includes("--all"); const report = buildAuditReport({ - env: effectiveEnv, - sessionId: argv.includes("--session") ? sessionIdFromArgs(argv, effectiveEnv) : null, + env, + sessionId: argv.includes("--session") ? sessionIdFromArgs(argv, env) : null, days: all ? null : positiveAuditDays(argumentValue(argv, "--days")), all }); @@ -2699,98 +2480,39 @@ export function main(argv = process.argv.slice(2), { env = process.env, cwd = pr if (argv.includes("--record")) { const request = parseRecordArguments(argv); - return settleRecords({ ...request, workspaceRoot: cwd, env: effectiveEnv, stdout }); - } - - if (argv.includes("--record-acceptance")) { - if (argv.includes("--session")) { - throw new TypeError("--session cannot be used with --record-acceptance; acceptance is bound to the current Claude session."); - } - const index = argv.indexOf("--record-acceptance"); - const workspaceRoot = argumentValue(argv, "--workspace") ?? cwd; - const acceptanceRequest = { - jobId: argv[index + 1], - acceptance: argv[index + 2], - workspaceRoot, - env: effectiveEnv, - sessionId: effectiveEnv.CLAUDE_CODE_SESSION_ID || null, - reason: argumentValue(argv, "--reason"), - source: argumentValue(argv, "--source") ?? "collector" - }; - const preparedAcceptance = prepareCodexAcceptance(acceptanceRequest); - const codexFound = codexJobExists(preparedAcceptance.observation.jobId, effectiveEnv); - const grokJob = codexFound ? null : grokJobById(preparedAcceptance.observation.jobId, effectiveEnv); - if (grokJob) { - recordGrokCompanionAcceptance({ - jobId: preparedAcceptance.observation.jobId, - acceptance: preparedAcceptance.observation.acceptance, - reason: preparedAcceptance.normalizedReason, - acceptFailedTransport: argv.includes("--accept-failed-transport"), - workspaceRoot: preparedAcceptance.normalizedRoot, - asJson, - env: effectiveEnv - }); - const observation = { ...preparedAcceptance.observation, engine: "grok" }; - stdout.write(asJson ? `${JSON.stringify(observation, null, 2)}\n` : `Recorded ${observation.acceptance} for Grok job ${observation.jobId}.\n`); - return observation; - } - const companion = recordCodexCompanionAcceptance({ - jobId: preparedAcceptance.observation.jobId, - acceptance: preparedAcceptance.observation.acceptance, - source: preparedAcceptance.normalizedSource, - reason: preparedAcceptance.normalizedReason, - acceptFailedTransport: argv.includes("--accept-failed-transport"), - workspaceRoot: preparedAcceptance.normalizedRoot, - env: effectiveEnv - }); - const observation = recordCodexAcceptance(acceptanceRequest); - if (!companion.updated) { - stderr.write("Warning: Codex job record was not updated because the companion or subcommand is unavailable.\n"); - } - stdout.write(asJson ? `${JSON.stringify(observation, null, 2)}\n` : `Recorded ${observation.acceptance} for Codex job ${observation.jobId}.\n`); - return observation; - } - - if (argv.includes("--record-worker-acceptance")) { - const index = argv.indexOf("--record-worker-acceptance"); - const observation = recordWorkerAcceptance({ - taskId: argv[index + 1], - acceptance: argv[index + 2], - env: effectiveEnv, - reason: argumentValue(argv, "--reason"), - source: argumentValue(argv, "--source") ?? "main-loop", - acceptFailedTransport: argv.includes("--accept-failed-transport") - }); - stdout.write(asJson ? `${JSON.stringify(observation, null, 2)}\n` : `Recorded ${observation.acceptance} for Fusion worker task ${observation.taskId}.\n`); - return observation; + const result = settleRecords({ ...request, workspaceRoot: cwd, env, stdout, stderr }); + if (result.errors.length > 0) { + throw new Error(`${result.errors.length} record settlement ${result.errors.length === 1 ? "failed" : "failures"}.`); + } + return result.writes; } if (argv.includes("--prune-dead")) { - const result = pruneDeadWorkspaces({ env: effectiveEnv, yes: argv.includes("--yes") }); + const result = pruneDeadWorkspaces({ env, yes: argv.includes("--yes") }); stdout.write(asJson ? `${JSON.stringify(result, null, 2)}\n` : renderPruneReport(result)); return result; } if (argv.includes("--unsettled")) { - const report = buildUnsettledReport({ env: effectiveEnv }); + const report = buildUnsettledReport({ env }); stdout.write(asJson ? `${JSON.stringify(report, null, 2)}\n` : renderUnsettledReport(report)); return report; } if (argv.includes("--trace")) { - const report = buildTraceReport({ env: effectiveEnv, cwd, sessionId: sessionIdFromArgs(argv, effectiveEnv) }); + const report = buildTraceReport({ env, cwd, sessionId: sessionIdFromArgs(argv, env) }); stdout.write(asJson ? `${JSON.stringify(report, null, 2)}\n` : renderTraceReport(report)); return report; } if (argv.includes("--session")) { - const report = buildSessionReport({ env: effectiveEnv, sessionId: sessionIdFromArgs(argv, effectiveEnv) }); + const report = buildSessionReport({ env, sessionId: sessionIdFromArgs(argv, env) }); stdout.write(asJson ? `${JSON.stringify(report, null, 2)}\n` : renderSessionReport(report)); return report; } const all = argv.includes("--all"); - const report = buildFusionStats({ all, env: effectiveEnv, cwd }); + const report = buildFusionStats({ all, env, cwd }); if (asJson) { stdout.write(`${JSON.stringify(report, null, 2)}\n`); } else { diff --git a/plugins/fusion/scripts/lib/codex-state-roots.mjs b/plugins/fusion/scripts/lib/codex-state-roots.mjs index 9faa6a3..8fd6b17 100644 --- a/plugins/fusion/scripts/lib/codex-state-roots.mjs +++ b/plugins/fusion/scripts/lib/codex-state-roots.mjs @@ -4,7 +4,6 @@ import path from "node:path"; const CANONICAL_STATE_ENV = "FUSION_CODEX_STATE"; const COMPATIBILITY_STATE_ENV = "FUSION_CODEX_STATE_DIR"; const DATA_ENV = "CODEX_COMPANION_DATA"; -const INCLUDE_LEGACY_ENV = "FUSION_CODEX_INCLUDE_LEGACY"; function configuredPath(value) { if (typeof value !== "string" || !value.trim()) { @@ -28,9 +27,7 @@ export function resolveCodexStateRoots(env = process.env) { return [path.join(dataOverride, "state")]; } const dataRoot = path.join(homeDir(env), ".claude", "plugins", "data"); - const canonical = path.join(dataRoot, "codex-claude-code-fusion", "state"); - const includeLegacy = /^(?:1|true|yes|on)$/i.test(String(env[INCLUDE_LEGACY_ENV] ?? "")); - return includeLegacy ? [canonical, path.join(dataRoot, "codex-openai-codex", "state")] : [canonical]; + return [path.join(dataRoot, "codex-claude-code-fusion", "state")]; } export function resolveCodexStateDir(env = process.env) { diff --git a/plugins/fusion/scripts/lib/engine-acceptance.mjs b/plugins/fusion/scripts/lib/engine-acceptance.mjs new file mode 100644 index 0000000..746af59 --- /dev/null +++ b/plugins/fusion/scripts/lib/engine-acceptance.mjs @@ -0,0 +1,139 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export class GrokPluginUpgradeRequiredError extends Error { + constructor(message) { + super(message); + this.name = "GrokPluginUpgradeRequiredError"; + this.code = "GROK_PLUGIN_UPGRADE_REQUIRED"; + } +} + +function configuredHome(env = process.env) { + const candidate = typeof env.HOME === "string" ? env.HOME.trim() : ""; + return candidate && path.isAbsolute(candidate) ? candidate : os.homedir(); +} + +function regularFile(file) { + try { + return fs.statSync(file).isFile(); + } catch { + return false; + } +} + +function siblingCompanion(engine) { + return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..", engine, "scripts", `${engine}-companion.mjs`); +} + +export function newestGrokCompanion(env = process.env) { + const override = typeof env.FUSION_GROK_COMPANION === "string" ? env.FUSION_GROK_COMPANION.trim() : ""; + if (override && path.isAbsolute(override) && regularFile(override)) { + return override; + } + const base = path.join(configuredHome(env), ".claude", "plugins", "cache", "claude-code-fusion", "grok"); + try { + const candidates = fs + .readdirSync(base) + .map((version) => path.join(base, version, "scripts", "grok-companion.mjs")) + .filter((candidate) => fs.existsSync(candidate)) + .map((candidate) => ({ candidate, mtime: fs.statSync(candidate).mtimeMs })) + .sort((left, right) => right.mtime - left.mtime); + if (candidates.length > 0) { + return candidates[0].candidate; + } + } catch { + void 0; + } + const sibling = siblingCompanion("grok"); + return fs.existsSync(sibling) ? sibling : null; +} + +export function newestCodexCompanion(env = process.env) { + const override = typeof env.FUSION_CODEX_COMPANION === "string" ? env.FUSION_CODEX_COMPANION.trim() : ""; + if (override && path.isAbsolute(override) && regularFile(override)) { + return override; + } + const base = path.join(configuredHome(env), ".claude", "plugins", "cache", "claude-code-fusion", "codex"); + try { + const candidates = fs + .readdirSync(base) + .map((version) => path.join(base, version, "scripts", "codex-companion.mjs")) + .filter((candidate) => fs.existsSync(candidate)) + .map((candidate) => ({ candidate, mtime: fs.statSync(candidate).mtimeMs })) + .sort((left, right) => right.mtime - left.mtime || left.candidate.localeCompare(right.candidate)); + if (candidates.length > 0) { + return candidates[0].candidate; + } + } catch { + void 0; + } + const sibling = siblingCompanion("codex"); + return fs.existsSync(sibling) ? sibling : null; +} + +function companionFailureMessage(result) { + return [result.stderr, result.stdout, result.error?.message].filter((value) => typeof value === "string" && value.trim()).join("\n").trim(); +} + +function codexAcceptanceSubcommandUnavailable(result) { + if (result.error) { + return true; + } + const message = companionFailureMessage(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 grokAcceptanceSubcommandUnavailable(result) { + if (result.error) { + return false; + } + const message = companionFailureMessage(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 }) { + 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 result = spawnSync(process.execPath, argv, { cwd: workspaceRoot, encoding: "utf8", env }); + if (!result.error && result.status === 0) { + return { updated: true }; + } + if (codexAcceptanceSubcommandUnavailable(result)) { + return { updated: false }; + } + throw new Error(companionFailureMessage(result) || "Codex acceptance record update failed."); +} + +function recordGrokCompanionAcceptance({ jobId, acceptance, reason, 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 result = spawnSync(process.execPath, argv, { cwd: workspaceRoot, encoding: "utf8", env }); + if (!result.error && result.status === 0) { + return { updated: true }; + } + if (grokAcceptanceSubcommandUnavailable(result)) { + throw new GrokPluginUpgradeRequiredError("The installed Grok plugin does not support record-acceptance. Upgrade the Grok plugin and retry."); + } + 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 }) { + if (engine === "grok") { + return recordGrokCompanionAcceptance({ jobId, acceptance, reason, acceptFailedTransport, workspaceRoot, asJson, env }); + } + const result = recordCodexCompanionAcceptance({ jobId, acceptance, source, reason, acceptFailedTransport, workspaceRoot, env }); + if (!result.updated && requireUpdate) { + throw new Error("Codex job record was not updated because the companion or subcommand is unavailable."); + } + return result; +} diff --git a/plugins/fusion/scripts/lib/worker-state.mjs b/plugins/fusion/scripts/lib/worker-state.mjs index 2eebaa8..9fe58ce 100644 --- a/plugins/fusion/scripts/lib/worker-state.mjs +++ b/plugins/fusion/scripts/lib/worker-state.mjs @@ -254,7 +254,7 @@ export function updateWorkerRecord(taskId, env, updater) { } export function markWorkerCollected(record, method, collectedAt = new Date().toISOString()) { - const awaitingVerdict = !record.collectedAt && record.acceptance === "unverified"; + const awaitingVerdict = record.acceptance === "unverified" && record.acceptanceRecordedAt == null && (record.awaitingVerdict === true || !record.collectedAt); const collectionMethod = canonicalCollectionMethod(record.collectionMethod) ?? canonicalCollectionMethod(method); if (!collectionMethod) { throw new TypeError("Fusion worker collection method is invalid."); @@ -267,8 +267,11 @@ export function markWorkerCollected(record, method, collectedAt = new Date().toI awaitingCollectionArmedAt: null, ...(awaitingVerdict ? { awaitingVerdict: true, - awaitingVerdictArmedAt: collectedAt - } : {}) + awaitingVerdictArmedAt: record.awaitingVerdictArmedAt ?? collectedAt + } : { + awaitingVerdict: false, + awaitingVerdictArmedAt: null + }) }; } @@ -698,49 +701,133 @@ function validatedAcceptanceFields(acceptance, source, reason) { return { safeReason }; } -function updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, validateRecord }) { +function workerAcceptanceGate(record, taskId, acceptance, acceptFailedTransport) { + const collector = canonicalWorkerAgentType(record.agentType) === "fusion:job-collector" || record.completionContract === "collector"; + if (collector && record.peerEngine === "codex") { + throw new Error(`Fusion collector task ${taskId} acceptance must be recorded through the Codex acceptance ledger.`); + } + if (collector && (record.peerEngine !== "grok" || !/^[a-f0-9]{32}$/.test(record.peerJobId ?? "") || !record.peerTransportStatus)) { + throw new Error(`Fusion collector task ${taskId} does not contain a verified Grok collection identity.`); + } + if (acceptance === "accepted" && record.transportStatus === "failed" && !acceptFailedTransport) { + throw new Error(`Fusion worker task ${taskId} has transport status failed. Pass --accept-failed-transport to record accepted.`); + } +} + +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) { + const { pendingVerdict: _pendingVerdict, pendingVerdictError: _pendingVerdictError, ...settled } = record; + return { + ...settled, + acceptance, + acceptanceSource: source, + acceptanceReason: reason, + acceptanceRecordedAt: acceptanceRecordedAt(now), + awaitingVerdict: false, + awaitingVerdictArmedAt: null + }; +} + +function workerAcceptanceResult(record, queued) { + const result = { record, queued }; + for (const key of Object.keys(record)) { + if (!Object.hasOwn(result, key)) { + Object.defineProperty(result, key, { enumerable: false, get: () => record[key] }); + } + } + return result; +} + +export function applyQueuedVerdict(record, now) { + if (!record?.pendingVerdict || !isTerminalWorkerStatus(record.transportStatus)) { + return record; + } + const { acceptance, source, reason, acceptFailedTransport = false } = record.pendingVerdict; + if (acceptance === "accepted" && record.transportStatus !== "done") { + const { pendingVerdict: _pendingVerdict, ...unsettled } = record; + return { + ...unsettled, + awaitingVerdict: true, + awaitingVerdictArmedAt: acceptanceRecordedAt(now), + pendingVerdictError: `Queued accepted verdict was not applied because transport status is ${record.transportStatus}.` + }; + } + try { + const { safeReason } = validatedAcceptanceFields(acceptance, source, reason); + workerAcceptanceGate(record, record.taskId, acceptance, acceptFailedTransport); + return settleWorkerAcceptance(record, { acceptance, source, reason: safeReason }, now); + } catch (error) { + return { + ...record, + pendingVerdictError: error instanceof Error ? error.message : String(error) + }; + } +} + +export function isPendingSettlement(record) { + return record.completionContract !== "collector" && isTerminalWorkerStatus(record.transportStatus) && Boolean(record.collectedAt) && record.awaitingVerdict === true; +} + +export function isSettledWorker(record) { + return record.acceptanceRecordedAt != null && record.awaitingVerdict !== true; +} + +function updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, validateRecord, acceptFailedTransport = false, queueIfNonTerminal = false }) { + let queued = false; const updated = updateWorkerRecord(taskId, env, (record) => { if (!record) { throw new Error(`Fusion worker task ${taskId} was not found.`); } if (!isTerminalWorkerStatus(record.transportStatus)) { - throw new Error(`Fusion worker task ${taskId} is not terminal.`); + if (!queueIfNonTerminal) { + throw new Error(`Fusion worker task ${taskId} is not terminal.`); + } + queued = true; + const { pendingVerdictError: _pendingVerdictError, ...queuedRecord } = record; + return { + ...queuedRecord, + pendingVerdict: { + acceptance, + source, + reason: safeReason, + queuedAt: new Date().toISOString(), + ...(acceptFailedTransport ? { acceptFailedTransport: true } : {}) + } + }; } validateRecord(record); - return { - ...record, - acceptance, - acceptanceSource: source, - acceptanceReason: safeReason, - acceptanceRecordedAt: new Date().toISOString(), - awaitingVerdict: false, - awaitingVerdictArmedAt: null - }; + return settleWorkerAcceptance(record, { acceptance, source, reason: safeReason }); }); - return updated; + return { record: updated, queued }; } -export function recordWorkerAcceptance({ taskId, acceptance, env = process.env, source = "main-loop", reason = null, acceptFailedTransport = process.argv.includes("--record-worker-acceptance") && process.argv.includes("--accept-failed-transport") }) { +export function validateWorkerAcceptance({ record, taskId, acceptance, source = "main-loop", reason = null, acceptFailedTransport = false }) { const { safeReason } = validatedAcceptanceFields(acceptance, source, reason); - return updateWorkerAcceptance({ + if (!record) { + throw new Error(`Fusion worker task ${taskId} was not found.`); + } + workerAcceptanceGate(record, taskId, acceptance, acceptFailedTransport); + return { safeReason }; +} + +export function recordWorkerAcceptance({ taskId, acceptance, env = process.env, source = "main-loop", reason = null, acceptFailedTransport = false }) { + const { safeReason } = validatedAcceptanceFields(acceptance, source, reason); + const result = updateWorkerAcceptance({ taskId, acceptance, env, source, safeReason, + acceptFailedTransport, + queueIfNonTerminal: true, validateRecord(record) { - const collector = canonicalWorkerAgentType(record.agentType) === "fusion:job-collector" || record.completionContract === "collector"; - if (collector && record.peerEngine === "codex") { - throw new Error(`Fusion collector task ${taskId} acceptance must be recorded through the Codex acceptance ledger.`); - } - if (collector && (record.peerEngine !== "grok" || !/^[a-f0-9]{32}$/.test(record.peerJobId ?? "") || !record.peerTransportStatus)) { - throw new Error(`Fusion collector task ${taskId} does not contain a verified Grok collection identity.`); - } - if (acceptance === "accepted" && record.transportStatus === "failed" && !acceptFailedTransport) { - throw new Error(`Fusion worker task ${taskId} has transport status failed. Pass --accept-failed-transport to record accepted.`); - } + workerAcceptanceGate(record, taskId, acceptance, acceptFailedTransport); } }); + return workerAcceptanceResult(result.record, result.queued); } export function recordCodexCollectorAcceptance({ taskId, jobId, sessionId, acceptance, env = process.env, source = "main-loop", reason = null }) { @@ -765,5 +852,5 @@ export function recordCodexCollectorAcceptance({ taskId, jobId, sessionId, accep throw new Error(`Fusion collector task ${taskId} does not match the Codex acceptance identity.`); } } - }); + }).record; } diff --git a/plugins/fusion/skills/doctor/SKILL.md b/plugins/fusion/skills/doctor/SKILL.md index 5b71432..6f66233 100644 --- a/plugins/fusion/skills/doctor/SKILL.md +++ b/plugins/fusion/skills/doctor/SKILL.md @@ -16,21 +16,21 @@ Environment overrides captured at invocation: Checks to perform: 1. A set CLAUDE_CODE_SUBAGENT_MODEL can silently override every agent model pin (Claude Code behavior as observed on 2.1.x); flag it as a problem unless the user says it is deliberate. -2. Read ~/.claude/settings.json and report the `model` key. It should be an alias, preferably `best[1m]` (Fable when available, latest Opus otherwise, floats to future releases). Flag any dated full model ID as a rot risk. Also confirm codex@claude-code-fusion, grok@claude-code-fusion, and fusion@claude-code-fusion appear in enabledPlugins. Treat codex@openai-codex and codex@claude-code-fusion being enabled together as an error because both register the `codex` namespace. If only codex@openai-codex is enabled, report a migration warning: use the old `/codex:status --all` to collect or cancel every authoritative running job, exit that session, disable and uninstall the old plugin with `--keep-data`, install codex@claude-code-fusion, then open a new session. When Codex is missing, report that ordinary implementation has lost its primary lane. Bounded isolated work may use Grok under `burst`; work that needs Claude tools, privacy, or falls outside Grok's safety boundary uses fusion:fast-worker. Recommend installing codex@claude-code-fusion. +2. Read ~/.claude/settings.json and report the `model` key. It should be an alias, preferably `best[1m]` (Fable when available, latest Opus otherwise, floats to future releases). Flag any dated full model ID as a rot risk. Also confirm codex@claude-code-fusion, grok@claude-code-fusion, and fusion@claude-code-fusion appear in enabledPlugins. Treat codex@openai-codex and codex@claude-code-fusion being enabled together as an error because both register the `codex` namespace. When Codex is missing, report that ordinary implementation has lost its primary lane. Bounded isolated work may use Grok under `burst`; work that needs Claude tools, privacy, or falls outside Grok's safety boundary uses fusion:fast-worker. Recommend installing codex@claude-code-fusion. 3. Read the frontmatter of every file in ${CLAUDE_PLUGIN_ROOT}/agents/ and report each `model`, `effort`, and `maxTurns` value. Alias pins (opus, sonnet, haiku) float to new releases automatically and are healthy. Full model ID pins are rot risks; fusion:trivial-worker's claude-haiku-4-5 pin is deliberate because the haiku alias can be remapped by ANTHROPIC_DEFAULT_HAIKU_MODEL, so a full ID pins the tier to the intended model regardless of that override, but it must be bumped by hand when a newer cheap tier ships. 4. List ~/.claude/agents/ if it exists. A file whose name matches a plugin agent (deep-reasoner, fast-worker, trivial-worker) registers as a duplicate agent alongside the fusion: namespaced one and confuses auto delegation; flag it as a stale leftover to delete unless the user says it is a deliberate local override. 5. Compare the template hash of ~/.claude/rules/orchestration.md against the template hash of ${CLAUDE_PLUGIN_ROOT}/rules/orchestration.md, using the same normalization as rules-sync: replace everything between the `` and `` sentinel lines with the fixed normalized marker before computing SHA-256. Prefer `node --input-type=module` with `hashRulesTemplate` exported by `${CLAUDE_PLUGIN_ROOT}/scripts/lib/rules-template.mjs`; a sed or similar comparison that strips only the sentinel region is also acceptable. Missing live rules or a mismatched template hash means the static routing policy is stale; point at /fusion:setup to install or update it. A user selected live model table may differ from the scored canonical table and is healthy when the template hashes match. -6. Remind the user of the role model: the main Claude session is the only control plane and final judge. Claude workers are native specialists rather than an independent model family, Codex owns ordinary implementation and default deep external review, and Grok keeps the protected `burst`, `independence`, `live-web`, `large-context`, and `best-of-n` roles. The orchestrator, deep reasoning, mechanical, and trivial Claude tiers are roles bound to alias tiers, not to specific models, so same tier releases (for example Sonnet 5 under the sonnet alias) adopt their role with no configuration change. Only a genuinely new tier above or between the existing rungs requires a decision. +6. Remind the user of the role model: the main Claude session is the only control plane and final judge. Claude workers are native specialists rather than an independent model family, Codex owns ordinary implementation and default deep external review, and Grok keeps the protected `burst`, `independence`, `live-web`, and `large-context` roles. The orchestrator, deep reasoning, mechanical, and trivial Claude tiers are roles bound to alias tiers, not to specific models, so same tier releases (for example Sonnet 5 under the sonnet alias) adopt their role with no configuration change. Only a genuinely new tier above or between the existing rungs requires a decision. 7. Note the recovery moves: if the main session fell back from Fable (safety classifier or availability), /model best restores the strongest available orchestrator. Peer engine models are owned by their own CLIs (~/.grok/config.toml, ~/.codex/config.toml); this setup never pins them. 8. Point at /codex:setup and /grok:setup for peer runtime health checks beyond config defaults; peer config defaults themselves are covered in the next step. 9. Peer engine defaults: extract the model related keys from ~/.grok/config.toml and the model and model_reasoning_effort keys from ~/.codex/config.toml (never read or print the full files; they sit next to credentials; use a scoped rg query rather than Read on those two files). Report each peer's configured default and flag grok drift when the grok CLI default is absent from the capability table or when that table's recorded lane or scores disagree with the default shown by the CLI. Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/fusion-config.mjs" audit` and report its Codex listing section as one checklist item. It reads only ~/.codex/models_cache.json (or FUSION_CODEX_MODELS_CACHE) and never refreshes it or invokes the Codex binary. Flag the configured-but-absent IDs and unconfigured gpt-* newcomers it reports, including the listing mtime. Treat `listing unavailable` as a warning without aborting the doctor run. /fusion:config is the write path for changing these scores; this audit never changes model-routing.json. When the capability table has no scored models at all, recommend /fusion:config propose so routing stops relying on the qualitative lane descriptions alone. 10. Audit the installed Codex surface against the foreground invariant. Glob `~/.claude/plugins/cache/claude-code-fusion/codex/*/commands/` and `~/.claude/plugins/cache/claude-code-fusion/codex/*/agents/`, then inspect the task, rescue, review, adversarial-review, and codex-rescue instructions. Task and review calls must stay foreground regardless of complexity, expected duration, review size, or model unless the incoming user request explicitly supplied `--background`; the rescue agent's companion Bash call must use the 10 minute tool timeout. A package that cannot fit the cap must be split or routed elsewhere. Fusion orchestration gives every detached job it creates one same turn collection attempt capped at 540000ms; a timeout must remain explicitly uncollected with the job id and result command. Direct Codex slash command instructions inspect through status and collect through result; Fusion's monitor is notification only. Confirm that task and rescue expose opt in `--web`, require `--write --network` for sandbox network access, and leave both settings disabled when absent. Flag any instruction that can detach implicitly or accepts a foreground receipt as a result. -11. Check both ~/.claude/plugins/data/codex-claude-code-fusion/state and the read only compatibility root ~/.claude/plugins/data/codex-openai-codex/state. The first root is canonical for new jobs; Fusion stats and the breaker may read the second after migration but never copy or mutate it. If neither exists, report no recorded Codex job history yet or an uninitialized Codex state path, not necessarily an error. +11. Check ~/.claude/plugins/data/codex-claude-code-fusion/state, the canonical root for new Codex jobs. If it does not exist, report no recorded Codex job history yet or an uninitialized Codex state path, not necessarily an error. 12. In the live ~/.claude/rules/orchestration.md, collect every concrete model ID named in routing prose outside the `` and `` sentinel region, then compare them with the model IDs in the live capability table. Flag every prose ID without a table row as an orphan. 13. Probe the installed Codex plugin cache by globbing `~/.claude/plugins/cache/claude-code-fusion/codex/*/commands/` for the `/codex:task`, `/codex:rescue`, `/codex:review`, `/codex:adversarial-review`, `/codex:status`, `/codex:result`, `/codex:cancel`, and `/codex:setup` command files and `~/.claude/plugins/cache/claude-code-fusion/codex/*/agents/` for the codex-rescue agent. Report any absent surface as a degraded-capability warning, not an error. 14. Audit the installed Grok runtime and instructions against the actual headless surface, not version assumptions. Consult, write, review, and best-of-n must all use `--sandbox strict`; a workspace downgrade is an error. Every managed run requires `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`; consult also requires `--permission-mode` and `--allow`, no-web requires `--disable-web-search`, write requires `--always-approve`, review requires `--json-schema`, best-of-n requires `--best-of-n` and `--background-wait-timeout`, and ordinary calls require `--no-wait-for-background` or the bounded wait flag. Missing installation, version, or applicable capability readiness is failure kind `setup`; recommend upgrading or repairing the CLI and rerunning `/grok:setup`, never retrying the unchanged task. Consult tools are read, grep, and list plus optional direct web tools. Write tools are `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`, with Agent only for best-of-n; Grok has no `write` tool id, and `search_replace` creates files. Every mode denies `search_tool`, `use_tool`, `ask_user_question`, and MCP tools, while ordinary calls also deny Agent. Treat command-pattern denies for common direct grok, claude, and codex invocations as best effort because absolute paths, aliases or functions, and indirect scripts can bypass them. A hard nested-execution boundary requires removing `run_terminal_cmd` or an OS-level executable or network policy. Builder tracing starts only after stdin is consumed, so it cannot prove policy before prompt delivery or every side effect; fallback or unmatched warnings must terminate early, and a successful close without positive `tools allowlist applied` evidence is an error. Confirm the following behavior. 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. Confirm that it removes inherited `CLAUDE_CODE_*`, `CLAUDE_PLUGIN_*`, `GROK_COMPANION_*`, and `_GROK_CLAUDE_MARKER_OVERRIDE`, broadly scrubs unrelated secrets while preserving xAI authentication, and passes `--no-auto-update`. Confirm `GROK_MEMORY=0` is forced by default, only a direct ordinary task with explicit `--memory` receives `GROK_MEMORY=1`, and Fusion routed briefs, review, stop gate, and best-of-n cannot enable it. Report upstream minimum-version forced update as a residual execution and network boundary. 15. Audit native Grok configuration as a user-controlled trust boundary. The `search_tool`, `use_tool`, and MCP denies block model invocation but do not prove that native MCP servers, plugins, or hooks under `~/.grok` were not started during agent construction or had no side effects; those bridge variables disable Codex compatibility imports, not native Grok configuration. Run `grok inspect --json` and review the reported `permissions`, `hooks`, `plugins`, `agents`, `mcpServers`, and `externalCompat` surfaces as the native configuration exposure check that the SECURITY threat model otherwise cannot verify. Use scoped `rg` queries that report configured surface names without printing credentials or full configuration, and label any native startup surface as an explicit trust-boundary notice rather than claiming MCP side effects are disabled. The entire Grok home is read-write under strict, and the sandbox permits consult `read_file` to reach `~/.grok/auth.json`, config, and sessions while write terminal commands may receive preserved xAI authentication variables. Confirm the companion's best-effort Read denies cover `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` with both absolute paths and standard `**/.grok` patterns. Report that raw path variants, symbolic links, and shell or indirect scripts can bypass those tool-level rules, so environment scrubbing and pattern denies do not isolate filesystem credentials. When memory is explicitly enabled, upstream's internal first-turn memory injection is not a model `read_file` call and can place selected memory in the model context sent to xAI despite those Read denies. Narrower exposure requires a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization. -16. Audit Grok sandbox, transport, output, metrics, session, worktree, collection, continuity, history, and ACP evidence. Strict's upstream `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`; the unique private `TMPDIR` is preferred and handshake-verified, not the only writable temporary surface. Before prompt delivery, the adapter must validate a new `ProfileApplied` event from the incremental shared `sandbox-events.jsonl` delta that contains this run's private path and matches the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. Upstream events have no run id or pid, so unrelated `ProfileApplied` and `ApplyFailed` records are not attributable to this run; failure events are auxiliary only. A matching owned-stderr warning, handshake timeout, missing or malformed matching evidence, shared-log rotation or disappearance, or matching field mismatch fails closed. The matching event proves configuration application, not actual network isolation: Linux uses seccomp, while macOS network blocking is a no-op and write shells may reach the network. Every prompt uses `--prompt-file /dev/stdin`. Stdout uses a 0600 file unlinked immediately after open and parsed through its descriptor under the total limit. An upstream `model_usage` row contains only input, output, cache-read, and model-call counts plus optional cost, never synthetic reasoning or total tokens. `usage_is_incomplete` makes reported values observed lower bounds and both job-total token and cost coverage incomplete. Exact cost otherwise requires a positive complete USD and tick pair, with ticks authoritative at 10000000000 per USD. Short-cwd metadata uses only the exact encoded path; long-cwd lookup requires an exact decoded or `.cwd` match and never selects a sole cross-cwd candidate. Review requires one `--json-schema` call; an error or explicit null structured result fails, and only a completely absent field permits local text parsing. Continuity must default to `manual`; optional `claude-session` affinity may select only a completed ordinary task from the same Claude session, exact resolved cwd, mode, strict profile, and memory boundary. Fusion routed briefs must not receive automatic affinity, while explicit resume remains limited to compatible terminal task records. `/grok:history` must expose only the safe companion metadata projection, default to the current workspace, and never read native session contents, briefs, results, or logs. Treat native sessions, companion history, and cross-session memory as three separate stores. Native session cleanup defaults to 30 days and accepts only a positive `[storage] cleanup_ttl_days`, with `0` falling back to 30; the companion ledger has no promised automatic garbage collection. Fusion creates real worktrees. Upstream ships ACP today, but the companion has not adopted it and continues per-call invocation; any future companion adoption must pool by canonical cwd plus sandbox profile with `grok --cwd --sandbox agent --no-leader stdio`. A collector timeout, dead job, or status error reports the Fusion task id, peer job id, `uncollected`, and exact result command. Successful Grok collection remains unverified until `/fusion:stats --record-worker-acceptance --source main-loop` records the main-loop judgment. +16. Audit Grok sandbox, transport, output, metrics, session, worktree, collection, continuity, history, and ACP evidence. Strict's upstream `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`; the unique private `TMPDIR` is preferred and handshake-verified, not the only writable temporary surface. Before prompt delivery, the adapter must validate a new `ProfileApplied` event from the incremental shared `sandbox-events.jsonl` delta that contains this run's private path and matches the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. Upstream events have no run id or pid, so unrelated `ProfileApplied` and `ApplyFailed` records are not attributable to this run; failure events are auxiliary only. A matching owned-stderr warning, handshake timeout, missing or malformed matching evidence, shared-log rotation or disappearance, or matching field mismatch fails closed. The matching event proves configuration application, not actual network isolation: Linux uses seccomp, while macOS network blocking is a no-op and write shells may reach the network. Every prompt uses `--prompt-file /dev/stdin`. Stdout uses a 0600 file unlinked immediately after open and parsed through its descriptor under the total limit. An upstream `model_usage` row contains only input, output, cache-read, and model-call counts plus optional cost, never synthetic reasoning or total tokens. `usage_is_incomplete` makes reported values observed lower bounds and both job-total token and cost coverage incomplete. Exact cost otherwise requires a positive complete USD and tick pair, with ticks authoritative at 10000000000 per USD. Short-cwd metadata uses only the exact encoded path; long-cwd lookup requires an exact decoded or `.cwd` match and never selects a sole cross-cwd candidate. Review requires one `--json-schema` call; an error or explicit null structured result fails, and only a completely absent field permits local text parsing. Continuity must default to `manual`; optional `claude-session` affinity may select only a completed ordinary task from the same Claude session, exact resolved cwd, mode, strict profile, and memory boundary. Fusion routed briefs must not receive automatic affinity, while explicit resume remains limited to compatible terminal task records. `/grok:history` must expose only the safe companion metadata projection, default to the current workspace, and never read native session contents, briefs, results, or logs. Treat native sessions, companion history, and cross-session memory as three separate stores. Native session cleanup defaults to 30 days and accepts only a positive `[storage] cleanup_ttl_days`, with `0` falling back to 30; the companion ledger has no promised automatic garbage collection. Fusion creates real worktrees. Upstream ships ACP today, but the companion has not adopted it and continues per-call invocation; any future companion adoption must pool by canonical cwd plus sandbox profile with `grok --cwd --sandbox agent --no-leader stdio`. A collector timeout, dead job, or status error reports the Fusion task id, peer job id, `uncollected`, and exact result command. Successful Grok collection remains unverified until `/fusion:stats --record =` records the main-loop judgment. 17. Run `codex --version` and `grok --version` and compare them against the verified contract versions in `${CLAUDE_PLUGIN_ROOT}/verified-versions.json`. An installed version older than the verified one is a compatibility warning. A newer one is an informational drift notice that the companion contracts were verified against the older release and behavior may have moved. Neither is an error by itself because a companion call failing with failure kind `setup` is the enforcement signal. Multiple Codex generations sharing one `CODEX_HOME` (for example an IDE or desktop app bundling a newer Codex) are worth flagging when the versions straddle the verified release. Report the findings compactly. Do not change any file; this command is read only. diff --git a/plugins/fusion/skills/stats/SKILL.md b/plugins/fusion/skills/stats/SKILL.md index 58234c1..d933642 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 ]] [--include-legacy] [--record =]... [--source ] [--reason ] [--record-acceptance ] [--accept-failed-transport] [--record-worker-acceptance ] [--json]' +argument-hint: '[--all] [--session [id]] [--trace] [--audit [--days ]] [--record =[:accept-failed-transport]]... [--source ] [--reason ] [--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-out is a strict verdict settlement made only of repeatable `--record =` pairs, optional `--source collector|main-loop`, and optional `--json`; those arguments may be passed directly. Anything else, including every `--reason` text value, still uses the raw-args transport exactly as described above. +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-out is a strict 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; those arguments may be passed directly. Anything else, including every `--reason` text value, 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. `--include-legacy` explicitly includes the previous Codex plugin state root. `/fusion:stats --record-acceptance` 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. 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/tests/breaker-check.test.mjs b/tests/breaker-check.test.mjs index a8183de..e591f4b 100644 --- a/tests/breaker-check.test.mjs +++ b/tests/breaker-check.test.mjs @@ -44,23 +44,6 @@ function run(sandbox, extraEnv = {}) { }); } -function runWithHome(sandbox, extraEnv = {}) { - const env = { - ...process.env, - HOME: sandbox.root, - GROK_COMPANION_DATA: sandbox.grokData - }; - delete env.FUSION_CODEX_STATE; - delete env.FUSION_CODEX_STATE_DIR; - delete env.CODEX_COMPANION_DATA; - Object.assign(env, extraEnv); - return spawnSync(process.execPath, [script], { env, encoding: "utf8" }); -} - -function homeCodexState(sandbox, pluginDataName) { - return path.join(sandbox.root, ".claude", "plugins", "data", pluginDataName, "state"); -} - test("an in-window quota failure prints a grok breaker advisory", (t) => { const sandbox = makeSandbox(t); writeRecord(jobFile(path.join(sandbox.grokData, "state"), "workspace", "quota"), { @@ -76,54 +59,6 @@ test("an in-window quota failure prints a grok breaker advisory", (t) => { assert.strictEqual(result.stderr, ""); }); -test("a legacy grok 402 stored as a generic error still opens the quota breaker", (t) => { - const sandbox = makeSandbox(t); - writeRecord(jobFile(path.join(sandbox.grokData, "state"), "workspace", "legacy-quota"), { - status: "error", - failureKind: "error", - errorTail: "request failed with status code: 402", - finishedAt: new Date(Date.now() - 30 * 60000).toISOString() - }); - - const result = run(sandbox); - assert.strictEqual(result.status, 0); - assert.match(result.stdout, /treat the grok breaker as open unless verified recovered/); - assert.match(result.stdout, /last failure quota \d+ minutes? ago/); - assert.strictEqual(result.stderr, ""); -}); - -test("a legacy grok 402 stored only in the job log still opens the quota breaker", (t) => { - const sandbox = makeSandbox(t); - const file = jobFile(path.join(sandbox.grokData, "state"), "workspace", "legacy-log-quota"); - writeRecord(file, { - status: "error", - failureKind: "error", - errorTail: "generic failure", - finishedAt: new Date(Date.now() - 30 * 60000).toISOString() - }); - fs.writeFileSync(file.replace(/\.json$/, ".log"), "HTTP/1.1 402\n", "utf8"); - - const result = run(sandbox); - assert.strictEqual(result.status, 0); - assert.match(result.stdout, /treat the grok breaker as open unless verified recovered/); - assert.match(result.stdout, /last failure quota \d+ minutes? ago/); - assert.strictEqual(result.stderr, ""); -}); - -test("a legacy failed grok job without a failure kind still recovers quota", (t) => { - const sandbox = makeSandbox(t); - writeRecord(jobFile(path.join(sandbox.grokData, "state"), "workspace", "legacy-empty-kind"), { - status: "error", - errorTail: "exhausted balance", - finishedAt: new Date(Date.now() - 30 * 60000).toISOString() - }); - - const result = run(sandbox); - assert.strictEqual(result.status, 0); - assert.match(result.stdout, /last failure quota \d+ minutes? ago/); - assert.strictEqual(result.stderr, ""); -}); - test("breaker quota recovery matches the companion diagnostic vocabulary", () => { for (const diagnostic of [ "usage limit reached", @@ -198,9 +133,10 @@ test("one Codex rate limit keeps the breaker closed for its retry", (t) => { test("a consecutive second Codex rate limit opens the breaker", (t) => { const sandbox = makeSandbox(t); writeRecord(jobFile(sandbox.codexState, "workspace", "rate-limit-first"), { - status: "failed", + status: "error", + failureKind: "rate_limited", errorMessage: "Rate limit exceeded", - completedAt: new Date(Date.now() - 3 * 60000).toISOString() + finishedAt: new Date(Date.now() - 3 * 60000).toISOString() }); writeRecord(jobFile(sandbox.codexState, "workspace", "rate-limit-second"), { status: "error", @@ -466,88 +402,17 @@ test("a typed non-breaker Codex error is not reclassified from diagnostic text", assert.strictEqual(result.stdout, ""); }); -test("Codex state resolution prefers the canonical override and keeps the legacy override compatible", () => { - assert.strictEqual(resolveCodexStateDir({ FUSION_CODEX_STATE: "/canonical", FUSION_CODEX_STATE_DIR: "/legacy" }), "/canonical"); - assert.strictEqual(resolveCodexStateDir({ FUSION_CODEX_STATE_DIR: "/legacy" }), "/legacy"); +test("Codex state resolution uses configured canonical state sources", () => { + assert.strictEqual(resolveCodexStateDir({ FUSION_CODEX_STATE: "/canonical" }), "/canonical"); assert.strictEqual(resolveCodexStateDir({ CODEX_COMPANION_DATA: "/adapter-data" }), "/adapter-data/state"); assert.strictEqual(resolveCodexStateDir({}), path.join(os.homedir(), ".claude", "plugins", "data", "codex-claude-code-fusion", "state")); -}); - -test("default Codex state resolution reads only canonical state unless legacy inclusion is explicit", () => { assert.deepStrictEqual(resolveCodexStateRoots({ HOME: "/test-home" }), ["/test-home/.claude/plugins/data/codex-claude-code-fusion/state"]); - assert.deepStrictEqual(resolveCodexStateRoots({ HOME: "/test-home", FUSION_CODEX_INCLUDE_LEGACY: "1" }), [ - "/test-home/.claude/plugins/data/codex-claude-code-fusion/state", - "/test-home/.claude/plugins/data/codex-openai-codex/state" - ]); assert.deepStrictEqual(resolveCodexStateRoots({ HOME: "/test-home", FUSION_CODEX_STATE: "/override" }), ["/override"]); assert.deepStrictEqual(resolveCodexStateRoots({ HOME: "/test-home", CODEX_COMPANION_DATA: "/adapter" }), ["/adapter/state"]); assert.deepStrictEqual(resolveCodexStateRoots({ HOME: "/test-home", FUSION_CODEX_STATE: "relative-override" }), ["/test-home/.claude/plugins/data/codex-claude-code-fusion/state"]); assert.deepStrictEqual(resolveCodexStateRoots({ HOME: "/test-home", CODEX_COMPANION_DATA: "relative-adapter" }), ["/test-home/.claude/plugins/data/codex-claude-code-fusion/state"]); }); -test("the breaker ignores legacy Codex state by default and reads it only when explicitly enabled", (t) => { - const sandbox = makeSandbox(t); - writeRecord(jobFile(homeCodexState(sandbox, "codex-openai-codex"), "workspace", "legacy-auth"), { - id: "legacy-auth", - status: "failed", - errorMessage: "Authentication failed", - completedAt: new Date(Date.now() - 2 * 60000).toISOString() - }); - const defaultResult = runWithHome(sandbox); - assert.strictEqual(defaultResult.status, 0); - assert.strictEqual(defaultResult.stdout, ""); - const legacyResult = runWithHome(sandbox, { FUSION_CODEX_INCLUDE_LEGACY: "1" }); - assert.strictEqual(legacyResult.status, 0); - assert.match(legacyResult.stdout, /codex breaker as open.*last failure auth/); -}); - -test("a mirrored Codex rate limit is deduplicated across canonical and legacy roots", (t) => { - const sandbox = makeSandbox(t); - const record = { - id: "mirrored-rate-limit", - status: "failed", - errorMessage: "Rate limit exceeded", - completedAt: new Date(Date.now() - 2 * 60000).toISOString() - }; - writeRecord(jobFile(homeCodexState(sandbox, "codex-claude-code-fusion"), "workspace", record.id), record); - writeRecord(jobFile(homeCodexState(sandbox, "codex-openai-codex"), "workspace", record.id), record); - const result = runWithHome(sandbox, { FUSION_CODEX_INCLUDE_LEGACY: "1" }); - assert.strictEqual(result.status, 0); - assert.doesNotMatch(result.stdout, /codex breaker/); -}); - -test("a terminal Codex copy supersedes a stale running mirror with the same id", (t) => { - const sandbox = makeSandbox(t); - writeRecord(jobFile(homeCodexState(sandbox, "codex-claude-code-fusion"), "workspace-a", "mirrored-job"), { - id: "mirrored-job", - status: "running", - errorMessage: "Authentication failed", - updatedAt: new Date(Date.now() - 1 * 60000).toISOString() - }); - writeRecord(jobFile(homeCodexState(sandbox, "codex-openai-codex"), "workspace-b", "mirrored-job"), { - id: "mirrored-job", - status: "completed", - errorMessage: "Authentication failed before recovery", - completedAt: new Date(Date.now() - 2 * 60000).toISOString() - }); - const result = runWithHome(sandbox, { FUSION_CODEX_INCLUDE_LEGACY: "1" }); - assert.strictEqual(result.status, 0); - assert.doesNotMatch(result.stdout, /codex breaker/); -}); - -test("an explicit Codex state override excludes legacy home state", (t) => { - const sandbox = makeSandbox(t); - writeRecord(jobFile(homeCodexState(sandbox, "codex-openai-codex"), "workspace", "legacy-auth"), { - id: "legacy-auth", - status: "failed", - errorMessage: "Authentication failed", - completedAt: new Date(Date.now() - 2 * 60000).toISOString() - }); - const result = runWithHome(sandbox, { FUSION_CODEX_STATE: path.join(sandbox.root, "isolated-state"), FUSION_CODEX_INCLUDE_LEGACY: "1" }); - assert.strictEqual(result.status, 0); - assert.doesNotMatch(result.stdout, /codex breaker/); -}); - test("a failure outside the lookback window is silent", (t) => { const sandbox = makeSandbox(t); writeRecord(jobFile(path.join(sandbox.grokData, "state"), "workspace", "old"), { diff --git a/tests/codex-jobs-monitor.test.mjs b/tests/codex-jobs-monitor.test.mjs index 8615ba6..ce5d213 100644 --- a/tests/codex-jobs-monitor.test.mjs +++ b/tests/codex-jobs-monitor.test.mjs @@ -146,14 +146,14 @@ async function waitForAnnouncedRecord(sandbox, jobId, sessionId = null, workspac test("a pre-existing terminal job emits nothing on startup", async (t) => { const sandbox = makeSandbox(t); - const { record } = seedJob(sandbox, { status: "completed" }); + const { record } = seedJob(sandbox, { status: "done" }); const monitor = startMonitor(sandbox, envFor(sandbox)); t.after(() => monitor.child.kill("SIGKILL")); await waitForAnnouncedRecord(sandbox, record.id); assert.deepStrictEqual(monitor.lines(), []); }); -test("a job transitioning to completed emits exactly one correctly shaped line", async (t) => { +test("a job transitioning to done emits exactly one correctly shaped line", async (t) => { const sandbox = makeSandbox(t); const { file, record } = seedJob(sandbox, { status: "running" }); const monitor = startMonitor(sandbox, envFor(sandbox)); @@ -161,13 +161,13 @@ test("a job transitioning to completed emits exactly one correctly shaped line", await waitUntil(() => readAnnouncedState(sandbox)); assert.deepStrictEqual(monitor.lines(), []); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.strictEqual(lines.length, 1); assert.strictEqual( lines[0], - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ); await waitForAnnouncedRecord(sandbox, record.id); @@ -182,14 +182,14 @@ test("monitors jobs from a sibling worktree in the same Git repository", async ( t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.strictEqual(lines.length, 1); - assert.match(lines[0], new RegExp(`^codex job ${record.id} completed\\.`)); + assert.match(lines[0], new RegExp(`^codex job ${record.id} done\\.`)); }); -test("a job transitioning to failed reports a truncated error message when present", async (t) => { +test("a job transitioning to error reports a truncated error message when present", async (t) => { const sandbox = makeSandbox(t); const { file, record } = seedJob(sandbox, { status: "running" }); const monitor = startMonitor(sandbox, envFor(sandbox)); @@ -198,8 +198,8 @@ test("a job transitioning to failed reports a truncated error message when prese writeJobRecordFile(file, { ...record, - status: "failed", - completedAt: new Date().toISOString(), + status: "error", + finishedAt: new Date().toISOString(), pid: null, errorMessage: "worker process exited with status 1\nfull stack trace follows", }); @@ -208,24 +208,24 @@ test("a job transitioning to failed reports a truncated error message when prese assert.strictEqual(lines.length, 1); assert.strictEqual( lines[0], - `codex job ${record.id} failed (worker process exited with status 1). collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} error (worker process exited with status 1). collect with /codex:result ${record.id}; completion notices do not replace collection.` ); }); -test("a job transitioning to failed with no error message emits no suffix", async (t) => { +test("a job transitioning to error with no error message emits no suffix", async (t) => { const sandbox = makeSandbox(t); const { file, record } = seedJob(sandbox, { status: "running" }); const monitor = startMonitor(sandbox, envFor(sandbox)); t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "failed", completedAt: new Date().toISOString(), pid: null }); + writeJobRecordFile(file, { ...record, status: "error", finishedAt: new Date().toISOString(), pid: null }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.strictEqual(lines.length, 1); assert.strictEqual( lines[0], - `codex job ${record.id} failed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} error. collect with /codex:result ${record.id}; completion notices do not replace collection.` ); }); @@ -240,7 +240,7 @@ test("a long error message is truncated to a sane length", async (t) => { writeJobRecordFile(file, { ...record, status: "cancelled", - completedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), pid: null, errorMessage: longMessage, }); @@ -272,18 +272,18 @@ test("session monitors announce their own Claude jobs once and still surface orp t.after(() => otherMonitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox, "session-own") && readAnnouncedState(sandbox, "session-other")); - writeJobRecordFile(ownFile, { ...ownRecord, status: "completed", completedAt: new Date().toISOString() }); - writeJobRecordFile(otherFile, { ...otherRecord, status: "completed", completedAt: new Date().toISOString() }); - writeJobRecordFile(orphanFile, { ...orphanRecord, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(ownFile, { ...ownRecord, status: "done", finishedAt: new Date().toISOString() }); + writeJobRecordFile(otherFile, { ...otherRecord, status: "done", finishedAt: new Date().toISOString() }); + writeJobRecordFile(orphanFile, { ...orphanRecord, status: "done", finishedAt: new Date().toISOString() }); await waitUntil(() => (ownMonitor.lines().length === 2 && otherMonitor.lines().length === 2 ? true : null)); assert.deepStrictEqual(ownMonitor.lines().sort(), [ - `codex job ${orphanRecord.id} completed. collect with /codex:result ${orphanRecord.id}; completion notices do not replace collection.`, - `codex job ${ownRecord.id} completed. collect with /codex:result ${ownRecord.id}; completion notices do not replace collection.` + `codex job ${orphanRecord.id} done. collect with /codex:result ${orphanRecord.id}; completion notices do not replace collection.`, + `codex job ${ownRecord.id} done. collect with /codex:result ${ownRecord.id}; completion notices do not replace collection.` ].sort()); assert.deepStrictEqual(otherMonitor.lines().sort(), [ - `codex job ${orphanRecord.id} completed. collect with /codex:result ${orphanRecord.id}; completion notices do not replace collection.`, - `codex job ${otherRecord.id} completed. collect with /codex:result ${otherRecord.id}; completion notices do not replace collection.` + `codex job ${orphanRecord.id} done. collect with /codex:result ${orphanRecord.id}; completion notices do not replace collection.`, + `codex job ${otherRecord.id} done. collect with /codex:result ${otherRecord.id}; completion notices do not replace collection.` ].sort()); const { ownLedger, otherLedger } = await waitUntil(() => { @@ -309,13 +309,13 @@ test("with the session id env var unset, a job from a different session still em t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.strictEqual(lines.length, 1); assert.strictEqual( lines[0], - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ); }); @@ -330,7 +330,7 @@ test("a job in a different workspace stays silent even once terminal", async (t) t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); await waitUntil(() => readAnnouncedState(sandbox)); assert.deepStrictEqual(monitor.lines(), []); @@ -347,13 +347,13 @@ test("a monitor started from a subdirectory still matches jobs recorded against t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.strictEqual(lines.length, 1); assert.strictEqual( lines[0], - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ); }); @@ -366,11 +366,11 @@ test("a monitor includes jobs recorded in a worktree beneath the repo root", asy t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.deepStrictEqual(lines, [ - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ]); }); @@ -552,7 +552,7 @@ test("a terminal worktree copy suppresses a dead alarm for its stale running mir seedJob(sandbox, { id, status: "running", pid, pidIdentity }, { workspace: "parent-ws", workspaceRoot: sandbox.workDir }); seedJob( sandbox, - { id, status: "completed", completedAt: new Date().toISOString(), pid: null }, + { id, status: "done", finishedAt: new Date().toISOString(), pid: null }, { workspace: "worktree-ws", workspaceRoot: worktreeRoot } ); @@ -588,13 +588,13 @@ test("a vanished state root mid run does not crash the process and a later tick fs.rmSync(sandbox.stateRoot, { force: true }); const { file, record } = seedJob(sandbox, { status: "running" }); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.strictEqual(lines.length, 1); assert.strictEqual( lines[0], - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ); }); @@ -608,11 +608,11 @@ test("removing an observed workspace state directory does not disable later moni fs.rmSync(path.join(sandbox.stateRoot, "old-workspace"), { recursive: true, force: true }); const { file, record } = seedJob(sandbox, { status: "running" }, { workspace: "new-workspace" }); await waitUntil(() => fs.existsSync(file)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.deepStrictEqual(lines, [ - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ]); }); @@ -624,11 +624,11 @@ test("a malformed job record does not suppress a healthy job transition", async t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.deepStrictEqual(lines, [ - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ]); }); @@ -670,14 +670,14 @@ function announcedPath(sandbox, sessionId = null, workspaceRoot = sandbox.workDi return path.join(sandbox.stateRoot, name); } -test("restart with persisted dedup state does not re-announce a completed job", async (t) => { +test("restart with persisted dedup state does not re-announce a done job", async (t) => { const sandbox = makeSandbox(t); const { file, record } = seedJob(sandbox, { status: "running" }); const env = envFor(sandbox); const first = startMonitor(sandbox, env); await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); await waitUntil(() => (first.lines().length > 0 ? first.lines() : null)); assert.strictEqual(first.lines().length, 1); first.child.kill("SIGTERM"); @@ -687,9 +687,9 @@ test("restart with persisted dedup state does not re-announce a completed job", const persisted = JSON.parse(fs.readFileSync(announcedPath(sandbox), "utf8")); assert.strictEqual(persisted.schemaVersion, 3); assert.strictEqual(persisted.repositoryKey, createHash("sha256").update(sandbox.workDir).digest("hex").slice(0, 16)); - assert.ok(persisted.keys.includes(`${record.id}:completed`)); + assert.ok(persisted.keys.includes(`${record.id}:done`)); assert.strictEqual(persisted.records[0].jobId, record.id); - assert.strictEqual(persisted.records[0].transportStatus, "completed"); + assert.strictEqual(persisted.records[0].transportStatus, "done"); const observedAt = persisted.records[0].observedAt; const second = startMonitor(sandbox, env); @@ -697,9 +697,9 @@ test("restart with persisted dedup state does not re-announce a completed job", await waitUntil(() => readAnnouncedState(sandbox)?.records[0]?.observedAt === observedAt); assert.strictEqual(JSON.parse(fs.readFileSync(announcedPath(sandbox), "utf8")).records[0].observedAt, observedAt); - writeJobRecordFile(file, { ...record, status: "running", completedAt: null }); + writeJobRecordFile(file, { ...record, status: "running", finishedAt: null }); await waitForAnnouncedRecord(sandbox, record.id); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(file, { ...record, status: "done", finishedAt: new Date().toISOString() }); await waitForAnnouncedRecord(sandbox, record.id); assert.deepStrictEqual(second.lines(), []); }); @@ -723,7 +723,7 @@ test("workspace-scoped dedup lets identical job ids announce independently and p const first = startMonitor(sandbox, env); await waitUntil(() => readAnnouncedState(sandbox, "shared-session")); - writeJobRecordFile(firstJob.file, { ...firstJob.record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(firstJob.file, { ...firstJob.record, status: "done", finishedAt: new Date().toISOString() }); await waitUntil(() => (first.lines().length > 0 ? first.lines() : null)); first.child.kill("SIGTERM"); await once(first.child, "close"); @@ -733,11 +733,11 @@ test("workspace-scoped dedup lets identical job ids announce independently and p const second = startMonitor(sandbox, env, { cwd: otherWorkDir }); t.after(() => second.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox, "shared-session", otherWorkDir)); - writeJobRecordFile(secondJob.file, { ...secondJob.record, status: "completed", completedAt: new Date().toISOString() }); + writeJobRecordFile(secondJob.file, { ...secondJob.record, status: "done", finishedAt: new Date().toISOString() }); const lines = await waitUntil(() => (second.lines().length > 0 ? second.lines() : null)); assert.strictEqual(lines.length, 1); - assert.match(lines[0], new RegExp(`codex job ${sharedId} completed`)); + assert.match(lines[0], new RegExp(`codex job ${sharedId} done`)); assert.notStrictEqual(firstStateFile, announcedPath(sandbox, "shared-session", otherWorkDir)); fs.rmSync(secondJob.file); @@ -748,9 +748,9 @@ test("workspace-scoped dedup lets identical job ids announce independently and p test("restart catch-up announces an unrecorded terminal job owned by the active session", async (t) => { const sandbox = makeSandbox(t); const { record } = seedJob(sandbox, { - status: "completed", + status: "done", sessionId: "session-own", - completedAt: new Date().toISOString() + finishedAt: new Date().toISOString() }); fs.writeFileSync(announcedPath(sandbox, "session-own"), "[]\n", "utf8"); @@ -758,7 +758,7 @@ test("restart catch-up announces an unrecorded terminal job owned by the active t.after(() => monitor.child.kill("SIGKILL")); const lines = await waitUntil(() => (monitor.lines().length > 0 ? monitor.lines() : null)); assert.deepStrictEqual(lines, [ - `codex job ${record.id} completed. collect with /codex:result ${record.id}; completion notices do not replace collection.` + `codex job ${record.id} done. collect with /codex:result ${record.id}; completion notices do not replace collection.` ]); }); @@ -766,7 +766,7 @@ test("an unavailable jobs snapshot neither prunes nor persists announcement stat const sandbox = makeSandbox(t); const { record } = seedJob(sandbox, { status: "running" }); const stateFile = announcedPath(sandbox); - fs.writeFileSync(stateFile, `${JSON.stringify([`${record.id}:completed`])}\n`, "utf8"); + fs.writeFileSync(stateFile, `${JSON.stringify([`${record.id}:done`])}\n`, "utf8"); const monitor = startMonitor(sandbox, envFor(sandbox)); t.after(() => monitor.child.kill("SIGKILL")); await waitUntil(() => readAnnouncedState(sandbox)?.schemaVersion === 3); @@ -978,36 +978,6 @@ test("a second poll does not duplicate a model-audit observation", async (t) => assert.strictEqual(readModelAuditLines(sandbox).length, 1); }); -test("an argv observation is replaced on disk by a higher-ranked terminal observation", async (t) => { - const sandbox = makeSandbox(t); - const threadId = "thread-model-audit-replacement"; - const turnId = "turn-model-audit-replacement"; - writeRollout(sandbox, threadId, [ - taskEvent("task_started", turnId, "2026-07-14T00:00:00.000Z"), - turnContext(turnId, "terminal-model", "xhigh"), - taskEvent("task_complete", turnId, "2026-07-14T00:00:03.000Z") - ]); - const { file, record } = seedJob(sandbox, { status: "running", pid: process.pid, result: { threadId }, turnId }); - const monitor = startMonitor(sandbox, envForAudit(sandbox)); - t.after(() => monitor.child.kill("SIGKILL")); - - const argv = await waitUntil(() => { - const observations = readModelAuditLines(sandbox); - return observations.length === 1 && observations[0].source === "argv" ? observations : null; - }); - assert.strictEqual(argv[0].jobId, record.id); - - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); - - const observations = await waitUntil(() => { - const current = readModelAuditLines(sandbox); - return current.length === 1 && current[0].source === "rollout-turn-context" ? current : null; - }); - assert.strictEqual(observations[0].jobId, record.id); - assert.strictEqual(observations[0].model, "terminal-model"); - assert.strictEqual(observations[0].effort, "xhigh"); -}); - test("concurrent monitor processes append one model-audit observation per job", async (t) => { const sandbox = makeSandbox(t); const barrier = path.join(sandbox.root, "fake-ps-barrier"); @@ -1246,84 +1216,6 @@ test("canonical foreground jobs are retained for stats without emitting completi assert.deepStrictEqual(monitor.lines(), []); }); -test("legacy terminal transition persists a structured ledger and exact rollout sidecars", async (t) => { - const sandbox = makeSandbox(t); - const threadId = "thread-monitor"; - const turnId = "turn-monitor"; - writeRollout(sandbox, threadId, [ - taskEvent("task_started", turnId, "2026-07-14T00:00:00.000Z"), - turnContext(turnId, "actual-model", "xhigh"), - rolloutTokenCount(90, 40, 20, 6, 110), - taskEvent("task_complete", turnId, "2026-07-14T00:00:03.000Z") - ]); - const { file, record } = seedJob(sandbox, { - status: "running", - turnId, - result: { threadId }, - request: { model: "requested-model", effort: "low" } - }); - const monitor = startMonitor(sandbox, envFor(sandbox)); - t.after(() => monitor.child.kill("SIGKILL")); - await waitUntil(() => readAnnouncedState(sandbox)); - writeJobRecordFile(file, { ...record, status: "completed", completedAt: new Date().toISOString() }); - await waitUntil(() => (monitor.lines().length > 0 ? true : null)); - - const { state, terminal } = await waitUntil(() => { - try { - const state = JSON.parse(fs.readFileSync(announcedPath(sandbox), "utf8")); - const terminal = state.records.find((entry) => entry.jobId === record.id); - return terminal ? { state, terminal } : null; - } catch { - return null; - } - }); - assert.strictEqual(state.schemaVersion, 3); - assert.strictEqual(terminal.repositoryKey, state.repositoryKey); - assert.strictEqual(terminal.transportStatus, "completed"); - assert.strictEqual(terminal.model, "actual-model"); - assert.strictEqual(terminal.modelSource, "rollout-turn-context"); - assert.strictEqual(terminal.effort, "xhigh"); - assert.strictEqual(terminal.effortSource, "rollout-turn-context"); - assert.deepStrictEqual(terminal.tokenUsage, { inputTokens: 90, cachedInputTokens: 40, outputTokens: 20, reasoningOutputTokens: 6, totalTokens: 110 }); - - const modelAudit = readModelAuditLines(sandbox); - assert.strictEqual(modelAudit.at(-1).source, "rollout-turn-context"); - assert.strictEqual(modelAudit.at(-1).model, "actual-model"); - assert.strictEqual(modelAudit.at(-1).workspaceRoot, sandbox.workDir); - assert.strictEqual(modelAudit.at(-1).repositoryKey, state.repositoryKey); - const tokenPath = path.join(sandbox.fusionData, "observations", createHash("sha256").update(sandbox.workDir).digest("hex").slice(0, 16), "token-usage.jsonl"); - const tokenAudit = fs.readFileSync(tokenPath, "utf8").trim().split("\n").map((line) => JSON.parse(line)); - assert.strictEqual(tokenAudit.at(-1).availability, "available"); - assert.strictEqual(tokenAudit.at(-1).source, "rollout-turn-delta"); - assert.strictEqual(tokenAudit.at(-1).workspaceRoot, sandbox.workDir); - assert.strictEqual(tokenAudit.at(-1).repositoryKey, state.repositoryKey); - - fs.rmSync(file); - await waitForAnnouncedRecord(sandbox, record.id); - const retained = JSON.parse(fs.readFileSync(announcedPath(sandbox), "utf8")); - assert.ok(retained.records.some((entry) => entry.jobId === record.id)); -}); - -test("legacy announcement arrays migrate to the structured terminal ledger", async (t) => { - const sandbox = makeSandbox(t); - const file = announcedPath(sandbox); - fs.writeFileSync(file, `${JSON.stringify(["legacy-completed:completed", "legacy-dead:dead"])}\n`); - const monitor = startMonitor(sandbox, envFor(sandbox)); - t.after(() => monitor.child.kill("SIGKILL")); - - const migrated = await waitUntil(() => { - try { - const state = JSON.parse(fs.readFileSync(file, "utf8")); - return state?.schemaVersion === 3 ? state : null; - } catch { - return null; - } - }); - assert.ok(migrated.keys.includes("legacy-completed:completed")); - assert.ok(migrated.records.some((entry) => entry.jobId === "legacy-completed" && entry.tokenUsageAvailability === "unavailable")); - assert.strictEqual(migrated.records.some((entry) => entry.jobId === "legacy-dead"), false); -}); - const REPAIR_UNAVAILABLE_LINE = "codex jobs monitor: companion repair unavailable; running in announce-only mode"; diff --git a/tests/fusion-raw-args-transport.test.mjs b/tests/fusion-raw-args-transport.test.mjs index 58589fc..e63a880 100644 --- a/tests/fusion-raw-args-transport.test.mjs +++ b/tests/fusion-raw-args-transport.test.mjs @@ -17,11 +17,10 @@ function sandbox(t) { } test("raw argument parsing preserves quoted values without evaluating shell syntax", () => { - const raw = '--record-acceptance task-safe rejected --reason "literal $(touch /tmp/never); `id`; $HOME" --json'; + const raw = '--record task-safe=rejected --reason "literal $(touch /tmp/never); `id`; $HOME" --json'; assert.deepStrictEqual(splitRawArgs(raw), [ - "--record-acceptance", - "task-safe", - "rejected", + "--record", + "task-safe=rejected", "--reason", "literal $(touch /tmp/never); `id`; $HOME", "--json" @@ -145,9 +144,12 @@ test("the stats CLI forwards --accept-failed-transport from staged arguments unc 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); - fs.writeFileSync(transport.file, `--record-acceptance ${jobId} accepted --accept-failed-transport`); + 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: "error" })); + fs.writeFileSync(transport.file, `--record ${jobId}=accepted --accept-failed-transport`); 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", "accepted", "--source", "collector", "--accept-failed-transport"]); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "main-loop", "--accept-failed-transport"]); }); diff --git a/tests/fusion-stats.test.mjs b/tests/fusion-stats.test.mjs index 1299b9e..0f82813 100644 --- a/tests/fusion-stats.test.mjs +++ b/tests/fusion-stats.test.mjs @@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { FILE_ENGINE_DESCRIPTORS, STATS_PROVIDER_REGISTRY, @@ -15,7 +15,6 @@ import { buildFusionStats, buildSessionReport, buildTraceReport, - acceptanceSidecarPath, claudeWorkerStats, codexStats, fileBasedEngineStats, @@ -26,7 +25,6 @@ import { normalizeCollectionMethod, normalizeCodexTokenUsage, pruneDeadWorkspaces, - recordCodexAcceptance, resolveCodexJobEffort, resolveCodexJobModel, resolveGitRepositoryCommonDir, @@ -37,9 +35,8 @@ import { workspaceRootsShareRepository } from "../plugins/fusion/scripts/fusion-stats.mjs"; import { resolveStateDir as guardResolveStateDir, stateFile as guardStateFile } from "../plugins/fusion/scripts/inline-delegation-guard.mjs"; -import { resolveCodexStateRoots } from "../plugins/fusion/scripts/lib/codex-state-roots.mjs"; import { createRawArgsTransport } from "../plugins/fusion/scripts/lib/raw-args-transport.mjs"; -import { createWorkerRecord, readWorkerRecord, recordWorkerAcceptance, updateWorkerRecord, workerRecordFile } from "../plugins/fusion/scripts/lib/worker-state.mjs"; +import { applyQueuedVerdict, createWorkerRecord, readWorkerRecord, recordWorkerAcceptance, updateWorkerRecord, workerRecordFile } from "../plugins/fusion/scripts/lib/worker-state.mjs"; import { gitIsolation } from "./lib/git-fixture.mjs"; const SCRIPT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "plugins", "fusion", "scripts", "fusion-stats.mjs"); @@ -170,12 +167,6 @@ function writeCacheCompanion(home, engine, version, modifiedAt) { return companion; } -function fusionStatsWithoutSiblings(root) { - const scripts = path.join(root, "fixture", "plugins", "fusion", "scripts"); - fs.cpSync(path.dirname(SCRIPT), scripts, { recursive: true }); - return path.join(scripts, "fusion-stats.mjs"); -} - test("acceptance companion resolvers use the newest cache entry and ignore invalid overrides", (t) => { const dir = sandbox(t); const home = path.join(dir, "home"); @@ -200,41 +191,6 @@ test("acceptance companion resolvers use the newest cache entry and ignore inval assert.strictEqual(newestCodexCompanion({ HOME: home, FUSION_CODEX_COMPANION: codexDirectory }), newerCodex); }); -test("acceptance recording preserves unavailable companion behavior without repository siblings", async (t) => { - const dir = sandbox(t); - const fixture = await import(`${pathToFileURL(fusionStatsWithoutSiblings(dir)).href}?fixture=${Date.now()}`); - const home = path.join(dir, "home"); - const grokData = path.join(dir, "grok-data"); - const grokJobId = "a".repeat(32); - writeGrokJob(grokData, dir, grokJobId, { status: "done", mode: "consult" }); - - assert.strictEqual(fixture.newestGrokCompanion({ HOME: home }), null); - assert.strictEqual(fixture.newestCodexCompanion({ HOME: home }), null); - assert.throws( - () => fixture.main(["--record-acceptance", grokJobId, "accepted"], { cwd: dir, env: { HOME: home, FUSION_CODEX_STATE: path.join(dir, "missing-codex"), GROK_COMPANION_DATA: grokData } }), - fixture.GrokPluginUpgradeRequiredError - ); - - const codexState = path.join(dir, "codex-state"); - const fusionData = path.join(dir, "fusion-data"); - const codexJobId = "b".repeat(32); - let stdout = ""; - let stderr = ""; - writeCodexJob(codexState, dir, codexJobId, { status: "done", jobClass: "task" }); - const observation = fixture.main( - ["--record-acceptance", codexJobId, "accepted"], - { - cwd: dir, - env: { HOME: home, FUSION_CODEX_STATE: codexState, FUSION_DATA_DIR: fusionData, GROK_COMPANION_DATA: path.join(dir, "missing-grok") }, - stdout: { write(chunk) { stdout += chunk; } }, - stderr: { write(chunk) { stderr += chunk; } } - } - ); - assert.strictEqual(observation.jobId, codexJobId); - assert.match(stdout, new RegExp(`Recorded accepted for Codex job ${codexJobId}`)); - assert.match(stderr, /Warning: Codex job record was not updated because the companion or subcommand is unavailable/); -}); - function createSiblingWorktree(root) { const main = path.join(root, "main"); const sibling = path.join(root, "sibling"); @@ -258,92 +214,25 @@ test("aggregates codex job state scoped to the workspace", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "state"); writeCodexJob(stateRoot, dir, "task-a", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-02T06:00:00.000Z", startedAt: "2026-07-02T06:00:00.000Z", - completedAt: "2026-07-02T06:00:10.000Z" + finishedAt: "2026-07-02T06:00:10.000Z" }); - writeCodexJob(stateRoot, "/somewhere/else", "task-b", { status: "failed", jobClass: "task", createdAt: "2026-07-02T05:00:00.000Z" }); + writeCodexJob(stateRoot, "/somewhere/else", "task-b", { status: "error", jobClass: "task", createdAt: "2026-07-02T05:00:00.000Z" }); const scoped = run({ cwd: dir, codexState: stateRoot }, ["--json"]); assert.strictEqual(scoped.status, 0, scoped.stderr); const scopedData = JSON.parse(scoped.stdout).codex; assert.strictEqual(scopedData.totalJobs, 1); - assert.strictEqual(scopedData.byStatus.completed, 1); + assert.strictEqual(scopedData.byStatus.done, 1); assert.strictEqual(scopedData.meanWallClockSeconds, 10); const all = run({ cwd: dir, codexState: stateRoot }, ["--all", "--json"]); const allData = JSON.parse(all.stdout).codex; assert.strictEqual(allData.totalJobs, 2); - assert.strictEqual(allData.byStatus.failed, 1); -}); - -test("Codex stats aggregate canonical and legacy roots without mutating legacy state", (t) => { - const dir = sandbox(t); - const [canonicalState, legacyState] = resolveCodexStateRoots({ HOME: dir, FUSION_CODEX_INCLUDE_LEGACY: "1" }); - writeCodexJob(canonicalState, dir, "canonical", { status: "done", jobClass: "task", createdAt: "2026-07-14T01:00:00.000Z", finishedAt: "2026-07-14T01:00:01.000Z" }); - const legacyFile = writeCodexJob(legacyState, dir, "legacy", { status: "completed", jobClass: "task", createdAt: "2026-07-13T01:00:00.000Z", completedAt: "2026-07-13T01:00:01.000Z" }); - const before = fs.readFileSync(legacyFile, "utf8"); - const result = codexStats({ all: true, env: { HOME: dir, FUSION_DATA_DIR: path.join(dir, "fusion-data"), FUSION_CODEX_INCLUDE_LEGACY: "1" }, cwd: dir }); - assert.strictEqual(result.available, true); - assert.strictEqual(result.totalJobs, 2); - assert.strictEqual(result.byStatus.done, 1); - assert.strictEqual(result.byStatus.completed, 1); - assert.strictEqual(fs.readFileSync(legacyFile, "utf8"), before); -}); - -test("default Codex stats ignore legacy history unless explicitly opted in", (t) => { - const dir = sandbox(t); - const legacyState = path.join(dir, ".claude", "plugins", "data", "codex-openai-codex", "state"); - writeCodexJob(legacyState, dir, "old", { status: "completed", jobClass: "task", createdAt: "2026-07-13T01:00:00.000Z", completedAt: "2026-07-13T01:00:01.000Z" }); - assert.deepStrictEqual(resolveCodexStateRoots({ HOME: dir }), [path.join(dir, ".claude", "plugins", "data", "codex-claude-code-fusion", "state")]); - assert.strictEqual(codexStats({ all: true, env: { HOME: dir, FUSION_DATA_DIR: path.join(dir, "fusion-data") }, cwd: dir }).available, false); - assert.strictEqual(codexStats({ all: true, env: { HOME: dir, FUSION_DATA_DIR: path.join(dir, "fusion-data"), FUSION_CODEX_INCLUDE_LEGACY: "1" }, cwd: dir }).totalJobs, 1); -}); - -test("Codex stats deduplicate a job mirrored across canonical and legacy roots", (t) => { - const dir = sandbox(t); - const [canonicalState, legacyState] = resolveCodexStateRoots({ HOME: dir, FUSION_CODEX_INCLUDE_LEGACY: "1" }); - const fields = { status: "completed", jobClass: "task", createdAt: "2026-07-14T01:00:00.000Z", completedAt: "2026-07-14T01:00:01.000Z" }; - writeCodexJob(canonicalState, dir, "mirrored", fields); - writeCodexJob(legacyState, dir, "mirrored", fields); - const result = codexStats({ all: true, env: { HOME: dir, FUSION_DATA_DIR: path.join(dir, "fusion-data"), FUSION_CODEX_INCLUDE_LEGACY: "1" }, cwd: dir }); - assert.strictEqual(result.available, true); - assert.strictEqual(result.totalJobs, 1); -}); - -test("Codex stats collapse stale legacy running mirrors for a deleted workspace", (t) => { - const dir = sandbox(t); - const [, legacyState] = resolveCodexStateRoots({ HOME: dir, FUSION_CODEX_INCLUDE_LEGACY: "1" }); - const deletedWorkspace = path.join(dir, "deleted-worktree"); - writeCodexJob(legacyState, deletedWorkspace, "legacy-mirror", { status: "running", jobClass: "task", createdAt: "2026-07-14T01:00:00.000Z" }); - writeCodexJob(legacyState, deletedWorkspace, "legacy-mirror", { status: "completed", jobClass: "task", createdAt: "2026-07-14T01:00:00.000Z", completedAt: "2026-07-14T01:00:01.000Z" }); - const result = codexStats({ all: true, env: { HOME: dir, FUSION_DATA_DIR: path.join(dir, "fusion-data"), FUSION_CODEX_INCLUDE_LEGACY: "1" }, cwd: dir }); - assert.strictEqual(result.available, true); - assert.strictEqual(result.totalJobs, 1); - assert.strictEqual(result.byStatus.completed, 1); - assert.strictEqual(result.pendingTransportJobs, 0); -}); - -test("the compatibility state override retains legacy stale mirror semantics", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "custom-legacy-state"); - const deletedWorkspace = path.join(dir, "deleted-worktree"); - writeCodexJob(stateRoot, deletedWorkspace, "legacy-mirror", { status: "running", jobClass: "task", createdAt: "2026-07-14T01:00:00.000Z" }); - writeCodexJob(stateRoot, deletedWorkspace, "legacy-mirror", { status: "completed", jobClass: "task", createdAt: "2026-07-14T01:00:00.000Z", completedAt: "2026-07-14T01:00:01.000Z" }); - const result = codexStats({ all: true, env: { HOME: dir, FUSION_DATA_DIR: path.join(dir, "fusion-data"), FUSION_CODEX_STATE_DIR: stateRoot }, cwd: dir }); - assert.strictEqual(result.totalJobs, 1); - assert.strictEqual(result.byStatus.completed, 1); - assert.strictEqual(result.pendingTransportJobs, 0); -}); - -test("an explicit Codex state override excludes legacy home stats", (t) => { - const dir = sandbox(t); - const [, legacyState] = resolveCodexStateRoots({ HOME: dir, FUSION_CODEX_INCLUDE_LEGACY: "1" }); - writeCodexJob(legacyState, dir, "legacy", { status: "completed", jobClass: "task", createdAt: "2026-07-14T01:00:00.000Z" }); - const result = codexStats({ all: true, env: { HOME: dir, FUSION_DATA_DIR: path.join(dir, "fusion-data"), FUSION_CODEX_STATE: path.join(dir, "isolated-state") }, cwd: dir }); - assert.strictEqual(result.available, false); + assert.strictEqual(allData.byStatus.error, 1); }); test("aggregates the canonical Codex lifecycle and direct token usage", (t) => { @@ -478,10 +367,8 @@ test("scopes Codex jobs by Git repository identity across sibling worktrees", (t const initialized = spawnSync("git", ["init", "-q"], { cwd: unrelated, encoding: "utf8" }); assert.strictEqual(initialized.status, 0, initialized.stderr); const stateRoot = path.join(dir, "state"); - writeCodexJob(stateRoot, sibling, "sibling-task", { status: "completed", jobClass: "task", createdAt: "2026-07-02T06:00:00.000Z" }); - writeCodexJob(stateRoot, unrelated, "unrelated-task", { status: "completed", jobClass: "task", createdAt: "2026-07-02T06:01:00.000Z" }); - const parentKey = createHash("sha256").update(dir).digest("hex").slice(0, 16); - fs.writeFileSync(path.join(stateRoot, `codex-jobs-monitor-announced.parent.${parentKey}.json`), `${JSON.stringify(["parent-task:completed"])}\n`); + writeCodexJob(stateRoot, sibling, "sibling-task", { status: "done", jobClass: "task", createdAt: "2026-07-02T06:00:00.000Z" }); + writeCodexJob(stateRoot, unrelated, "unrelated-task", { status: "done", jobClass: "task", createdAt: "2026-07-02T06:01:00.000Z" }); assert.strictEqual(resolveGitRepositoryCommonDir(main), resolveGitRepositoryCommonDir(sibling)); assert.strictEqual(workspaceRootsShareRepository(main, sibling), true); @@ -491,7 +378,7 @@ test("scopes Codex jobs by Git repository identity across sibling worktrees", (t assert.strictEqual(result.status, 0, result.stderr); const stats = JSON.parse(result.stdout).codex; assert.strictEqual(stats.totalJobs, 1); - assert.deepStrictEqual(stats.byTransportStatus, { completed: 1 }); + assert.deepStrictEqual(stats.byTransportStatus, { done: 1 }); }); test("survives a malformed codex job file", (t) => { @@ -502,7 +389,7 @@ test("survives a malformed codex job file", (t) => { fs.writeFileSync(path.join(jobsDir, "broken.json"), "{ not json"); fs.writeFileSync( path.join(jobsDir, "task-ok.json"), - JSON.stringify({ id: "task-ok", workspaceRoot: dir, status: "completed", jobClass: "review", createdAt: "2026-07-02T06:00:00.000Z" }) + JSON.stringify({ id: "task-ok", workspaceRoot: dir, status: "done", jobClass: "review", createdAt: "2026-07-02T06:00:00.000Z" }) ); const result = run({ cwd: dir, codexState: stateRoot }, ["--json"]); @@ -613,11 +500,11 @@ test("codexStats matches fileBasedEngineStats with the codex descriptor", (t) => const dir = sandbox(t); const stateRoot = path.join(dir, "state"); writeCodexJob(stateRoot, dir, "via-wrapper", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-02T06:00:00.000Z", startedAt: "2026-07-02T06:00:00.000Z", - completedAt: "2026-07-02T06:00:05.000Z" + finishedAt: "2026-07-02T06:00:05.000Z" }); const env = { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion-data") }; const viaCodex = codexStats({ env, cwd: dir }); @@ -680,13 +567,13 @@ function writeTraceFixture(dir) { request: { model: "grok-model", effort: "high" } }); writeCodexJob(codexState, dir, "codex-approximate", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-10T10:06:00.000Z", request: { model: "codex-model", effort: "xhigh" } }); writeCodexJob(codexState, dir, "codex-outside-span", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-10T11:11:00.000Z", request: { model: "outside-model", effort: "low" } @@ -726,8 +613,8 @@ test("--session reports the seeded lane counters for the current session", (t) = test("--session scopes per-engine job counts to the current session where available", (t) => { const dir = sandbox(t); const codexState = path.join(dir, "codex-state"); - writeCodexJob(codexState, dir, "own-job", { status: "completed", jobClass: "task", sessionId: "session-abc", createdAt: "2026-07-02T06:00:00.000Z" }); - writeCodexJob(codexState, dir, "other-job", { status: "completed", jobClass: "task", sessionId: "session-xyz", createdAt: "2026-07-02T06:00:00.000Z" }); + writeCodexJob(codexState, dir, "own-job", { status: "done", jobClass: "task", sessionId: "session-abc", createdAt: "2026-07-02T06:00:00.000Z" }); + writeCodexJob(codexState, dir, "other-job", { status: "done", jobClass: "task", sessionId: "session-xyz", createdAt: "2026-07-02T06:00:00.000Z" }); const grokDataDir = path.join(dir, "grok-data"); writeGrokJob(grokDataDir, dir, "own-grok-job", { status: "done", mode: "consult", claudeSessionId: "session-abc", createdAt: "2026-07-04T00:00:00.000Z" }); @@ -741,7 +628,7 @@ test("--session scopes per-engine job counts to the current session where availa const data = JSON.parse(result.stdout); assert.strictEqual(data.engines.codex.available, true); assert.strictEqual(data.engines.codex.totalJobs, 1); - assert.deepStrictEqual(data.engines.codex.byStatus, { completed: 1 }); + assert.deepStrictEqual(data.engines.codex.byStatus, { done: 1 }); assert.strictEqual(data.engines.grok.available, true); assert.strictEqual(data.engines.grok.totalJobs, 1); assert.deepStrictEqual(data.engines.grok.byStatus, { done: 1 }); @@ -772,7 +659,7 @@ test("--trace joins exact Grok and approximate Codex jobs into dispatch time ord ["2026-07-10T10:10:00.000Z", "dispatch", "codex"] ]); assert.match(result.stdout, /grok-model@high \| done/); - assert.match(result.stdout, /codex-model@xhigh \| completed/); + assert.match(result.stdout, /codex-model@xhigh \| done/); assert.match(result.stdout, /codex \(approximate\)/); assert.doesNotMatch(result.stdout, /outside-model/); }); @@ -816,7 +703,7 @@ test("--trace --json returns the session timeline shape", (t) => { jobId: "codex-approximate", model: "codex-model", effort: "xhigh", - status: "completed" + status: "done" }); }); @@ -862,7 +749,7 @@ test("--trace includes Codex jobs from a sibling worktree in the same repository const { main, sibling } = createSiblingWorktree(dir); const fixture = writeTraceFixture(main); writeCodexJob(fixture.codexState, sibling, "codex-sibling", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-10T10:07:00.000Z", request: { model: "sibling-model", effort: "high" } @@ -873,7 +760,7 @@ test("--trace includes Codex jobs from a sibling worktree in the same repository GROK_COMPANION_DATA: fixture.grokDataDir }); assert.strictEqual(result.status, 0, result.stderr); - assert.match(result.stdout, /sibling-model@high \| completed/); + assert.match(result.stdout, /sibling-model@high \| done/); }); test("--prune-dead dry run lists only workspace dirs whose every job cwd is gone", (t) => { @@ -883,8 +770,8 @@ test("--prune-dead dry run lists only workspace dirs whose every job cwd is gone const goneCodexRoot = path.join(dir, "gone-codex-root"); const goneGrokCwd = path.join(dir, "gone-grok-cwd"); - writeCodexJob(codexState, goneCodexRoot, "dead-codex", { status: "completed", jobClass: "task", createdAt: "2026-07-01T00:00:00.000Z" }); - writeCodexJob(codexState, dir, "live-codex", { status: "completed", jobClass: "task", createdAt: "2026-07-01T00:00:00.000Z" }); + writeCodexJob(codexState, goneCodexRoot, "dead-codex", { status: "done", jobClass: "task", createdAt: "2026-07-01T00:00:00.000Z" }); + writeCodexJob(codexState, dir, "live-codex", { status: "done", jobClass: "task", createdAt: "2026-07-01T00:00:00.000Z" }); writeGrokJob(grokDataDir, goneGrokCwd, "dead-grok-1", { status: "done", mode: "consult", createdAt: "2026-07-01T00:00:00.000Z" }); writeGrokJob(grokDataDir, goneGrokCwd, "dead-grok-2", { status: "error", mode: "consult", createdAt: "2026-07-01T00:00:00.000Z" }); @@ -917,11 +804,11 @@ test("--prune-dead --yes removes only the all-dead workspace dirs and spares liv const goneGrokCwd = path.join(dir, "gone-grok-cwd-2"); writeCodexJob(codexState, path.join(dir, "gone-codex-root-2"), "dead-codex", { - status: "failed", + status: "error", jobClass: "task", createdAt: "2026-07-01T00:00:00.000Z" }); - writeCodexJob(codexState, dir, "live-codex", { status: "completed", jobClass: "task", createdAt: "2026-07-01T00:00:00.000Z" }); + writeCodexJob(codexState, dir, "live-codex", { status: "done", jobClass: "task", createdAt: "2026-07-01T00:00:00.000Z" }); writeGrokJob(grokDataDir, goneGrokCwd, "dead-grok", { status: "done", mode: "consult", createdAt: "2026-07-01T00:00:00.000Z" }); writeGrokJob(grokDataDir, dir, "live-cwd-grok", { status: "done", mode: "consult", createdAt: "2026-07-01T00:00:00.000Z" }); @@ -986,7 +873,7 @@ test("prune-dead treats any unreadable job record as unsafe for the whole worksp fs.mkdirSync(jobsDir, { recursive: true }); fs.writeFileSync( path.join(jobsDir, "dead.json"), - JSON.stringify({ id: "dead", workspaceRoot: path.join(dir, "gone"), status: "completed" }) + JSON.stringify({ id: "dead", workspaceRoot: path.join(dir, "gone"), status: "done" }) ); fs.mkdirSync(path.join(jobsDir, "unreadable.json")); @@ -999,8 +886,8 @@ test("prune-dead treats any unreadable job record as unsafe for the whole worksp test("prune-dead requires every terminal record to carry an absolute missing cwd", (t) => { const dir = sandbox(t); const codexState = path.join(dir, "codex-state"); - writeCodexJob(codexState, undefined, "missing-cwd", { status: "completed" }); - writeCodexJob(codexState, "", "empty-cwd", { status: "failed" }); + writeCodexJob(codexState, undefined, "missing-cwd", { status: "done" }); + writeCodexJob(codexState, "", "empty-cwd", { status: "error" }); writeCodexJob(codexState, "relative/workspace", "relative-cwd", { status: "cancelled" }); const env = { FUSION_CODEX_STATE: codexState, GROK_COMPANION_DATA: path.join(dir, "missing-grok") }; @@ -1012,8 +899,8 @@ test("prune-dead requires every terminal record to carry an absolute missing cwd test("prune-dead revalidates directory mtime, file membership, and running state immediately before removal", (t) => { const dir = sandbox(t); const codexState = path.join(dir, "codex-state"); - writeCodexJob(codexState, path.join(dir, "gone-a"), "dead-a", { status: "completed" }); - writeCodexJob(codexState, path.join(dir, "gone-b"), "dead-b", { status: "failed" }); + writeCodexJob(codexState, path.join(dir, "gone-a"), "dead-a", { status: "done" }); + writeCodexJob(codexState, path.join(dir, "gone-b"), "dead-b", { status: "error" }); const env = { FUSION_CODEX_STATE: codexState, GROK_COMPANION_DATA: path.join(dir, "missing-grok") }; const candidates = findDeadWorkspaces(env).codex; assert.strictEqual(candidates.length, 2); @@ -1104,23 +991,23 @@ test("codex byModel includes effort from request fields first then sidecar in JS const fusionData = path.join(dir, "fusion-data"); writeCodexJob(stateRoot, dir, "from-request", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-02T06:00:00.000Z", request: { model: "request-model", effort: "high" } }); writeCodexJob(stateRoot, dir, "from-sidecar", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-02T06:01:00.000Z" }); writeCodexJob(stateRoot, dir, "unknown-job", { - status: "failed", + status: "error", jobClass: "task", createdAt: "2026-07-02T06:02:00.000Z" }); writeCodexJob(stateRoot, dir, "request-over-sidecar", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-02T06:03:00.000Z", request: { model: "wins-from-request" } @@ -1166,10 +1053,10 @@ test("codex stats dedupe mirrored job ids and prefer the terminal workspace copy request: { model: "stale-model", effort: "low" } }); writeCodexJob(stateRoot, worktreeRoot, "mirrored-job", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-02T06:00:00.000Z", - completedAt: "2026-07-02T06:01:00.000Z", + finishedAt: "2026-07-02T06:01:00.000Z", request: { model: "terminal-model", effort: "high" } }); @@ -1177,7 +1064,7 @@ test("codex stats dedupe mirrored job ids and prefer the terminal workspace copy assert.strictEqual(result.status, 0, result.stderr); const codex = JSON.parse(result.stdout).codex; assert.strictEqual(codex.totalJobs, 1); - assert.deepStrictEqual(codex.byStatus, { completed: 1 }); + assert.deepStrictEqual(codex.byStatus, { done: 1 }); assert.deepStrictEqual(codex.byModel, { "terminal-model@high": 1 }); }); @@ -1190,7 +1077,7 @@ test("codex workspace scope resolves the git root and includes descendant worktr fs.mkdirSync(nested, { recursive: true }); fs.mkdirSync(worktree, { recursive: true }); assert.strictEqual(spawnSync("git", ["init", "--quiet", repo]).status, 0); - writeCodexJob(stateRoot, worktree, "worktree-job", { status: "completed", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z" }); + writeCodexJob(stateRoot, worktree, "worktree-job", { status: "done", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z" }); const result = run({ cwd: nested, codexState: stateRoot }, ["--json"]); assert.strictEqual(result.status, 0, result.stderr); @@ -1207,7 +1094,7 @@ test("terminal ledgers supplement cleaned jobs and safely dedupe live state", (t { schemaVersion: 1, jobId: "cleaned-job", - transportStatus: "completed", + transportStatus: "done", workspaceRoot: dir, sessionId: "session-ledger", kind: "review", @@ -1224,14 +1111,14 @@ test("terminal ledgers supplement cleaned jobs and safely dedupe live state", (t const recovered = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: dir }); assert.strictEqual(recovered.totalJobs, 1); - assert.deepStrictEqual(recovered.byTransportStatus, { completed: 1 }); + assert.deepStrictEqual(recovered.byTransportStatus, { done: 1 }); assert.deepStrictEqual(recovered.byAcceptance, { unverified: 1 }); assert.deepStrictEqual(recovered.byModel, { "ledger-model@high": 1 }); assert.strictEqual(recovered.evidence.recoveredTerminalJobs, 1); assert.deepStrictEqual(recovered.tokenUsage.totals, { inputTokens: 100, cachedInputTokens: 40, outputTokens: 20, reasoningOutputTokens: 5, totalTokens: 120 }); writeCodexJob(stateRoot, dir, "cleaned-job", { - status: "completed", + status: "done", jobClass: "review", createdAt: "2026-07-14T00:00:00.000Z", request: { model: "state-model", effort: "xhigh" } @@ -1247,7 +1134,7 @@ test("terminal ledger repository identity survives removal of a sibling worktree const { main, sibling } = createSiblingWorktree(dir); const stateRoot = path.join(dir, "state"); writeCodexJob(stateRoot, sibling, "removed-sibling-job", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z" }); @@ -1255,7 +1142,7 @@ test("terminal ledger repository identity survives removal of a sibling worktree { schemaVersion: 1, jobId: "removed-sibling-job", - transportStatus: "completed", + transportStatus: "done", workspaceRoot: sibling, kind: "task", createdAt: "2026-07-14T00:00:00.000Z" @@ -1266,12 +1153,12 @@ test("terminal ledger repository identity survives removal of a sibling worktree const stats = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: main }); assert.strictEqual(stats.totalJobs, 1); - assert.deepStrictEqual(stats.byTransportStatus, { completed: 1 }); + assert.deepStrictEqual(stats.byTransportStatus, { done: 1 }); assert.strictEqual(stats.evidence.recoveredTerminalJobs, 1); const all = codexStats({ all: true, env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: main }); assert.strictEqual(all.totalJobs, 1); - assert.deepStrictEqual(all.byTransportStatus, { completed: 1 }); + assert.deepStrictEqual(all.byTransportStatus, { done: 1 }); assert.deepStrictEqual(all.evidence.bySource, { state: 1 }); }); @@ -1308,7 +1195,7 @@ test("authoritative terminal ledger repository identity rejects a conflicting pa { schemaVersion: 1, jobId: "nested-repo-job", - transportStatus: "completed", + transportStatus: "done", workspaceRoot: nested, repositoryKey: fusionRepositoryKey(nested), kind: "task" @@ -1329,7 +1216,7 @@ test("deleted worktree evidence does not merge with an unrelated repository that { schemaVersion: 1, jobId: "reused-path-job", - transportStatus: "completed", + transportStatus: "done", workspaceRoot: sibling, kind: "task" } @@ -1338,38 +1225,22 @@ test("deleted worktree evidence does not merge with an unrelated repository that assert.strictEqual(removed.status, 0, removed.stderr); fs.mkdirSync(sibling, { recursive: true }); assert.strictEqual(spawnSync("git", ["init", "-q"], { cwd: sibling }).status, 0); - writeCodexJob(stateRoot, sibling, "reused-path-job", { status: "failed", jobClass: "task" }); + writeCodexJob(stateRoot, sibling, "reused-path-job", { status: "error", jobClass: "task" }); const env = { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }; const all = codexStats({ all: true, env, cwd: main }); assert.strictEqual(all.totalJobs, 2); - assert.deepStrictEqual(all.byTransportStatus, { failed: 1, completed: 1 }); + assert.deepStrictEqual(all.byTransportStatus, { error: 1, done: 1 }); const original = codexStats({ env, cwd: main }); assert.strictEqual(original.totalJobs, 1); - assert.deepStrictEqual(original.byTransportStatus, { completed: 1 }); + assert.deepStrictEqual(original.byTransportStatus, { done: 1 }); const replacement = codexStats({ env, cwd: sibling }); assert.strictEqual(replacement.totalJobs, 1); - assert.deepStrictEqual(replacement.byTransportStatus, { failed: 1 }); -}); - -test("legacy string announcement ledgers remain a scoped synthetic lower bound", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "state"); - fs.mkdirSync(stateRoot, { recursive: true }); - const key = createHash("sha256").update(dir).digest("hex").slice(0, 16); - fs.writeFileSync(path.join(stateRoot, `codex-jobs-monitor-announced.legacy.${key}.json`), `${JSON.stringify(["legacy-job:failed"])}\n`); - - const stats = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: dir }); - assert.strictEqual(stats.totalJobs, 1); - assert.deepStrictEqual(stats.byTransportStatus, { failed: 1 }); - assert.deepStrictEqual(stats.byModel, { unknown: 1 }); - assert.deepStrictEqual(stats.byEffort, { unavailable: 1 }); - assert.strictEqual(stats.tokenUsage.availability, "unavailable"); - assert.strictEqual(stats.evidence.recoveredLegacyTerminalJobs, 1); + assert.deepStrictEqual(replacement.byTransportStatus, { error: 1 }); }); -test("codex dedupe keeps identical ids from unrelated workspaces and merges legacy evidence with descendants", (t) => { +test("codex dedupe keeps identical ids from unrelated workspaces", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "state"); const firstRoot = path.join(dir, "first"); @@ -1377,54 +1248,34 @@ test("codex dedupe keeps identical ids from unrelated workspaces and merges lega const secondRoot = path.join(dir, "second"); fs.mkdirSync(firstWorktree, { recursive: true }); fs.mkdirSync(secondRoot, { recursive: true }); - writeCodexJob(stateRoot, firstWorktree, "shared-id", { status: "completed", jobClass: "task" }); - writeCodexJob(stateRoot, secondRoot, "shared-id", { status: "failed", jobClass: "task" }); - const firstKey = createHash("sha256").update(firstRoot).digest("hex").slice(0, 16); - fs.writeFileSync(path.join(stateRoot, `codex-jobs-monitor-announced.legacy.${firstKey}.json`), `${JSON.stringify(["shared-id:completed"])}\n`); - + writeCodexJob(stateRoot, firstWorktree, "shared-id", { status: "done", jobClass: "task" }); + writeCodexJob(stateRoot, secondRoot, "shared-id", { status: "error", jobClass: "task" }); const all = codexStats({ all: true, env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: dir }); assert.strictEqual(all.totalJobs, 2); - assert.deepStrictEqual(all.byTransportStatus, { completed: 1, failed: 1 }); + assert.deepStrictEqual(all.byTransportStatus, { done: 1, error: 1 }); const first = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: firstRoot }); assert.strictEqual(first.totalJobs, 1); assert.deepStrictEqual(first.evidence.bySource, { state: 1 }); }); -test("semantic acceptance is explicit and excludes non-terminal transport jobs", (t) => { +test("semantic acceptance reads the job record and excludes non-terminal transport jobs", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "state"); - const fusionData = path.join(dir, "fusion"); - writeCodexJob(stateRoot, dir, "completed-job", { status: "completed", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z" }); + const completedJob = writeCodexJob(stateRoot, dir, "completed-job", { status: "done", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z" }); writeCodexJob(stateRoot, dir, "running-job", { status: "running", jobClass: "task", createdAt: "2026-07-14T00:01:00.000Z" }); - const before = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }, cwd: dir }); + const before = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: dir }); assert.deepStrictEqual(before.byAcceptance, { unverified: 1 }); assert.strictEqual(before.pendingTransportJobs, 1); assert.strictEqual(before.acceptanceScope, "terminal transport jobs only"); - recordCodexAcceptance({ - jobId: "completed-job", - acceptance: "accepted", - workspaceRoot: dir, - env: { FUSION_DATA_DIR: fusionData }, - sessionId: "session-one", - source: "collector", - recordedAt: "2026-07-14T00:02:00.000Z" - }); - const after = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }, cwd: dir }); + fs.writeFileSync(completedJob, JSON.stringify({ id: "completed-job", workspaceRoot: dir, status: "done", jobClass: "task", acceptance: "accepted", semanticAcceptance: "rejected" })); + const after = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: dir }); assert.deepStrictEqual(after.byAcceptance, { accepted: 1 }); - recordCodexAcceptance({ - jobId: "completed-job", - acceptance: "rejected", - workspaceRoot: dir, - env: { FUSION_DATA_DIR: fusionData }, - sessionId: "session-one", - source: "collector", - recordedAt: "2026-07-14T00:03:00.000Z" - }); - const rejected = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }, cwd: dir }); + fs.writeFileSync(completedJob, JSON.stringify({ id: "completed-job", workspaceRoot: dir, status: "done", jobClass: "task", acceptance: "rejected" })); + const rejected = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: path.join(dir, "fusion") }, cwd: dir }); assert.deepStrictEqual(rejected.byAcceptance, { rejected: 1 }); }); @@ -1438,17 +1289,14 @@ test("Codex acceptance and token observations stay scoped when unrelated reposit assert.strictEqual(spawnSync("git", ["init", "-q"], { cwd: secondRoot }).status, 0); const stateRoot = path.join(dir, "state"); const fusionData = path.join(dir, "fusion"); - writeCodexJob(stateRoot, firstRoot, "shared-observation-id", { status: "completed", jobClass: "task" }); - writeCodexJob(stateRoot, secondRoot, "shared-observation-id", { status: "completed", jobClass: "task" }); + writeCodexJob(stateRoot, firstRoot, "shared-observation-id", { status: "done", jobClass: "task", acceptance: "accepted" }); + writeCodexJob(stateRoot, secondRoot, "shared-observation-id", { status: "done", jobClass: "task", acceptance: "rejected" }); writeTokenUsage(fusionData, firstRoot, [ { jobId: "shared-observation-id", tokenUsage: { inputTokens: 10, cachedInputTokens: 2, outputTokens: 3, reasoningOutputTokens: 1, totalTokens: 13 }, observedAt: "2026-07-14T00:01:00.000Z" } ]); writeTokenUsage(fusionData, secondRoot, [ { jobId: "shared-observation-id", tokenUsage: { inputTokens: 99, cachedInputTokens: 9, outputTokens: 9, reasoningOutputTokens: 4, totalTokens: 108 }, observedAt: "2026-07-14T00:02:00.000Z" } ]); - recordCodexAcceptance({ jobId: "shared-observation-id", acceptance: "accepted", workspaceRoot: firstRoot, env: { FUSION_DATA_DIR: fusionData }, sessionId: "session-first", recordedAt: "2026-07-14T00:01:00.000Z" }); - recordCodexAcceptance({ jobId: "shared-observation-id", acceptance: "rejected", workspaceRoot: secondRoot, env: { FUSION_DATA_DIR: fusionData }, sessionId: "session-second", recordedAt: "2026-07-14T00:02:00.000Z" }); - const first = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }, cwd: firstRoot }); assert.deepStrictEqual(first.byAcceptance, { accepted: 1 }); assert.strictEqual(first.tokenUsage.totals.totalTokens, 13); @@ -1457,180 +1305,6 @@ test("Codex acceptance and token observations stay scoped when unrelated reposit assert.strictEqual(second.tokenUsage.totals.totalTokens, 108); }); -test("acceptance CLI validates fields, redacts short reasons, and recovers stale locks", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "state"); - const fusionData = path.join(dir, "fusion"); - const jobId = "0123456789abcdef0123456789abcdef"; - const companion = writeCodexAcceptanceCompanion(dir); - fs.mkdirSync(stateRoot, { recursive: true }); - const sidecar = acceptanceSidecarPath(dir, { FUSION_DATA_DIR: fusionData }); - fs.mkdirSync(path.dirname(sidecar), { recursive: true }); - fs.writeFileSync(`${sidecar}.lock`, "stale\n"); - const stale = new Date(Date.now() - 60000); - fs.utimesSync(`${sidecar}.lock`, stale, stale); - const secret = `sk-${"a".repeat(20)}`; - - const result = run( - { cwd: dir, codexState: stateRoot }, - ["--record-acceptance", jobId, "rejected", "--reason", `verification failed ${secret}`, "--source", "main-loop", "--json"], - { FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion, CLAUDE_CODE_SESSION_ID: "session-safe" } - ); - assert.strictEqual(result.status, 0, result.stderr); - const observation = JSON.parse(result.stdout); - assert.strictEqual(observation.acceptance, "rejected"); - assert.strictEqual(observation.reason, "verification failed [redacted]"); - assert.strictEqual(fs.existsSync(`${sidecar}.lock`), false); - assert.strictEqual(fs.readFileSync(sidecar, "utf8").includes(secret), false); - assert.strictEqual(fs.statSync(path.dirname(sidecar)).mode & 0o777, 0o700); - assert.strictEqual(fs.statSync(sidecar).mode & 0o777, 0o600); - - assert.throws( - () => recordCodexAcceptance({ jobId: "bad job id", acceptance: "accepted", workspaceRoot: dir, env: { FUSION_DATA_DIR: fusionData } }), - /job id is invalid/ - ); - assert.throws( - () => recordCodexAcceptance({ jobId, acceptance: "accepted", workspaceRoot: dir, env: { FUSION_DATA_DIR: fusionData }, reason: "line one\nline two" }), - /single non-sensitive line/ - ); -}); - -test("--record-acceptance updates the Codex job record before appending the ledger", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "state"); - const fusionData = path.join(dir, "fusion"); - const jobId = "a".repeat(32); - const companion = writeCodexAcceptanceCompanion(dir); - const argsFile = path.join(dir, "companion-args.json"); - writeCodexJob(stateRoot, dir, jobId, { status: "done", jobClass: "task" }); - - const result = run( - { cwd: dir, codexState: stateRoot }, - ["--record-acceptance", jobId, "accepted", "--reason", "verification passed", "--source", "main-loop"], - { FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } - ); - - assert.strictEqual(result.status, 0, result.stderr); - assert.strictEqual(result.stdout, `Recorded accepted for Codex job ${jobId}.\n`); - assert.strictEqual(result.stderr, ""); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "main-loop", "--reason", "verification passed"]); - assert.strictEqual(fs.readFileSync(acceptanceSidecarPath(dir, { FUSION_DATA_DIR: fusionData }), "utf8").trim().split("\n").length, 1); -}); - -test("--record-acceptance surfaces the Codex transport gate without appending the ledger", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "state"); - const fusionData = path.join(dir, "fusion"); - const jobId = "b".repeat(32); - const companion = writeCodexAcceptanceCompanion(dir); - writeCodexJob(stateRoot, dir, jobId, { status: "error", jobClass: "task" }); - - const result = run( - { cwd: dir, codexState: stateRoot }, - ["--record-acceptance", jobId, "accepted"], - { FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_MODE: "gate" } - ); - - assert.notStrictEqual(result.status, 0); - assert.match(result.stderr, /Accepted Codex jobs must have done transport status/); - assert.strictEqual(fs.existsSync(acceptanceSidecarPath(dir, { FUSION_DATA_DIR: fusionData })), false); -}); - -test("--record-acceptance keeps the ledger compatible with companions lacking the subcommand", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "state"); - const fusionData = path.join(dir, "fusion"); - const jobId = "c".repeat(32); - const companion = writeCodexAcceptanceCompanion(dir); - writeCodexJob(stateRoot, dir, jobId, { status: "done", jobClass: "task" }); - - const result = run( - { cwd: dir, codexState: stateRoot }, - ["--record-acceptance", jobId, "rejected", "--json"], - { FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_MODE: "absent" } - ); - - assert.strictEqual(result.status, 0, result.stderr); - assert.strictEqual(JSON.parse(result.stdout).acceptance, "rejected"); - assert.match(result.stderr, /Warning: Codex job record was not updated/); - assert.strictEqual(fs.readFileSync(acceptanceSidecarPath(dir, { FUSION_DATA_DIR: fusionData }), "utf8").trim().split("\n").length, 1); -}); - -test("--record-acceptance forwards --accept-failed-transport unchanged", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "state"); - const fusionData = path.join(dir, "fusion"); - const jobId = "d".repeat(32); - const companion = writeCodexAcceptanceCompanion(dir); - const argsFile = path.join(dir, "companion-args.json"); - writeCodexJob(stateRoot, dir, jobId, { status: "error", jobClass: "task" }); - - const result = run( - { cwd: dir, codexState: stateRoot }, - ["--record-acceptance", jobId, "accepted", "--accept-failed-transport"], - { FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } - ); - - assert.strictEqual(result.status, 0, result.stderr); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--source", "collector", "--accept-failed-transport"]); -}); - -test("--record-acceptance forwards an explicit source to the Codex companion", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "state"); - const fusionData = path.join(dir, "fusion"); - const jobId = "e".repeat(32); - const companion = writeCodexAcceptanceCompanion(dir); - const argsFile = path.join(dir, "companion-args.json"); - writeCodexJob(stateRoot, dir, jobId, { status: "done", jobClass: "task" }); - - const result = run( - { cwd: dir, codexState: stateRoot }, - ["--record-acceptance", jobId, "accepted", "--source", "main-loop"], - { FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } - ); - - 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"]); -}); - -test("--record-acceptance resolves a Grok job after a Codex lookup miss", (t) => { - const dir = sandbox(t); - const grokData = path.join(dir, "grok-data"); - const jobId = "e".repeat(32); - const companion = writeGrokAcceptanceCompanion(dir); - const argsFile = path.join(dir, "grok-companion-args.json"); - writeGrokJob(grokData, dir, jobId, { status: "done", mode: "consult" }); - - const result = run( - { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-acceptance", jobId, "accepted", "--reason", "verification passed", "--accept-failed-transport", "--json"], - { GROK_COMPANION_DATA: grokData, FUSION_GROK_COMPANION: companion, FUSION_TEST_GROK_COMPANION_ARGS: argsFile } - ); - - assert.strictEqual(result.status, 0, result.stderr); - assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", jobId, "--acceptance", "accepted", "--reason", "verification passed", "--accept-failed-transport", "--json"]); - assert.strictEqual(JSON.parse(result.stdout).engine, "grok"); - assert.strictEqual(fs.existsSync(acceptanceSidecarPath(dir, { FUSION_DATA_DIR: path.join(dir, "fusion") })), false); -}); - -test("--record-acceptance requires a Grok plugin upgrade when its companion lacks the subcommand", (t) => { - const dir = sandbox(t); - const grokData = path.join(dir, "grok-data"); - const jobId = "f".repeat(32); - const companion = writeGrokAcceptanceCompanion(dir); - writeGrokJob(grokData, dir, jobId, { status: "done", mode: "consult" }); - - const result = run( - { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-acceptance", jobId, "rejected"], - { GROK_COMPANION_DATA: grokData, FUSION_GROK_COMPANION: companion, FUSION_TEST_GROK_COMPANION_MODE: "absent" } - ); - - assert.notStrictEqual(result.status, 0); - assert.match(result.stderr, /Grok plugin does not support record-acceptance.*Upgrade the Grok plugin/); -}); - function createTerminalWorker({ taskId, env, workspaceRoot, peerEngine = null, peerJobId = null }) { createWorkerRecord({ taskId, sessionId: "session-settlement", dispatchToolUseId: `tool-${taskId}`, agentType: "fusion:fast-worker", workspaceRoot, limits: {} }, env); updateWorkerRecord(taskId, env, (record) => ({ @@ -1654,7 +1328,7 @@ test("--record settles a Fusion task and its Codex peer with direct strict argv" const result = runDirect( { cwd: dir, codexState: stateRoot }, - ["--record", `${taskId}=accepted`, "--source", "main-loop"], + ["--record", `${taskId}=accepted`], { ...env, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile } ); @@ -1664,6 +1338,35 @@ test("--record settles a Fusion task and its Codex peer with direct strict argv" assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "accepted"); }); +test("--record leaves a terminal worker unsettled when its engine companion fails", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const taskId = `fusion-${"1".repeat(24)}`; + const jobId = "2".repeat(32); + const companion = writeCodexAcceptanceCompanion(dir); + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId, env, workspaceRoot: dir, peerEngine: "codex", peerJobId: jobId }); + updateWorkerRecord(taskId, env, (record) => ({ + ...record, + collectedAt: "2026-07-22T00:00:00.000Z", + awaitingVerdict: true, + awaitingVerdictArmedAt: "2026-07-22T00:00:00.000Z" + })); + + const result = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${taskId}=accepted`], + { ...env, FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_MODE: "gate" } + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, new RegExp(`Failed to settle ${taskId}: Engine settlement failed for Codex job ${jobId}`)); + const worker = readWorkerRecord(taskId, env); + assert.strictEqual(worker.acceptance, "unverified"); + assert.strictEqual(worker.acceptanceRecordedAt, undefined); + assert.strictEqual(worker.awaitingVerdict, true); +}); + test("--record resolves a bare Codex job and settles matching worker rows", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "codex-state"); @@ -1754,6 +1457,74 @@ test("--record batches mixed verdicts", (t) => { assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptance, "rejected"); }); +test("--record queues a nonterminal worker verdict and applies it once terminal", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const taskId = `fusion-${"7".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createWorkerRecord({ taskId, sessionId: "session-queued", dispatchToolUseId: "tool-queued", agentType: "fusion:fast-worker", workspaceRoot: dir, limits: {} }, env); + + const queued = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${taskId}=accepted`], + env + ); + + assert.strictEqual(queued.status, 0, queued.stderr); + assert.strictEqual(queued.stdout, `Queued accepted for ${taskId}; applies when the record turns terminal.\n`); + assert.strictEqual(readWorkerRecord(taskId, env).pendingVerdict.acceptance, "accepted"); + updateWorkerRecord(taskId, env, (record) => applyQueuedVerdict({ ...record, transportStatus: "done" }, "2026-07-22T00:00:00.000Z")); + const settled = readWorkerRecord(taskId, env); + assert.strictEqual(settled.acceptance, "accepted"); + assert.strictEqual(settled.acceptanceRecordedAt, "2026-07-22T00:00:00.000Z"); + assert.strictEqual(settled.pendingVerdict, undefined); +}); + +test("--record continues a batch after a failed transport gate", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const firstTaskId = `fusion-${"8".repeat(24)}`; + const failedTaskId = `fusion-${"9".repeat(24)}`; + const thirdTaskId = `fusion-${"a".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId: firstTaskId, env, workspaceRoot: dir }); + createTerminalWorker({ taskId: failedTaskId, env, workspaceRoot: dir }); + createTerminalWorker({ taskId: thirdTaskId, env, workspaceRoot: dir }); + updateWorkerRecord(failedTaskId, env, (record) => ({ ...record, transportStatus: "failed" })); + + const result = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${firstTaskId}=accepted`, "--record", `${failedTaskId}=accepted`, "--record", `${thirdTaskId}=rejected`], + env + ); + + assert.notStrictEqual(result.status, 0); + assert.match(result.stderr, new RegExp(failedTaskId)); + assert.doesNotMatch(result.stderr, new RegExp(firstTaskId)); + assert.doesNotMatch(result.stderr, new RegExp(thirdTaskId)); + assert.strictEqual(readWorkerRecord(firstTaskId, env).acceptance, "accepted"); + assert.strictEqual(readWorkerRecord(failedTaskId, env).acceptance, "unverified"); + assert.strictEqual(readWorkerRecord(thirdTaskId, env).acceptance, "rejected"); +}); + +test("--record accepts a per-pair failed transport override", (t) => { + const dir = sandbox(t); + const stateDir = path.join(dir, "worker-state"); + const taskId = `fusion-${"b".repeat(24)}`; + const env = { FUSION_WORKER_STATE_DIR: stateDir }; + createTerminalWorker({ taskId, env, workspaceRoot: dir }); + updateWorkerRecord(taskId, env, (record) => ({ ...record, transportStatus: "failed" })); + + const result = runDirect( + { cwd: dir, codexState: path.join(dir, "missing") }, + ["--record", `${taskId}=accepted:accept-failed-transport`], + env + ); + + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "accepted"); +}); + test("--record rejects a reason attached to multiple pairs and requires transport outside the strict carve-out", (t) => { const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); @@ -1775,36 +1546,14 @@ test("--record rejects a reason attached to multiple pairs and requires transpor assert.strictEqual(readWorkerRecord(secondTaskId, env).acceptance, "unverified"); }); -test("legacy acceptance aliases continue to work through the raw-args transport", (t) => { - const dir = sandbox(t); - const stateRoot = path.join(dir, "codex-state"); - const stateDir = path.join(dir, "worker-state"); - const fusionData = path.join(dir, "fusion-data"); - const taskId = `fusion-${"6".repeat(24)}`; - const jobId = "7".repeat(32); - const companion = writeCodexAcceptanceCompanion(dir); - const env = { FUSION_WORKER_STATE_DIR: stateDir, FUSION_DATA_DIR: fusionData, FUSION_CODEX_COMPANION: companion }; - writeCodexJob(stateRoot, dir, jobId, { status: "done", jobClass: "task" }); - createTerminalWorker({ taskId, env, workspaceRoot: dir }); - - const jobAlias = run({ cwd: dir, codexState: stateRoot }, ["--record-acceptance", jobId, "accepted"], env); - const workerAlias = run({ cwd: dir, codexState: stateRoot }, ["--record-worker-acceptance", taskId, "rejected"], env); - - assert.strictEqual(jobAlias.status, 0, jobAlias.stderr); - assert.strictEqual(workerAlias.status, 0, workerAlias.stderr); - assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "rejected"); - assert.strictEqual(fs.readFileSync(acceptanceSidecarPath(dir, env), "utf8").trim().split("\n").length, 1); -}); - -test("Codex reports acceptance anomalies only when the ledger and transport state diverge", (t) => { +test("Codex reports acceptance anomalies only when the job record and transport state diverge", (t) => { const dir = sandbox(t); const stateRoot = path.join(dir, "state"); const fusionData = path.join(dir, "fusion"); const acceptedErrorId = "e".repeat(32); const doneWithoutAcceptanceId = "f".repeat(32); - writeCodexJob(stateRoot, dir, acceptedErrorId, { status: "error", jobClass: "task" }); + writeCodexJob(stateRoot, dir, acceptedErrorId, { status: "error", jobClass: "task", acceptance: "accepted" }); writeCodexJob(stateRoot, dir, doneWithoutAcceptanceId, { status: "done", jobClass: "task" }); - recordCodexAcceptance({ jobId: acceptedErrorId, acceptance: "accepted", workspaceRoot: dir, env: { FUSION_DATA_DIR: fusionData }, recordedAt: "2026-07-17T00:00:00.000Z" }); const stats = codexStats({ env: { FUSION_CODEX_STATE: stateRoot, FUSION_DATA_DIR: fusionData }, cwd: dir }); assert.deepStrictEqual(stats.acceptanceAnomalies, { @@ -1815,169 +1564,7 @@ test("Codex reports acceptance anomalies only when the ledger and transport stat assert.match(rendered, /Acceptance anomalies:\n- Accepted ledger entries with error transport: 1 \(eeeeeeee\)\n- Done jobs without acceptance records: 1 \(ffffffffffffffffffffffffffffffff\)/); }); -test("Codex acceptance updates only the matching session collector after its worktree disappears", (t) => { - const dir = sandbox(t); - const activeRoot = path.join(dir, "active"); - const deletedWorktree = path.join(dir, "deleted-worktree"); - const stateDir = path.join(dir, "worker-state"); - const fusionData = path.join(dir, "fusion"); - const env = { FUSION_DATA_DIR: fusionData, FUSION_WORKER_STATE_DIR: stateDir }; - const jobId = "a".repeat(32); - fs.mkdirSync(activeRoot, { recursive: true }); - fs.mkdirSync(deletedWorktree, { recursive: true }); - - for (const [taskId, sessionId] of [["fusion-collector-current", "session-current"], ["fusion-collector-other", "session-other"]]) { - createWorkerRecord({ - taskId, - sessionId, - dispatchToolUseId: `tool-${sessionId}`, - agentType: "fusion:job-collector", - workspaceRoot: deletedWorktree, - completionContract: "collector", - limits: {} - }, env); - updateWorkerRecord(taskId, env, (record) => ({ - ...record, - transportStatus: "done", - peerEngine: "codex", - peerJobId: jobId, - peerTransportStatus: "done", - peerSemanticStatus: "unverified", - finishedAt: "2026-07-16T00:00:00.000Z" - })); - } - fs.rmSync(deletedWorktree, { recursive: true, force: true }); - - const observation = recordCodexAcceptance({ - jobId, - acceptance: "rejected", - workspaceRoot: activeRoot, - env, - sessionId: "session-current", - source: "main-loop", - reason: "verification did not pass", - recordedAt: "2026-07-16T00:01:00.000Z" - }); - - const current = readWorkerRecord("fusion-collector-current", env); - const other = readWorkerRecord("fusion-collector-other", env); - assert.strictEqual(current.acceptance, observation.acceptance); - assert.strictEqual(current.acceptanceSource, observation.source); - assert.strictEqual(current.acceptanceReason, observation.reason); - assert.ok(current.acceptanceRecordedAt); - assert.strictEqual(other.acceptance, "unverified"); - assert.strictEqual(other.acceptanceRecordedAt, undefined); - const ledger = fs.readFileSync(acceptanceSidecarPath(activeRoot, env), "utf8").trim().split("\n").map((line) => JSON.parse(line)); - assert.strictEqual(ledger.length, 1); - assert.strictEqual(ledger[0].sessionId, "session-current"); - assert.strictEqual(ledger[0].jobId, jobId); - assert.strictEqual(ledger[0].acceptance, current.acceptance); -}); - -test("retrying Codex acceptance reconciles a worker write failure without duplicating the ledger", (t) => { - const dir = sandbox(t); - const stateDir = path.join(dir, "worker-state"); - const fusionData = path.join(dir, "fusion"); - const env = { FUSION_DATA_DIR: fusionData, FUSION_WORKER_STATE_DIR: stateDir }; - const taskId = "fusion-collector-reconcile"; - const jobId = "9".repeat(32); - createWorkerRecord({ - taskId, - sessionId: "session-reconcile", - dispatchToolUseId: "tool-reconcile", - agentType: "fusion:job-collector", - workspaceRoot: dir, - completionContract: "collector", - limits: {} - }, env); - updateWorkerRecord(taskId, env, (record) => ({ - ...record, - transportStatus: "done", - peerEngine: "codex", - peerJobId: jobId, - peerTransportStatus: "done", - finishedAt: "2026-07-16T00:00:00.000Z" - })); - const workerLock = `${workerRecordFile(taskId, env)}.lock`; - fs.writeFileSync(workerLock, String(process.pid), "utf8"); - const request = { - jobId, - acceptance: "accepted", - workspaceRoot: dir, - env, - sessionId: "session-reconcile", - source: "main-loop", - reason: "verification passed", - recordedAt: "2026-07-16T00:01:00.000Z" - }; - - assert.throws(() => recordCodexAcceptance(request), /worker state lock/); - fs.rmSync(workerLock, { force: true }); - assert.strictEqual(readWorkerRecord(taskId, env).acceptanceRecordedAt, undefined); - recordCodexAcceptance(request); - - const ledger = fs.readFileSync(acceptanceSidecarPath(dir, env), "utf8").trim().split("\n"); - assert.strictEqual(ledger.length, 1); - assert.strictEqual(readWorkerRecord(taskId, env).acceptance, "accepted"); - assert.ok(readWorkerRecord(taskId, env).acceptanceRecordedAt); -}); - -test("Codex acceptance without a session records the ledger and leaves collector gates closed", (t) => { - const dir = sandbox(t); - const stateDir = path.join(dir, "worker-state"); - const fusionData = path.join(dir, "fusion"); - const env = { FUSION_DATA_DIR: fusionData, FUSION_WORKER_STATE_DIR: stateDir }; - const jobId = "b".repeat(32); - createWorkerRecord({ - taskId: "fusion-collector-nosession", - sessionId: "session-existing", - dispatchToolUseId: "tool-existing", - agentType: "fusion:job-collector", - workspaceRoot: dir, - completionContract: "collector", - limits: {} - }, env); - updateWorkerRecord("fusion-collector-nosession", env, (record) => ({ ...record, transportStatus: "done", peerEngine: "codex", peerJobId: jobId })); - - const observation = recordCodexAcceptance({ jobId, acceptance: "accepted", workspaceRoot: dir, env, sessionId: null }); - - assert.strictEqual(observation.sessionId, null); - const collector = readWorkerRecord("fusion-collector-nosession", env); - assert.strictEqual(collector.acceptance, "unverified"); - assert.strictEqual(collector.acceptanceRecordedAt, undefined); - assert.strictEqual(fs.readFileSync(acceptanceSidecarPath(dir, env), "utf8").trim().split("\n").length, 1); -}); - -test("Codex acceptance CLI rejects a session override before writing the ledger", (t) => { - const dir = sandbox(t); - const stateDir = path.join(dir, "worker-state"); - const fusionData = path.join(dir, "fusion"); - const env = { FUSION_DATA_DIR: fusionData, FUSION_WORKER_STATE_DIR: stateDir, CLAUDE_CODE_SESSION_ID: "session-current" }; - const jobId = "d".repeat(32); - createWorkerRecord({ - taskId: "fusion-collector-other-session", - sessionId: "session-other", - dispatchToolUseId: "tool-other-session", - agentType: "fusion:job-collector", - workspaceRoot: dir, - completionContract: "collector", - limits: {} - }, env); - updateWorkerRecord("fusion-collector-other-session", env, (record) => ({ ...record, transportStatus: "done", peerEngine: "codex", peerJobId: jobId })); - - const result = run( - { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-acceptance", jobId, "accepted", "--session", "session-other", "--json"], - env - ); - - assert.notStrictEqual(result.status, 0); - assert.match(result.stderr, /acceptance is bound to the current Claude session/); - assert.strictEqual(readWorkerRecord("fusion-collector-other-session", env).acceptanceRecordedAt, undefined); - assert.strictEqual(fs.existsSync(acceptanceSidecarPath(dir, env)), false); -}); - -test("generic worker acceptance rejects collectors in the API and CLI", (t) => { +test("generic worker acceptance rejects Codex collectors", (t) => { const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); const env = { FUSION_WORKER_STATE_DIR: stateDir }; @@ -1996,13 +1583,6 @@ test("generic worker acceptance rejects collectors in the API and CLI", (t) => { () => recordWorkerAcceptance({ taskId: "fusion-collector-guard", acceptance: "accepted", env }), /must be recorded through the Codex acceptance ledger/ ); - const result = run( - { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-worker-acceptance", "fusion-collector-guard", "accepted", "--json"], - env - ); - assert.notStrictEqual(result.status, 0); - assert.match(result.stderr, /must be recorded through the Codex acceptance ledger/); const collector = readWorkerRecord("fusion-collector-guard", env); assert.strictEqual(collector.acceptance, "unverified"); assert.strictEqual(collector.acceptanceRecordedAt, undefined); @@ -2040,7 +1620,7 @@ test("worker acceptance requires --accept-failed-transport only for accepted fai const dir = sandbox(t); const stateDir = path.join(dir, "worker-state"); const env = { FUSION_WORKER_STATE_DIR: stateDir }; - const taskId = "fusion-reaped-acceptance"; + const taskId = `fusion-${"e".repeat(24)}`; createWorkerRecord({ taskId, sessionId: "session-reaped", @@ -2060,7 +1640,7 @@ test("worker acceptance requires --accept-failed-transport only for accepted fai const blocked = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-worker-acceptance", taskId, "accepted", "--json"], + ["--record", `${taskId}=accepted`, "--json"], env ); assert.notStrictEqual(blocked.status, 0); @@ -2069,7 +1649,7 @@ test("worker acceptance requires --accept-failed-transport only for accepted fai const rejected = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-worker-acceptance", taskId, "rejected", "--json"], + ["--record", `${taskId}=rejected`, "--json"], env ); assert.strictEqual(rejected.status, 0, rejected.stderr); @@ -2077,7 +1657,7 @@ test("worker acceptance requires --accept-failed-transport only for accepted fai const unverified = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-worker-acceptance", taskId, "unverified", "--json"], + ["--record", `${taskId}=unverified`, "--json"], env ); assert.strictEqual(unverified.status, 0, unverified.stderr); @@ -2085,7 +1665,7 @@ test("worker acceptance requires --accept-failed-transport only for accepted fai const accepted = run( { cwd: dir, codexState: path.join(dir, "missing") }, - ["--record-worker-acceptance", taskId, "accepted", "--accept-failed-transport", "--json"], + ["--record", `${taskId}=accepted`, "--accept-failed-transport", "--json"], env ); assert.strictEqual(accepted.status, 0, accepted.stderr); @@ -2160,7 +1740,7 @@ test("exact rollout model observations override request and argv values", (t) => const stateRoot = path.join(dir, "state"); const fusionData = path.join(dir, "fusion"); writeCodexJob(stateRoot, dir, "model-conflict", { - status: "completed", + status: "done", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z", request: { model: "requested-model", effort: "low" } @@ -2179,8 +1759,8 @@ test("token totals aggregate exact sidecars and mark missing jobs unavailable", const dir = sandbox(t); const stateRoot = path.join(dir, "state"); const fusionData = path.join(dir, "fusion"); - writeCodexJob(stateRoot, dir, "with-usage", { status: "completed", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z" }); - writeCodexJob(stateRoot, dir, "without-usage", { status: "failed", jobClass: "task", createdAt: "2026-07-14T00:01:00.000Z" }); + writeCodexJob(stateRoot, dir, "with-usage", { status: "done", jobClass: "task", createdAt: "2026-07-14T00:00:00.000Z" }); + writeCodexJob(stateRoot, dir, "without-usage", { status: "error", jobClass: "task", createdAt: "2026-07-14T00:01:00.000Z" }); writeTokenUsage(fusionData, dir, [ { jobId: "with-usage", @@ -2213,7 +1793,7 @@ test("Codex token totals exclude incomplete usage from records and sidecars", (t tokenUsageAvailability: "partial", tokenUsage: { inputTokens: 1_000, cachedInputTokens: 300, outputTokens: 200, reasoningOutputTokens: 100, totalTokens: 1_200 } }); - writeCodexJob(stateRoot, dir, "partial-sidecar", { status: "failed", jobClass: "task" }); + writeCodexJob(stateRoot, dir, "partial-sidecar", { status: "error", jobClass: "task" }); writeCodexJob(stateRoot, dir, "exact-sidecar", { status: "error", jobClass: "task", @@ -2393,14 +1973,14 @@ test("Claude worker stats expose lifecycle, exact usage, acceptance, and the uni const stateDir = path.join(dir, "worker-state"); const env = { FUSION_WORKER_STATE_DIR: stateDir }; createWorkerRecord({ - taskId: "fusion-12345678", + taskId: "fusion-1234567890abcdef12345678", sessionId: "session-workers", dispatchToolUseId: "tool-1", agentType: "fusion:fast-worker", workspaceRoot: dir, limits: {} }, env); - updateWorkerRecord("fusion-12345678", env, (record) => ({ + updateWorkerRecord("fusion-1234567890abcdef12345678", env, (record) => ({ ...record, agentId: "agent-1", backgroundTaskId: "agent-1", @@ -2427,7 +2007,7 @@ test("Claude worker stats expose lifecycle, exact usage, acceptance, and the uni assert.deepStrictEqual(stats.byAcceptance, { unverified: 1 }); assert.strictEqual(stats.usage.totalTokens, 39); assert.deepStrictEqual(stats.identities[0], { - taskId: "fusion-12345678", + taskId: "fusion-1234567890abcdef12345678", sessionId: "session-workers", agentId: "agent-1", backgroundTaskId: "agent-1", @@ -2437,11 +2017,11 @@ test("Claude worker stats expose lifecycle, exact usage, acceptance, and the uni }); const secret = `sk-${"q".repeat(24)}`; - const accepted = run({ cwd: dir, codexState: path.join(dir, "missing") }, ["--record-worker-acceptance", "fusion-12345678", "accepted", "--reason", `verified ${secret}`, "--json"], env); + const accepted = run({ cwd: dir, codexState: path.join(dir, "missing") }, ["--record", "fusion-1234567890abcdef12345678=accepted", "--reason", `verified ${secret}`, "--json"], env); assert.strictEqual(accepted.status, 0, accepted.stderr); - assert.strictEqual(JSON.parse(accepted.stdout).acceptanceReason, "verified [redacted]"); + assert.strictEqual(readWorkerRecord("fusion-1234567890abcdef12345678", env).acceptanceReason, "verified [redacted]"); assert.deepStrictEqual(claudeWorkerStats({ all: true, env, cwd: dir }).byAcceptance, { accepted: 1 }); - const jobFile = path.join(stateDir, "jobs", "fusion-12345678.json"); + const jobFile = path.join(stateDir, "jobs", "fusion-1234567890abcdef12345678.json"); assert.strictEqual(fs.statSync(stateDir).mode & 0o777, 0o700); assert.strictEqual(fs.statSync(path.dirname(jobFile)).mode & 0o777, 0o700); assert.strictEqual(fs.statSync(jobFile).mode & 0o777, 0o600); @@ -2486,7 +2066,7 @@ test("--unsettled lists terminal unverified jobs and preserves Codex acceptance finishedAt: "2026-07-21T00:03:00.000Z" }); writeCodexJob(stateRoot, dir, codexNoProvenanceId, { - status: "completed", + status: "done", jobClass: "review", acceptance: "unverified", finishedAt: "2026-07-21T00:00:00.000Z" diff --git a/tests/fusion-worker-state.test.mjs b/tests/fusion-worker-state.test.mjs new file mode 100644 index 0000000..d809a15 --- /dev/null +++ b/tests/fusion-worker-state.test.mjs @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { WORKER_COLLECTION_METHODS, applyQueuedVerdict, createWorkerRecord, isPendingSettlement, isSettledWorker, markWorkerCollected, recordWorkerAcceptance, updateWorkerRecord } from "../plugins/fusion/scripts/lib/worker-state.mjs"; + +function sandbox(t) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "fusion-worker-state-")); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function unverifiedRecord(overrides = {}) { + return { + taskId: "fusion-state-seam", + completionContract: "analysis", + transportStatus: "done", + collectedAt: "2026-07-22T00:00:00.000Z", + acceptance: "unverified", + acceptanceRecordedAt: null, + awaitingVerdict: true, + awaitingVerdictArmedAt: "2026-07-22T00:00:00.000Z", + ...overrides + }; +} + +test("settlement seam identifies pending and settled worker records", () => { + const pending = unverifiedRecord(); + const settled = unverifiedRecord({ acceptance: "accepted", acceptanceRecordedAt: "2026-07-22T00:01:00.000Z", awaitingVerdict: false, awaitingVerdictArmedAt: null }); + + assert.strictEqual(isPendingSettlement(pending), true); + assert.strictEqual(isSettledWorker(pending), false); + assert.strictEqual(isPendingSettlement(settled), false); + assert.strictEqual(isSettledWorker(settled), true); +}); + +test("markWorkerCollected preserves an already settled acceptance", () => { + const settled = markWorkerCollected( + unverifiedRecord({ collectedAt: null, acceptance: "rejected", acceptanceRecordedAt: "2026-07-22T00:01:00.000Z", acceptanceSource: "main-loop", awaitingVerdict: false, awaitingVerdictArmedAt: null }), + WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, + "2026-07-22T00:02:00.000Z" + ); + + assert.strictEqual(settled.acceptance, "rejected"); + assert.strictEqual(settled.acceptanceRecordedAt, "2026-07-22T00:01:00.000Z"); + assert.strictEqual(settled.awaitingVerdict, false); + assert.strictEqual(isSettledWorker(settled), true); +}); + +test("applyQueuedVerdict settles queued accepted only after a done terminal", () => { + const queued = unverifiedRecord({ + pendingVerdict: { + acceptance: "accepted", + source: "main-loop", + reason: "verified", + queuedAt: "2026-07-22T00:00:00.000Z" + } + }); + const settled = applyQueuedVerdict(queued, "2026-07-22T00:03:00.000Z"); + assert.strictEqual(settled.acceptance, "accepted"); + assert.strictEqual(settled.acceptanceRecordedAt, "2026-07-22T00:03:00.000Z"); + assert.strictEqual(settled.awaitingVerdict, false); + assert.strictEqual(settled.pendingVerdict, undefined); + + for (const transportStatus of ["cancelled", "incomplete"]) { + const blocked = applyQueuedVerdict({ ...queued, transportStatus }, "2026-07-22T00:03:00.000Z"); + assert.strictEqual(blocked.acceptance, "unverified"); + assert.strictEqual(blocked.acceptanceRecordedAt, null); + assert.strictEqual(blocked.awaitingVerdict, true); + assert.strictEqual(blocked.awaitingVerdictArmedAt, "2026-07-22T00:03:00.000Z"); + assert.strictEqual(blocked.pendingVerdict, undefined); + assert.match(blocked.pendingVerdictError, new RegExp(`transport status is ${transportStatus}`)); + assert.strictEqual(isPendingSettlement(blocked), true); + } +}); + +test("applyQueuedVerdict settles queued rejected on a failed terminal", () => { + const settled = applyQueuedVerdict(unverifiedRecord({ + transportStatus: "failed", + pendingVerdict: { + acceptance: "rejected", + source: "main-loop", + reason: "verification failed", + queuedAt: "2026-07-22T00:00:00.000Z" + } + }), "2026-07-22T00:03:00.000Z"); + + assert.strictEqual(settled.acceptance, "rejected"); + assert.strictEqual(settled.acceptanceRecordedAt, "2026-07-22T00:03:00.000Z"); + assert.strictEqual(settled.awaitingVerdict, false); + assert.strictEqual(settled.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") }; + const taskId = "fusion-contract-anchor"; + createWorkerRecord({ taskId, sessionId: "session-anchor", dispatchToolUseId: "tool-anchor", agentType: "fusion:fast-worker", workspaceRoot: directory, limits: {} }, env); + updateWorkerRecord(taskId, env, (record) => markWorkerCollected({ ...record, transportStatus: "done" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, "2026-07-22T00:00:00.000Z")); + const pending = updateWorkerRecord(taskId, env, (record) => record); + + assert.strictEqual(isPendingSettlement(pending), true); + const acceptance = recordWorkerAcceptance({ taskId, acceptance: "accepted", env }); + assert.strictEqual(acceptance.queued, false); + assert.strictEqual(isPendingSettlement(acceptance.record), false); + + const queued = unverifiedRecord({ + pendingVerdict: { + acceptance: "rejected", + source: "main-loop", + reason: null, + queuedAt: "2026-07-22T00:00:00.000Z" + } + }); + assert.strictEqual(isPendingSettlement(applyQueuedVerdict(queued, "2026-07-22T00:04:00.000Z")), false); +}); diff --git a/tests/raw-transport-surface.test.mjs b/tests/raw-transport-surface.test.mjs index 648f5ef..4d08eb6 100644 --- a/tests/raw-transport-surface.test.mjs +++ b/tests/raw-transport-surface.test.mjs @@ -91,7 +91,7 @@ test("every executable private transport surface follows its declared Write tran assert.match(content, /file is not empty/, file); if (file === "plugins/fusion/skills/stats/SKILL.md") { assert.match(content, /sole carve-out is a strict verdict settlement/, file); - assert.match(content, /repeatable `--record =` pairs, optional `--source collector\|main-loop`, and optional `--json`/, 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, /Anything else, including every `--reason` text value, still uses the raw-args transport exactly as described above\./, file); } } From aadd51d9023450d137ae21d5e765de1ce0aca912 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Wed, 22 Jul 2026 15:38:02 +0800 Subject: [PATCH 2/7] feat(fusion): settle queued verdicts and backfill peer identity in worker lifecycle --- plugins/fusion/hooks/hooks.json | 2 +- plugins/fusion/scripts/worker-lifecycle.mjs | 372 +++++++++++++------- tests/fusion-worker-lifecycle.test.mjs | 293 +++++++++++++-- 3 files changed, 518 insertions(+), 149 deletions(-) diff --git a/plugins/fusion/hooks/hooks.json b/plugins/fusion/hooks/hooks.json index da8f876..d211e56 100644 --- a/plugins/fusion/hooks/hooks.json +++ b/plugins/fusion/hooks/hooks.json @@ -133,7 +133,7 @@ ], "SubagentStop": [ { - "matcher": "^(?:fusion:)?(?:fast-worker|trivial-worker|deep-reasoner|job-collector)$", + "matcher": "^(?:(?:fusion:)?(?:fast-worker|trivial-worker|deep-reasoner|job-collector)|codex:codex-rescue|grok:grok-rescue|grok:grok-review-runner)$", "hooks": [ { "type": "command", diff --git a/plugins/fusion/scripts/worker-lifecycle.mjs b/plugins/fusion/scripts/worker-lifecycle.mjs index ec4bf15..bb54357 100644 --- a/plugins/fusion/scripts/worker-lifecycle.mjs +++ b/plugins/fusion/scripts/worker-lifecycle.mjs @@ -4,15 +4,20 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { recordEngineAcceptance } from "./lib/engine-acceptance.mjs"; import { + applyQueuedVerdict, backfillWorkerTaskOutputTelemetry, canonicalWorkerAgentType, createWorkerRecord, createWorkerTaskId, findWorkerRecord, isFusionWorkerAgent, + isPendingSettlement, + isSettledWorker, isTerminalWorkerStatus, markWorkerCollected, + readWorkerRecord, readWorkerSessionState, readWorkerRecords, refreshWorkerTranscript, @@ -56,6 +61,7 @@ const TERMINAL_RUNTIME_TASK_STATUSES = new Set(["completed", "complete", "done", const SUCCESSFUL_TASK_NOTIFICATION_STATUSES = new Set(["completed", "complete", "done"]); const CANCELLED_TASK_NOTIFICATION_STATUSES = new Set(["killed", "cancelled", "canceled", "stopped", "terminated"]); const FAILED_TASK_NOTIFICATION_STATUSES = new Set(["failed", "error", "timed_out", "timeout"]); +const ENGINE_JOB_ID_PATTERN = /^[0-9a-f]{32}$/; function readHookInput() { try { @@ -367,13 +373,18 @@ export function workerBudgetFailure(record, now = Date.now()) { } function markBudgetFailure(record, failure, env) { - return updateWorkerRecord(record.taskId, env, (current) => ({ - ...(current ?? record), - transportStatus: "cancel_requested", - failureKind: failure.failureKind, - cancelReason: failure.reason, - cancelRequestedAt: new Date().toISOString() - })); + return updateWorkerRecord(record.taskId, env, (current) => { + if (!current || isTerminalWorkerStatus(current.transportStatus)) { + return null; + } + return { + ...current, + transportStatus: "cancel_requested", + failureKind: failure.failureKind, + cancelReason: failure.reason, + cancelRequestedAt: new Date().toISOString() + }; + }); } function completedReport(record, message) { @@ -494,6 +505,9 @@ function handlePreToolUse(input, env) { return; } const refreshed = refreshRecord(record, input, env); + if (isTerminalWorkerStatus(refreshed.transportStatus)) { + return; + } const failure = workerBudgetFailure(refreshed); if (failure) { markBudgetFailure(refreshed, failure, env); @@ -501,7 +515,12 @@ function handlePreToolUse(input, env) { return; } const now = new Date().toISOString(); - updateWorkerRecord(refreshed.taskId, env, (current) => ({ ...current, lastActivityAt: now, inFlightSince: now })); + updateWorkerRecord(refreshed.taskId, env, (current) => { + if (!current || isTerminalWorkerStatus(current.transportStatus)) { + return null; + } + return { ...current, lastActivityAt: now, inFlightSince: now }; + }); } function noTaskFoundError(input, failed) { @@ -710,6 +729,38 @@ function backfillCollectedTaskOutput(record, input) { return record; } +function settleQueuedVerdict(record, now, env) { + const pendingVerdict = record.pendingVerdict; + const settled = applyQueuedVerdict(record, now); + if (!pendingVerdict || settled.pendingVerdict || settled.acceptanceRecordedAt == null) { + return settled; + } + const peerJobId = typeof settled.peerJobId === "string" && ENGINE_JOB_ID_PATTERN.test(settled.peerJobId) ? settled.peerJobId : null; + const peerEngine = settled.peerEngine === "codex" || settled.peerEngine === "grok" ? settled.peerEngine : null; + if (!peerJobId || !peerEngine) { + return settled; + } + try { + recordEngineAcceptance({ + engine: peerEngine, + jobId: peerJobId, + acceptance: settled.acceptance, + source: pendingVerdict.source, + reason: settled.acceptanceReason, + acceptFailedTransport: pendingVerdict.acceptFailedTransport === true, + workspaceRoot: typeof settled.workspaceRoot === "string" ? settled.workspaceRoot : process.cwd(), + env + }); + const { engineSettlementError: _engineSettlementError, ...withoutEngineSettlementError } = settled; + return withoutEngineSettlementError; + } catch (error) { + return { + ...settled, + engineSettlementError: error instanceof Error ? error.message : String(error) + }; + } +} + function handlePostToolUse(input, env, failed = false) { if (!input.agent_id && ["Read", "TaskOutput", "TaskStop"].includes(input.tool_name)) { const taskId = typeof input.tool_input?.task_id === "string" ? input.tool_input.task_id : null; @@ -722,17 +773,13 @@ function handlePostToolUse(input, env, failed = false) { if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { return null; } - return { - ...current, + return settleQueuedVerdict({ + ...markWorkerCollected(current, WORKER_COLLECTION_METHODS.TASK_REAPED, now), transportStatus: "failed", - acceptance: "unverified", failureKind: "task_reaped", finishedAt: now, - collectedAt: now, - collectionMethod: WORKER_COLLECTION_METHODS.TASK_REAPED, - awaitingVerdict: true, - awaitingVerdictArmedAt: now - }; + lastActivityAt: now + }, now, env); }); } return; @@ -758,7 +805,16 @@ function handlePostToolUse(input, env, failed = false) { } for (const record of matches) { updateWorkerRecord(record.taskId, env, (current) => { - const worker = current ?? record; + if (!current || current.sessionId !== input.session_id) { + return null; + } + const worker = current; + const peerIdentity = ["Read", "TaskOutput"].includes(input.tool_name) && PEER_JOB_FOOTER_AGENTS.has(worker.agentType) + ? capturedPeerIdentity(worker.agentType, collectedResultText, worker.peerEngine) + : {}; + if (terminalCollectedRecord(worker)) { + return peerIdentityBackfill(worker, peerIdentity); + } const transportStatus = input.tool_name === "TaskStop" ? "cancelled" : "done"; const collectionMethod = input.tool_name === "Read" ? WORKER_COLLECTION_METHODS.OUTPUT_FILE_READ @@ -766,16 +822,15 @@ function handlePostToolUse(input, env, failed = false) { ? WORKER_COLLECTION_METHODS.TASK_OUTPUT : WORKER_COLLECTION_METHODS.TASK_STOP; const harnessAsyncDelivery = collectedHarnessAsyncDelivery(worker, transportStatus); - const updated = { - ...markWorkerCollected({ ...worker, acceptance: "unverified" }, collectionMethod, now), + const updated = settleQueuedVerdict({ + ...markWorkerCollected(worker, collectionMethod, now), transportStatus, - acceptance: "unverified", failureKind: input.tool_name === "TaskStop" ? worker.failureKind ?? "cancelled" : harnessAsyncDelivery ? null : worker.failureKind, ...(harnessAsyncDelivery ? { deliveryMode: "harness_async" } : {}), - ...(["Read", "TaskOutput"].includes(input.tool_name) && PEER_JOB_FOOTER_AGENTS.has(worker.agentType) ? capturedPeerIdentity(worker.agentType, collectedResultText, worker.peerEngine) : {}), + ...peerIdentity, finishedAt: worker.finishedAt ?? now, lastActivityAt: now - }; + }, now, env); return ["Read", "TaskOutput"].includes(input.tool_name) ? backfillCollectedTaskOutput(updated, input) : updated; }); } @@ -826,8 +881,12 @@ function handlePostToolUse(input, env, failed = false) { const totalToolUseCount = response.totalToolUseCount ?? nested.totalToolUseCount; const now = new Date().toISOString(); updateWorkerRecord(pending.taskId, env, (current) => { - const worker = current ?? pending; - return { + if (!current || terminalCollectedRecord(current)) { + return null; + } + const worker = current; + const transportStatus = failed ? "failed" : isAsync ? "pending_async" : completed && !isTerminalWorkerStatus(worker.transportStatus) ? "done" : worker.transportStatus === "dispatching" ? "running" : worker.transportStatus; + const updated = { ...(!failed && completed ? markWorkerCollected(worker, WORKER_COLLECTION_METHODS.AGENT_RESULT, now) : worker), ...(agentId ? { agentId, backgroundTaskId: isAsync ? agentId : worker.backgroundTaskId } : {}), ...(outputFile ? { transcriptPath: outputFile } : {}), @@ -836,12 +895,13 @@ function handlePostToolUse(input, env, failed = false) { ...(directUsage ? { usageAvailability: directUsage.availability, reportedTotalTokens: directUsage.reportedTotalTokens } : {}), ...(Number.isFinite(response.totalDurationMs ?? nested.totalDurationMs) ? { durationMs: response.totalDurationMs ?? nested.totalDurationMs } : {}), ...(Number.isSafeInteger(totalToolUseCount) ? { toolCalls: totalToolUseCount, toolCallsSource: "tool-response" } : {}), - transportStatus: failed ? "failed" : isAsync ? "pending_async" : completed && !isTerminalWorkerStatus(worker.transportStatus) ? "done" : worker.transportStatus === "dispatching" ? "running" : worker.transportStatus, + transportStatus, failureKind: failed ? "launch" : worker.failureKind, finishedAt: failed || completed ? now : worker.finishedAt, runtimeAsync: isAsync, ...(isAsync ? { startedAt: worker.startedAt ?? now } : {}) }; + return isTerminalWorkerStatus(transportStatus) ? settleQueuedVerdict(updated, now, env) : updated; }); } return; @@ -856,8 +916,11 @@ function handlePostToolUse(input, env, failed = false) { const now = new Date().toISOString(); let emitWindDown = false; updateWorkerRecord(record.taskId, env, (current) => { + if (!current || isTerminalWorkerStatus(current.transportStatus)) { + return null; + } const selectedTranscript = isAttributedTranscriptPath(input.transcript_path, record) ? input.transcript_path : null; - const refreshed = selectedTranscript ? refreshWorkerTranscript(current ?? record, selectedTranscript) : current ?? record; + const refreshed = selectedTranscript ? refreshWorkerTranscript(current, selectedTranscript) : current; const progress = !failed && !READ_ONLY_TOOLS.has(input.tool_name); const windDownThreshold = Math.max(0, (refreshed.limits?.maxTurns ?? Number.POSITIVE_INFINITY) - 2); emitWindDown = !isTerminalWorkerStatus(refreshed.transportStatus) && refreshed.windDownContextSentAt == null && (refreshed.turns ?? 0) >= windDownThreshold; @@ -884,14 +947,24 @@ function handleSubagentStart(input, env) { return; } const now = new Date().toISOString(); - const record = updateWorkerRecord(pending.taskId, env, (current) => ({ - ...current, - agentId: input.agent_id, - transportStatus: "running", - startedAt: current.startedAt ?? now, - lastActivityAt: now, - lastProgressAt: now - })); + let started = false; + const record = updateWorkerRecord(pending.taskId, env, (current) => { + if (!current || isTerminalWorkerStatus(current.transportStatus)) { + return null; + } + started = true; + return { + ...current, + agentId: input.agent_id, + transportStatus: "running", + startedAt: current.startedAt ?? now, + lastActivityAt: now, + lastProgressAt: now + }; + }); + if (!started) { + return; + } const limits = record.limits; const verdictEnvelope = "Keep the final message to a compact verdict envelope that states changed file paths without diffs, the verification command with pass and fail counts, any environment findings, and the path of a file holding the full report when one is written. Write long unified diffs, full test logs, and long quoted file contents to that file instead of inlining them."; const delivery = record.completionContract === "collector" @@ -911,8 +984,13 @@ function handleSubagentStop(input, env) { return; } const refreshed = refreshRecord(record, input, env); - const failure = workerBudgetFailure(refreshed); const message = typeof input.last_assistant_message === "string" ? input.last_assistant_message : ""; + if (terminalCollectedRecord(refreshed)) { + const peerIdentity = PEER_JOB_FOOTER_AGENTS.has(refreshed.agentType) ? capturedPeerIdentity(refreshed.agentType, message, refreshed.peerEngine) : {}; + updateWorkerRecord(refreshed.taskId, env, (current) => current && terminalCollectedRecord(current) ? peerIdentityBackfill(current, peerIdentity) : null); + return; + } + const failure = workerBudgetFailure(refreshed); let finalTextFile = isFusionWorkerAgent(refreshed.agentType) && message.length > 0 ? finalTextArtifactPath(refreshed, env) : null; if (finalTextFile) { try { @@ -927,8 +1005,7 @@ function handleSubagentStop(input, env) { const collectorReport = collectorContract ? structuredCollectorReport(message) : null; const collectorIdentityMismatch = collectorContract && collectorReport && (collectorReport.peerEngine !== refreshed.expectedPeerEngine || collectorReport.peerJobId !== refreshed.expectedPeerJobId); const trustedCollectorReport = collectorIdentityMismatch ? null : collectorReport; - const legacyCollectorReport = collectorContract && complete && !collectorReport; - const collectorProtocolFailure = legacyCollectorReport || collectorIdentityMismatch; + const collectorProtocolFailure = (collectorContract && complete && !collectorReport) || collectorIdentityMismatch; if (collectorProtocolFailure && !failure && (refreshed.retryCount ?? 0) < 1 && !input.stop_hook_active) { updateWorkerRecord(refreshed.taskId, env, (current) => ({ ...current, retryCount: (current.retryCount ?? 0) + 1 })); const expected = refreshed.expectedPeerEngine && refreshed.expectedPeerJobId @@ -944,20 +1021,26 @@ function handleSubagentStop(input, env) { } const now = new Date().toISOString(); updateWorkerRecord(refreshed.taskId, env, (current) => { - const worker = current ?? refreshed; + if (!current) { + return null; + } + const worker = current; + const peerIdentity = PEER_JOB_FOOTER_AGENTS.has(worker.agentType) ? capturedPeerIdentity(worker.agentType, message, worker.peerEngine) : {}; + if (terminalCollectedRecord(worker)) { + return peerIdentityBackfill(worker, peerIdentity); + } const runtimeAsync = worker.runtimeAsync === true; const successfulCompletion = complete && !failure && trustedCollectorReport?.kind !== "outcome" && !collectorProtocolFailure; const subagentStopCollected = successfulCompletion && finalTextFile != null && !collectorContract; - return { - ...(runtimeAsync && !subagentStopCollected ? worker : markWorkerCollected({ ...worker, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.SUBAGENT_STOP, now)), + return settleQueuedVerdict({ + ...(runtimeAsync && !subagentStopCollected ? worker : markWorkerCollected(worker, WORKER_COLLECTION_METHODS.SUBAGENT_STOP, now)), transportStatus: complete && (trustedCollectorReport?.kind === "outcome" || collectorProtocolFailure) ? "incomplete" : complete ? runtimeAsync && !subagentStopCollected ? "ready_uncollected" : "done" : failure ? "failed" : "incomplete", - acceptance: "unverified", failureKind: failure?.failureKind ?? (trustedCollectorReport?.kind === "outcome" ? `collection_${trustedCollectorReport.collectionOutcome}` : collectorProtocolFailure ? "collection_protocol" : complete ? null : "delivery"), inFlightSince: undefined, ...(successfulCompletion && worker.failureKind && worker.failureKind !== "unexpected_async" ? { recoveredFailureKind: worker.failureKind } : {}), ...(complete && !failure && !collectorProtocolFailure && runtimeAsync && worker.failureKind === "unexpected_async" ? { deliveryMode: "harness_async" } : {}), ...(finalTextFile ? { outputFile: finalTextFile } : {}), - ...(PEER_JOB_FOOTER_AGENTS.has(worker.agentType) ? capturedPeerIdentity(worker.agentType, message, worker.peerEngine) : {}), + ...peerIdentity, ...(trustedCollectorReport?.peerEngine ? { peerEngine: trustedCollectorReport.peerEngine } : {}), ...(trustedCollectorReport?.peerJobId ? { peerJobId: trustedCollectorReport.peerJobId } : {}), ...(trustedCollectorReport?.peerTransportStatus ? { peerTransportStatus: trustedCollectorReport.peerTransportStatus } : {}), @@ -966,7 +1049,7 @@ function handleSubagentStop(input, env) { ...(Number.isSafeInteger(trustedCollectorReport?.elapsedSeconds) ? { collectionElapsedSeconds: trustedCollectorReport.elapsedSeconds } : {}), finishedAt: now, lastActivityAt: now - }; + }, now, env); }); } @@ -1005,6 +1088,15 @@ function terminalCollectionPending(record) { || (record.runtimeAsync === true && isTerminalWorkerStatus(record.transportStatus) && !record.collectedAt); } +function terminalCollectedRecord(record) { + return Boolean(record) && isTerminalWorkerStatus(record.transportStatus) && Boolean(record.collectedAt) && (isSettledWorker(record) || record.awaitingVerdict === true || record.acceptance === "unverified"); +} + +function peerIdentityBackfill(record, identity) { + const additions = Object.fromEntries(Object.entries(identity).filter(([key, value]) => value != null && record[key] == null)); + return Object.keys(additions).length > 0 ? { ...record, ...additions } : null; +} + function taskNotificationTextParts(value) { if (typeof value === "string") { return [value]; @@ -1253,15 +1345,14 @@ function taskNotificationTransition(record, status, now, env) { : {}; if (!finalText) { const turnLimited = refreshed.failureKind === "turn_limit" || (refreshed.turns ?? 0) >= (refreshed.limits?.maxTurns ?? Number.POSITIVE_INFINITY); - return { + return settleQueuedVerdict({ ...refreshed, transportStatus: "incomplete", - acceptance: "unverified", failureKind: turnLimited ? "turn_limit" : "missing_final_text", ...peerIdentity, finishedAt: refreshed.finishedAt ?? now, lastActivityAt: now - }; + }, now, env); } let finalTextFile = null; try { @@ -1270,39 +1361,36 @@ function taskNotificationTransition(record, status, now, env) { finalTextFile = null; } if (!finalTextFile) { - return { + return settleQueuedVerdict({ ...refreshed, transportStatus: "incomplete", - acceptance: "unverified", failureKind: "delivery", ...peerIdentity, finishedAt: refreshed.finishedAt ?? now, lastActivityAt: now - }; + }, now, env); } const harnessAsyncDelivery = collectedHarnessAsyncDelivery(refreshed, "done"); - return { - ...markWorkerCollected({ ...refreshed, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), + return settleQueuedVerdict({ + ...markWorkerCollected(refreshed, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), transportStatus: "done", - acceptance: "unverified", failureKind: harnessAsyncDelivery ? null : refreshed.failureKind, ...(harnessAsyncDelivery ? { deliveryMode: "harness_async" } : {}), outputFile: finalTextFile, ...peerIdentity, finishedAt: refreshed.finishedAt ?? now, lastActivityAt: now - }; + }, now, env); } const cancelled = CANCELLED_TASK_NOTIFICATION_STATUSES.has(status); const fallbackFailureKind = cancelled ? "cancelled" : "task_failed"; - return { - ...markWorkerCollected({ ...refreshed, acceptance: "unverified" }, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), + return settleQueuedVerdict({ + ...markWorkerCollected(refreshed, WORKER_COLLECTION_METHODS.TASK_NOTIFICATION, now), transportStatus: cancelled ? "cancelled" : "failed", - acceptance: "unverified", failureKind: refreshed.failureKind && refreshed.failureKind !== "unexpected_async" ? refreshed.failureKind : fallbackFailureKind, finishedAt: refreshed.finishedAt ?? now, lastActivityAt: now - }; + }, now, env); } function reconcileTaskNotifications(input, env) { @@ -1359,18 +1447,27 @@ function reconcileTaskNotifications(input, env) { function armInFlightRecords(records, tasks, env) { const inFlight = records.filter((record) => !terminalTransportObserved(record, runtimeTaskForRecord(record, tasks))); const armedAt = new Date().toISOString(); + const armed = []; for (const record of inFlight) { - updateWorkerRecord(record.taskId, env, (current) => ({ - ...(current ?? record), - awaitingCollection: true, - awaitingCollectionArmedAt: current?.awaitingCollectionArmedAt ?? armedAt - })); + const updated = updateWorkerRecord(record.taskId, env, (current) => { + if (!current || terminalTransportObserved(current, runtimeTaskForRecord(current, tasks))) { + return null; + } + return { + ...current, + awaitingCollection: true, + awaitingCollectionArmedAt: current.awaitingCollectionArmedAt ?? armedAt + }; + }); + if (updated && !terminalTransportObserved(updated, runtimeTaskForRecord(updated, tasks))) { + armed.push(updated); + } } - return inFlight; + return armed; } function unjudgedPeerCollections(sessionId, env) { - return readWorkerRecords(env, { strict: true }).filter((record) => record.sessionId === sessionId && isTerminalWorkerStatus(record.transportStatus) && record.completionContract === "collector" && record.peerJobId && record.peerTransportStatus && !record.acceptanceRecordedAt); + return readWorkerRecords(env, { strict: true }).filter((record) => record.sessionId === sessionId && record.completionContract === "collector" && record.peerJobId && record.peerTransportStatus && !isSettledWorker(record)); } function unreportedCollectorFailures(sessionId, env) { @@ -1405,63 +1502,84 @@ function collectorStopGate(input, env) { } if (missingFailureReports.length > 0) { const commands = missingFailureReports.map(collectorResultCommand).filter(Boolean); - const legacyCount = missingFailureReports.filter((record) => !(record.peerJobId ?? record.expectedPeerJobId)).length; const commandInstruction = commands.length > 0 ? ` Include ${commands.join(" or ")}.` : ""; - const legacyInstruction = legacyCount > 0 ? " For a legacy record without a peer job id, report its Fusion task id and state that the result remains uncollected; no result command is available." : ""; writeOutput(blockStop(`Collector failure reporting is incomplete for ${missingFailureReports.map((record) => { const engine = record.peerEngine ?? record.expectedPeerEngine; const jobId = record.peerJobId ?? record.expectedPeerJobId; - const identity = engine && jobId ? `${engine}:${jobId}` : jobId ? `job=${jobId}` : "legacy peer identity unavailable"; + const identity = engine && jobId ? `${engine}:${jobId}` : jobId ? `job=${jobId}` : "peer identity unavailable"; return `${record.taskId} (${record.failureKind}, ${identity})`; - }).join(", ")}. Report each Fusion task id and every available peer job id, and state explicitly that the peer result remains uncollected.${commandInstruction}${legacyInstruction}`)); + }).join(", ")}. Report each Fusion task id and every available peer job id, and state explicitly that the peer result remains uncollected.${commandInstruction}`)); return true; } const unjudged = unjudgedPeerCollections(input.session_id, env); if (unjudged.length > 0) { - const acceptanceInstructions = { - codex: (record) => `/fusion:stats --record-acceptance ${record.peerJobId} --source main-loop`, - grok: (record) => `/fusion:stats --record-worker-acceptance ${record.taskId} --source main-loop` - }; - const instructions = unjudged.map((record) => acceptanceInstructions[record.peerEngine]?.(record) ?? `a manual resolution because the collection for Fusion task ${record.taskId} needs manual resolution`); + const instructions = unjudged.map((record) => ["codex", "grok"].includes(record.peerEngine) ? `/fusion:stats --record ${record.taskId}=accepted|rejected|unverified` : `a manual resolution because the collection for Fusion task ${record.taskId} needs manual resolution`); writeOutput(blockStop(`Collected peer transport results still require an explicit semantic judgment before finishing: ${unjudged.map((record) => `${record.peerEngine}:${record.peerJobId} (task=${record.taskId}, transport=${record.peerTransportStatus}, semantic=${record.peerSemanticStatus ?? "unverified"})`).join(", ")}. After checking the requested completion criteria, record each judgment with ${instructions.join(" or ")}.`)); return true; } return false; } -function unverifiedCollectedWorkers(sessionId, env) { - const records = readWorkerRecords(env, { strict: true }).filter((record) => record.sessionId === sessionId); - if (records.length === 0 || records.some((record) => !isTerminalWorkerStatus(record.transportStatus) || !record.collectedAt)) { - return []; - } - return records.filter((record) => record.completionContract !== "collector" && record.awaitingVerdict === true); +function unverifiedCollectedWorkers(records) { + return records.filter(isPendingSettlement); } -function writeAcceptanceAdvisory(sessionId, env) { - const unverified = unverifiedCollectedWorkers(sessionId, env); +function writeAcceptanceAdvisory(records, env) { + const unverified = unverifiedCollectedWorkers(rereadPendingRecords(records, isPendingSettlement, env)); if (unverified.length === 0) { return; } const commands = unverified.map((record) => `/fusion:stats --record ${record.taskId}=accepted|rejected|unverified`); - writeOutput(hookOutput("Stop", `Acceptance remains unverified for ${unverified.length} collected Fusion worker${unverified.length === 1 ? "" : "s"}. Settle each row with exactly one command: ${commands.join("; ")}. pairs are = with id either a fusion task id (fusion- plus 24 lowercase hex) or an engine job id (32 lowercase hex), verdict one of accepted|rejected|unverified.`)); + const workers = unverified.map((record) => `${record.taskId} (${[record.agentType, record.description].filter((value) => typeof value === "string" && value.trim()).join(", ")})`); + writeOutput(hookOutput("Stop", `Acceptance remains unverified for ${unverified.length} collected Fusion worker${unverified.length === 1 ? "" : "s"}: ${workers.join("; ")}. Settle each row with exactly one command: ${commands.join("; ")}. pairs are = with id either a fusion task id (fusion- plus 24 lowercase hex) or an engine job id (32 lowercase hex), verdict one of accepted|rejected|unverified.`)); } function terminalCollectionInstruction(record) { const collectId = record.backgroundTaskId ?? record.agentId; + const identity = [record.agentType, record.description].filter((value) => typeof value === "string" && value.trim()).join(", "); + const worker = `${record.taskId}${identity ? ` (${identity})` : ""}`; return record.outputFile - ? `Call Read with file_path=${record.outputFile} to collect the terminal output before finishing.` - : collectId ? `Call TaskOutput with block=true for terminal owned task ${collectId} and collect the result before finishing.` : `Collect terminal owned task ${record.taskId} before finishing.`; + ? `Call Read with file_path=${record.outputFile} to collect the terminal output for Fusion task ${worker} before finishing.` + : collectId ? `Call TaskOutput with block=true for terminal owned task ${collectId} and collect Fusion task ${worker} before finishing.` : `Collect terminal owned task ${worker} before finishing.`; } function settleOnlyRecords(records) { - return records.filter((record) => record.completionContract !== "collector" && isTerminalWorkerStatus(record.transportStatus) && record.collectedAt && record.awaitingVerdict === true); + return records.filter(isPendingSettlement); +} + +function rereadPendingRecords(records, predicate, env) { + return records.map((record) => readWorkerRecord(record.taskId, env)).filter((record) => record && predicate(record)); } -function writeCombinedStopPending(terminalUncollected, settleOnly) { - const lines = terminalUncollected.map((record) => `${record.taskId}: ${terminalCollectionInstruction(record)}`) - .concat(settleOnly.map((record) => `settle-only: ${record.taskId}: /fusion:stats --record ${record.taskId}=accepted|rejected|unverified`)); +function settleOnlyInstruction(record) { + const identity = [record.agentType, record.description].filter((value) => typeof value === "string" && value.trim()).join(", "); + return `settle-only: ${record.taskId}: /fusion:stats --record ${record.taskId}=accepted|rejected|unverified${identity ? ` (${identity})` : ""}`; +} + +function writeCombinedStopPending(terminalUncollected, settleOnly, env) { + const currentTerminalUncollected = rereadPendingRecords(terminalUncollected, terminalCollectionPending, env); + const currentSettleOnly = rereadPendingRecords(settleOnly, isPendingSettlement, env); + if (currentTerminalUncollected.length + currentSettleOnly.length === 0) { + return false; + } + const lines = currentTerminalUncollected.map((record) => `${record.taskId}: ${terminalCollectionInstruction(record)}`) + .concat(currentSettleOnly.map(settleOnlyInstruction)); const message = `Fusion worker completion is pending for ${lines.length} records:\n${lines.map((line) => `* ${line}`).join("\n")}\nTransport completion remains unverified until every result and its verification evidence are reviewed. Record accepted or rejected explicitly through /fusion:stats.`; - writeOutput(terminalUncollected.length > 0 ? blockStop(message) : hookOutput("Stop", message)); + writeOutput(currentTerminalUncollected.length > 0 ? blockStop(message) : hookOutput("Stop", message)); + return true; +} + +function writeTerminalCollectionPending(records, env) { + const terminalUncollected = rereadPendingRecords(records, terminalCollectionPending, env); + if (terminalUncollected.length === 0) { + return false; + } + const instructions = terminalUncollected.map(terminalCollectionInstruction); + instructions.push( + "Transport completion remains unverified until the result and verification evidence are reviewed. Record accepted or rejected explicitly through /fusion:stats." + ); + writeOutput(blockStop(instructions.join(" "))); + return true; } function inFlightSignature(records) { @@ -1486,12 +1604,16 @@ function clearInFlightAdvisory(sessionId, env) { }); } -function shouldWriteSettleOnlyAdvisory(sessionId, signature, env) { - return readWorkerSessionState(sessionId, env)?.settleOnlyAdvisorySignature !== signature; -} - -function recordSettleOnlyAdvisory(sessionId, signature, env) { - updateWorkerSessionState(sessionId, env, (current) => ({ ...(current ?? {}), settleOnlyAdvisorySignature: signature })); +function claimSettleOnlyAdvisory(sessionId, signature, env) { + let claimed = false; + updateWorkerSessionState(sessionId, env, (current) => { + if (current?.settleOnlyAdvisorySignature === signature) { + return null; + } + claimed = true; + return { ...(current ?? {}), settleOnlyAdvisorySignature: signature }; + }); + return claimed; } function clearSettleOnlyAdvisory(sessionId, env) { @@ -1518,7 +1640,10 @@ function handleStop(input, env) { const observedTerminal = terminalTransportObserved(record, task); const failure = observedTerminal ? null : workerBudgetFailure(record); const updated = updateWorkerRecord(record.taskId, env, (current) => { - const worker = current ?? record; + if (!current || isTerminalWorkerStatus(current.transportStatus)) { + return null; + } + const worker = current; const transportStatus = failure ? "cancel_requested" : runtimeTaskIsTerminal(task) ? "ready_uncollected" : ["ready_uncollected", "ready_background", "cancel_requested"].includes(worker.transportStatus) ? worker.transportStatus : "pending_async"; const advisoryRound = !failure && transportStatus === "pending_async"; const successfulTerminal = !failure && worker.failureKind !== "unexpected_async" && runtimeTaskSucceeded(task); @@ -1539,7 +1664,12 @@ function handleStop(input, env) { const now = new Date().toISOString(); const missingRuntimeIds = cancellations.filter((record) => !record.backgroundTaskId && !record.agentId); for (const record of missingRuntimeIds) { - updateWorkerRecord(record.taskId, env, (current) => ({ ...current, transportStatus: "failed", failureKind: current.failureKind ?? "owner_lost", finishedAt: now })); + updateWorkerRecord(record.taskId, env, (current) => { + if (!current || isTerminalWorkerStatus(current.transportStatus)) { + return null; + } + return settleQueuedVerdict({ ...current, transportStatus: "failed", failureKind: current.failureKind ?? "owner_lost", finishedAt: now }, now, env); + }); } const cancelIds = cancellations.map((record) => record.backgroundTaskId ?? record.agentId).filter(Boolean); if (cancelIds.length === 0) { @@ -1560,21 +1690,26 @@ function handleStop(input, env) { if (!settleOnlySignature) { clearSettleOnlyAdvisory(input.session_id, env); } - if (terminalUncollected.length + settleOnly.length > 1) { - if (terminalUncollected.length > 0 || shouldWriteSettleOnlyAdvisory(input.session_id, settleOnlySignature, env)) { - writeCombinedStopPending(terminalUncollected, settleOnly); - if (settleOnlySignature) { - recordSettleOnlyAdvisory(input.session_id, settleOnlySignature, env); - } + if (terminalUncollected.length > 0 && settleOnly.length > 0) { + if (claimSettleOnlyAdvisory(input.session_id, settleOnlySignature, env)) { + writeCombinedStopPending(terminalUncollected, settleOnly, env); + } else { + writeTerminalCollectionPending(terminalUncollected, env); } return; } if (terminalUncollected.length > 0) { - const instructions = terminalUncollected.map(terminalCollectionInstruction); - instructions.push( - "Transport completion remains unverified until the result and verification evidence are reviewed. Record accepted or rejected explicitly through /fusion:stats." - ); - writeOutput(blockStop(instructions.join(" "))); + writeTerminalCollectionPending(terminalUncollected, env); + return; + } + if (settleOnly.length > 0) { + if (!input.stop_hook_active && claimSettleOnlyAdvisory(input.session_id, settleOnlySignature, env)) { + if (settleOnly.length === 1) { + writeAcceptanceAdvisory(settleOnly, env); + } else { + writeCombinedStopPending([], settleOnly, env); + } + } return; } if (inFlight.length > 0) { @@ -1585,21 +1720,22 @@ function handleStop(input, env) { } return; } - if (!input.stop_hook_active) { - writeAcceptanceAdvisory(input.session_id, env); - } } function handleSessionEnd(input, env) { const now = new Date().toISOString(); for (const record of activeSessionRecords(input.session_id, env)) { - updateWorkerRecord(record.taskId, env, (current) => ({ - ...current, - transportStatus: "owner_ended", - acceptance: "unverified", - failureKind: current.failureKind ?? "owner_lost", - finishedAt: now - })); + updateWorkerRecord(record.taskId, env, (current) => { + if (!current || isTerminalWorkerStatus(current.transportStatus)) { + return null; + } + return settleQueuedVerdict({ + ...current, + transportStatus: "owner_ended", + failureKind: current.failureKind ?? "owner_lost", + finishedAt: now + }, now, env); + }); } } diff --git a/tests/fusion-worker-lifecycle.test.mjs b/tests/fusion-worker-lifecycle.test.mjs index fb42430..1112595 100644 --- a/tests/fusion-worker-lifecycle.test.mjs +++ b/tests/fusion-worker-lifecycle.test.mjs @@ -5,7 +5,6 @@ import os from "node:os"; import path from "node:path"; import { test } from "node:test"; -import { recordCodexAcceptance } from "../plugins/fusion/scripts/fusion-stats.mjs"; import { createWorkerRecord, readWorkerSessionState, readWorkerRecords, recordWorkerAcceptance, updateWorkerRecord, WORKER_COLLECTION_METHODS } from "../plugins/fusion/scripts/lib/worker-state.mjs"; import { validateWorkerBrief, workerBudgetFailure, workerLimits } from "../plugins/fusion/scripts/worker-lifecycle.mjs"; @@ -36,6 +35,18 @@ function run(box, payload, extra = {}) { return spawnSync(process.execPath, [script], { input: JSON.stringify(payload), encoding: "utf8", env: envFor(box, extra) }); } +function writeCodexAcceptanceCompanion(box) { + const companion = path.join(box.root, "codex-companion.mjs"); + fs.writeFileSync(companion, [ + 'import fs from "node:fs";', + "const argv = process.argv.slice(2);", + "if (process.env.FUSION_TEST_CODEX_COMPANION_ARGS) {", + " fs.writeFileSync(process.env.FUSION_TEST_CODEX_COMPANION_ARGS, JSON.stringify(argv));", + "}" + ].join("\n")); + return companion; +} + function brief() { return "fusion-brief: v1\ncontext-mode: isolated\ngoal: implement one fix\nscope: src/a.ts\nverification: node --test\n"; } @@ -73,6 +84,19 @@ function createSettleOnlyRecord(box, taskId) { return updateWorkerRecord(worker.taskId, envFor(box), (current) => ({ ...current, transportStatus: "done", collectedAt, collectionMethod: WORKER_COLLECTION_METHODS.SUBAGENT_STOP, awaitingVerdict: true, awaitingVerdictArmedAt: collectedAt })); } +function createAcceptedCollectedRecord(box, taskId, agentId, agentType = "fusion:fast-worker") { + const worker = createWorkerRecord({ + taskId, + sessionId: "session-1", + agentType, + workspaceRoot: box.cwd + }, envFor(box)); + const collectedAt = new Date().toISOString(); + updateWorkerRecord(worker.taskId, envFor(box), (current) => ({ ...current, agentId, backgroundTaskId: agentId, transportStatus: "done", collectedAt, collectionMethod: WORKER_COLLECTION_METHODS.SUBAGENT_STOP, awaitingVerdict: true, awaitingVerdictArmedAt: collectedAt })); + recordWorkerAcceptance({ taskId: worker.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + return record(box); +} + function stop(box) { return run(box, { hook_event_name: "Stop", @@ -1083,8 +1107,7 @@ test("an armed async worker is collected by SubagentStop and remains verdict gat transcript_path: box.transcript, background_tasks: [] }); - assert.strictEqual(JSON.parse(collectedStop.stdout).decision, undefined); - assert.match(JSON.parse(collectedStop.stdout).hookSpecificOutput.additionalContext, /Acceptance remains unverified/); + assert.strictEqual(collectedStop.stdout, ""); recordWorkerAcceptance({ taskId: record(box).taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); const settledStop = run(box, { @@ -2427,27 +2450,6 @@ test("a structured Codex collection persists final text and remains gated until assert.match(JSON.parse(blocked.stdout).reason, new RegExp(peerJobId)); assert.match(JSON.parse(blocked.stdout).reason, /accepted\|rejected\|unverified/); - recordCodexAcceptance({ - jobId: peerJobId, - acceptance: "rejected", - workspaceRoot: box.cwd, - env: envFor(box), - sessionId: "session-1", - source: "main-loop", - reason: "verification did not pass" - }); - assert.strictEqual(record(box).acceptance, "rejected"); - assert.ok(record(box).acceptanceRecordedAt); - - const allowed = run(box, { - hook_event_name: "Stop", - session_id: "session-1", - cwd: box.cwd, - transcript_path: box.transcript, - last_assistant_message: "reported rejected delivery", - background_tasks: [] - }); - assert.strictEqual(allowed.stdout, ""); }); test("a structured Grok collection remains gated until the main loop records a semantic judgment", (t) => { @@ -2484,7 +2486,7 @@ test("a structured Grok collection remains gated until the main loop records a s }); assert.strictEqual(JSON.parse(blocked.stdout).decision, "block"); assert.match(JSON.parse(blocked.stdout).reason, new RegExp(collector.taskId)); - assert.match(JSON.parse(blocked.stdout).reason, /record-worker-acceptance/); + assert.match(JSON.parse(blocked.stdout).reason, new RegExp(`/fusion:stats --record ${collector.taskId}=accepted\\|rejected\\|unverified`)); recordWorkerAcceptance({ taskId: collector.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); const allowed = run(box, { @@ -2524,7 +2526,7 @@ test("an unrecognized collector engine requires manual resolution before Stop", assert.strictEqual(JSON.parse(blocked.stdout).decision, "block"); assert.match(reason, new RegExp(collector.taskId)); assert.match(reason, /collection for Fusion task .* needs manual resolution/); - assert.doesNotMatch(reason, /record-worker-acceptance/); + assert.doesNotMatch(reason, /\/fusion:stats --record/); }); test("an authorized background worker cannot bypass an unjudged Codex collection", (t) => { @@ -2704,7 +2706,7 @@ test("legacy collector failures without a peer job id can satisfy the Stop gate" background_tasks: [], }); assert.strictEqual(JSON.parse(blocked.stdout).decision, "block"); - assert.match(JSON.parse(blocked.stdout).reason, /legacy peer identity unavailable/); + assert.match(JSON.parse(blocked.stdout).reason, /peer identity unavailable/); assert.doesNotMatch(JSON.parse(blocked.stdout).reason, /undefined/); const reported = run(box, { @@ -3385,6 +3387,29 @@ test("Stop emits a settle-only advisory once for an unchanged pending set", (t) assert.strictEqual(repeated.stdout, ""); }); +test("Stop stays silent after a pending settlement lands between stops", (t) => { + const box = sandbox(t); + const settled = createSettleOnlyRecord(box, `fusion-${"4".repeat(24)}`); + + const first = stop(box); + assert.match(first.stdout, new RegExp(settled.taskId)); + recordWorkerAcceptance({ taskId: settled.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + + const second = stop(box); + assert.strictEqual(second.stdout, ""); + assert.doesNotMatch(second.stdout, new RegExp(settled.taskId)); +}); + +test("Stop deduplicates a single settle-only worker", (t) => { + const box = sandbox(t); + const settling = createSettleOnlyRecord(box, `fusion-${"5".repeat(24)}`); + + const first = stop(box); + assert.match(first.stdout, new RegExp(settling.taskId)); + const repeated = stop(box); + assert.strictEqual(repeated.stdout, ""); +}); + test("Stop re-emits a settle-only advisory when a record enters the pending set", (t) => { const box = sandbox(t); const first = createSettleOnlyRecord(box, `fusion-${"1".repeat(24)}`); @@ -3414,7 +3439,7 @@ test("Stop re-emits a settle-only advisory when a record settles", (t) => { assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature, [firstRemaining.taskId, secondRemaining.taskId].sort().join(",")); }); -test("Stop combines a blocking collection demand with an unchanged settle-only advisory", (t) => { +test("Stop deduplicates unchanged settlement rows while demanding collection", (t) => { const box = sandbox(t); const first = createSettleOnlyRecord(box, `fusion-${"1".repeat(24)}`); const second = createSettleOnlyRecord(box, `fusion-${"2".repeat(24)}`); @@ -3431,8 +3456,8 @@ test("Stop combines a blocking collection demand with an unchanged settle-only a const decision = JSON.parse(emitted.stdout); assert.strictEqual(decision.decision, "block"); assert.match(decision.reason, /Call TaskOutput with block=true for terminal owned task collecting-peer/); - assert.match(decision.reason, new RegExp(`settle-only: ${first.taskId}`)); - assert.match(decision.reason, new RegExp(`settle-only: ${second.taskId}`)); + assert.doesNotMatch(decision.reason, new RegExp(`settle-only: ${first.taskId}`)); + assert.doesNotMatch(decision.reason, new RegExp(`settle-only: ${second.taskId}`)); assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).settleOnlyAdvisorySignature, [first.taskId, second.taskId].sort().join(",")); }); @@ -3509,6 +3534,214 @@ test("malformed parent transcript content does not transition a worker during St assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).taskNotificationScanOffset, fs.statSync(box.transcript).size); }); +test("late SubagentStop payloads preserve an accepted collection", (t) => { + for (const message of ["", "summary\ndelivery: complete\nverification: passed"]) { + const box = sandbox(t); + const settled = createAcceptedCollectedRecord(box, `fusion-${message ? "6".repeat(24) : "7".repeat(24)}`, `late-${message ? "full" : "empty"}`); + + const stopped = run(box, { + hook_event_name: "SubagentStop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: settled.agentId, + agent_type: "fusion:fast-worker", + stop_hook_active: true, + last_assistant_message: message + }); + assert.strictEqual(stopped.stdout, ""); + assert.strictEqual(record(box).transportStatus, "done"); + assert.strictEqual(record(box).acceptance, "accepted"); + + const following = stop(box); + assert.strictEqual(following.stdout, ""); + } +}); + +test("Stop does not demote a settled record listed as a completed background task", (t) => { + const box = sandbox(t); + const settled = createAcceptedCollectedRecord(box, `fusion-${"8".repeat(24)}`, "settled-background"); + + const stopped = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + background_tasks: [{ id: settled.agentId, type: "subagent", status: "completed", agent_type: "fusion:fast-worker" }] + }); + assert.strictEqual(stopped.stdout, ""); + assert.strictEqual(record(box).transportStatus, "done"); + assert.strictEqual(record(box).acceptance, "accepted"); +}); + +test("TaskOutput preserves a settled worker verdict", (t) => { + const box = sandbox(t); + const settled = createAcceptedCollectedRecord(box, `fusion-${"9".repeat(24)}`, "settled-task-output"); + + const collected = run(box, { + hook_event_name: "PostToolUse", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + tool_name: "TaskOutput", + tool_input: { task_id: settled.agentId, block: true }, + tool_response: { status: "completed", content: "late task output" } + }); + assert.strictEqual(collected.stdout, ""); + assert.strictEqual(record(box).transportStatus, "done"); + assert.strictEqual(record(box).acceptance, "accepted"); +}); + +test("a queued verdict settles at the notification-driven terminal transition", (t) => { + const box = sandbox(t); + const workerTranscript = path.join(box.root, "queued-notification.output"); + const finalMessage = "notification result\ndelivery: complete\nverification: passed"; + fs.writeFileSync(workerTranscript, `${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: finalMessage }] } })}\n`, "utf8"); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "queued-notification", outputFile: workerTranscript } + }); + const queued = recordWorkerAcceptance({ taskId: record(box).taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + assert.strictEqual(queued.queued, true); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\nqueued-notification\ncompleted\n` } })}\n`, "utf8"); + + const stopped = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: true, + background_tasks: [] + }); + assert.strictEqual(stopped.stdout, ""); + const settled = record(box); + assert.strictEqual(settled.transportStatus, "done"); + assert.strictEqual(settled.acceptance, "accepted"); + assert.strictEqual(settled.awaitingVerdict, false); + assert.strictEqual(settled.pendingVerdict, undefined); +}); + +test("queued accepted verdicts re-arm settlement after cancelled and incomplete hook transitions", (t) => { + const cancelled = sandbox(t); + run(cancelled, dispatch(cancelled)); + run(cancelled, { + ...dispatch(cancelled), + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "queued-cancelled" } + }); + recordWorkerAcceptance({ taskId: record(cancelled).taskId, acceptance: "accepted", env: envFor(cancelled), source: "main-loop" }); + run(cancelled, { + hook_event_name: "PostToolUse", + session_id: "session-1", + cwd: cancelled.cwd, + transcript_path: cancelled.transcript, + tool_name: "TaskStop", + tool_input: { task_id: "queued-cancelled" }, + tool_response: { status: "cancelled" } + }); + + const cancelledRecord = record(cancelled); + assert.strictEqual(cancelledRecord.transportStatus, "cancelled"); + assert.strictEqual(cancelledRecord.acceptance, "unverified"); + assert.strictEqual(cancelledRecord.awaitingVerdict, true); + assert.strictEqual(cancelledRecord.pendingVerdict, undefined); + assert.match(cancelledRecord.pendingVerdictError, /transport status is cancelled/); + + const incomplete = sandbox(t); + run(incomplete, dispatch(incomplete)); + run(incomplete, { + hook_event_name: "SubagentStart", + session_id: "session-1", + cwd: incomplete.cwd, + transcript_path: incomplete.transcript, + agent_id: "queued-incomplete", + agent_type: "fusion:fast-worker" + }); + recordWorkerAcceptance({ taskId: record(incomplete).taskId, acceptance: "accepted", env: envFor(incomplete), source: "main-loop" }); + run(incomplete, { + hook_event_name: "SubagentStop", + session_id: "session-1", + cwd: incomplete.cwd, + transcript_path: incomplete.transcript, + agent_id: "queued-incomplete", + agent_type: "fusion:fast-worker", + stop_hook_active: true, + last_assistant_message: "partial result" + }); + + const incompleteRecord = record(incomplete); + assert.strictEqual(incompleteRecord.transportStatus, "incomplete"); + assert.strictEqual(incompleteRecord.acceptance, "unverified"); + assert.strictEqual(incompleteRecord.awaitingVerdict, true); + assert.strictEqual(incompleteRecord.pendingVerdict, undefined); + assert.match(incompleteRecord.pendingVerdictError, /transport status is incomplete/); +}); + +test("a queued accepted done transition records the linked engine verdict", (t) => { + const box = sandbox(t); + const peerJobId = "d".repeat(32); + const argsFile = path.join(box.root, "codex-companion-args.json"); + const companion = writeCodexAcceptanceCompanion(box); + const workerTranscript = path.join(box.root, "queued-peer-notification.output"); + fs.writeFileSync(workerTranscript, `${JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "peer result\ndelivery: complete\nverification: passed" }] } })}\n`, "utf8"); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + 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" }); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: "\nqueued-peer-notification\ncompleted\n" } })}\n`, "utf8"); + + const stopped = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: true, + background_tasks: [] + }, { FUSION_CODEX_COMPANION: companion, FUSION_TEST_CODEX_COMPANION_ARGS: argsFile }); + + assert.strictEqual(stopped.stdout, ""); + const settled = record(box); + assert.strictEqual(settled.transportStatus, "done"); + assert.strictEqual(settled.acceptance, "accepted"); + assert.strictEqual(settled.engineSettlementError, undefined); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), ["record-acceptance", "--job-id", peerJobId, "--acceptance", "accepted", "--source", "main-loop"]); +}); + +test("the peer wrapper SubagentStop matcher reaches job footer collection", (t) => { + const box = sandbox(t); + const peerJobId = "a".repeat(32); + const hooks = JSON.parse(fs.readFileSync(path.join(repoRoot, "plugins", "fusion", "hooks", "hooks.json"), "utf8")).hooks; + assert.ok(hooks.SubagentStop.some((group) => group.matcher.includes("grok:grok-rescue"))); + const peerDispatch = dispatch(box, { subagent_type: "grok:grok-rescue", prompt: "bounded peer brief" }); + run(box, peerDispatch); + run(box, { + ...peerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "peer-wrapper-stop" } + }); + run(box, { + hook_event_name: "SubagentStop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + agent_id: "peer-wrapper-stop", + agent_type: "grok:grok-rescue", + stop_hook_active: false, + last_assistant_message: `peer complete\njob: ${peerJobId}\ndelivery: complete\nverification: passed` + }); + assert.strictEqual(record(box).peerJobId, peerJobId); + assert.strictEqual(record(box).peerEngine, "grok"); +}); + test("hooks configuration wires lifecycle events through an executable shell command", () => { const hooks = JSON.parse(fs.readFileSync(path.join(repoRoot, "plugins", "fusion", "hooks", "hooks.json"), "utf8")).hooks; for (const event of ["PreToolUse", "PostToolUse", "PostToolUseFailure", "SubagentStart", "SubagentStop", "Stop", "SessionEnd"]) { From 88602b26b28011ef8ff259f6796382f4f50bba5e Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Wed, 22 Jul 2026 15:38:02 +0800 Subject: [PATCH 3/7] feat(codex): adapt to codex cli 0.145.0 --- docs/codex-contract.md | 8 +- plugins/codex/agents/codex-rescue.md | 1 + .../adversarial-review-verdict.schema.json | 38 +++++ plugins/codex/scripts/codex-companion.mjs | 143 ++++++++++-------- plugins/codex/scripts/lib/codex-exec.mjs | 3 + plugins/codex/scripts/lib/render.mjs | 3 + .../codex/skills/adversarial-review/SKILL.md | 2 +- .../codex/skills/codex-cli-runtime/SKILL.md | 4 +- plugins/codex/skills/rescue/SKILL.md | 2 +- plugins/codex/skills/review/SKILL.md | 2 +- plugins/codex/skills/task/SKILL.md | 2 +- tests/codex-args.test.mjs | 11 +- tests/codex-companion.test.mjs | 127 +++++++++++----- tests/codex-exec.test.mjs | 20 ++- tests/codex-render.test.mjs | 7 + tests/fake-codex | 5 +- 16 files changed, 258 insertions(+), 120 deletions(-) create mode 100644 plugins/codex/schemas/adversarial-review-verdict.schema.json diff --git a/docs/codex-contract.md b/docs/codex-contract.md index 2c21ea4..6aebd2d 100644 --- a/docs/codex-contract.md +++ b/docs/codex-contract.md @@ -4,7 +4,7 @@ This document defines the Codex instance of the shared companion contract. The a ## Runtime boundary -The companion invokes the user-installed `codex` binary through `codex exec --strict-config --json` and forwards `--skip-git-repo-check`. It consumes JSONL from stdout, keeps stderr separate, and stores the raw event stream for diagnosis. It does not depend on `codex-plugin-cc`, parse human-readable Codex output, or copy Codex authentication material. The JSONL stream remains the primary transport authority. A bounded read of the matching locally persisted Codex rollout may recover partial output, cumulative usage, and the actual model and effort after timeout or cancellation, but it cannot turn a failed transport into success. Codex configuration parse failures under `--strict-config`, for example an unknown `mcp_servers` field, currently surface as `failureKind: "process"` with the configuration error in the job log. +The companion invokes the user-installed `codex` binary through `codex exec --strict-config --json` and forwards `--skip-git-repo-check`. It consumes JSONL from stdout, keeps stderr separate, and stores the raw event stream for diagnosis. It does not depend on `codex-plugin-cc`, parse human-readable Codex output, or copy Codex authentication material. The JSONL stream remains the primary transport authority. A bounded read of the matching locally persisted Codex rollout may recover partial output, cumulative usage, and the actual model and effort after timeout or cancellation, but it cannot turn a failed transport into success. Timeout and cancellation recovery depends on JSONL rollout files under `~/.codex/sessions`, so the companion must never pass `--ephemeral` because it disables that persistence. Codex configuration parse failures under `--strict-config`, for example an unknown `mcp_servers` field, currently surface as `failureKind: "process"` with the configuration error in the job log. The adapter accepts protocol extensions by retaining unknown events. A run succeeds only after a valid `turn.completed` event and a zero process exit. A `turn.failed` event, nonzero exit, signal, timeout, malformed required event, or exit without a terminal turn event fails the job. Top-level `error` events and error items remain diagnostics because Codex can emit them for recoverable retries and configuration warnings. @@ -59,7 +59,7 @@ Every Codex exec invocation disables `multi_agent` and `multi_agent_v2`. The ada The companion does not add a shell command allow list and does not rewrite the user's configured MCP servers or other Codex tools. Those integrations remain a separate Codex configuration trust boundary and may carry capabilities beyond the filesystem sandbox. -Model and reasoning effort remain unset unless explicitly requested. The Codex configuration remains authoritative for defaults. The companion explicitly sets web search to disabled and workspace-write network access to false by default. `--web` opts a read-only or write task into live Codex web search. `--network` requires `--write` and opts the workspace-write sandbox into network access. +Model and reasoning effort remain unset unless explicitly requested. The Codex configuration remains authoritative for defaults. The companion explicitly sets web search to disabled and workspace-write network access to false by default. `--web` opts a read-only or write task into live Codex web search. `--network` requires `--write` and opts the workspace-write sandbox into network access. Task mode `--output-schema ` validates a regular JSON Schema file up to 256 KiB and parses the final agent message once, while the Codex CLI `review` subcommand accepts but silently ignores `--output-schema`, unchanged from 0.144.6 through the live-verified 0.145.0. The companion writes the final prompt to Codex stdin. Prompts are never interpolated into a shell command. Programmatic callers use `--request-stdin` and pipe the complete raw companion request through stdin, leaving fixed argv free of request bytes. Claude Code's Bash tool does not expose stdin, so slash commands and wrapper Agents retain a private token-named staging compatibility path: the command model writes the raw request through its Write tool before invoking a fixed companion command containing only that token. Once the staging write has landed, the parser preserves positional whitespace, newlines, quotes, and backslashes byte for byte while removing recognized adapter options. The staged compatibility path is not an end-to-end byte-exact transport because a model performs the Write step. SessionEnd removes verified staging files owned by that Claude session, and later transport creation prunes other verified staging files after one hour. Final response text comes from the bounded JSONL stream, so Codex never receives an unbounded fallback output path. Prompts, individual JSONL events, final responses, raw event ledgers, logs, and rendered diagnostics have explicit byte limits. Oversized input or output fails as `resource` before it can inflate durable state. Resume accepts only a persisted Codex thread ID or a companion-selected terminal task record. `--resume-last` is restricted to the current Claude session when its id is available and otherwise selects the newest eligible workspace task. Active jobs are never resume candidates. `--fresh` rejects combination with either resume form and forces a new thread. @@ -94,9 +94,7 @@ Classification uses structured Codex events first, then the process error and bo ## Compatibility -The companion checks the installed Codex version during setup and records it on every job when available. Preflight hard-fails with `failureKind: "setup"` when the installed Codex CLI version parses below the tested minimum 0.144.0 and tells the user to upgrade. Versions above the tested 0.144.x window remain allowed with the setup compatibility advisory. Setup also verifies CLI authentication and a writable adapter data directory. A nonempty `CODEX_API_KEY` is accepted as exec authentication when `codex login status` reports logged out because that probe does not inspect environment based authentication. Unknown JSONL event types are retained and ignored unless they affect terminal correctness. Missing or changed required lifecycle events fail closed as protocol errors. - -Codex CLI 0.144.4 requires `supports_reasoning_summaries` in `models_cache.json` without a serde default. Codex 0.145.0 alpha releases renamed the field to `supports_reasoning_summary_parameter` and added a default. A newer generation binary sharing `CODEX_HOME`, including ChatGPT.app's bundled codex-cli 0.145.0-alpha.18 at `Contents/Resources/codex`, can rewrite the cache in the new schema. A 0.144.4 exec session then logs non-fatal `failed to renew cache TTL: missing field` messages on each etag renewal. Startup refetch self-heals the file. The durable fix is version alignment among all Codex binaries sharing the same `CODEX_HOME`. +The companion checks the installed Codex version during setup and records it on every job when available. Preflight hard-fails with `failureKind: "setup"` when the installed Codex CLI version parses below the tested minimum 0.145.0 and tells the user to upgrade. The tested interval runs from 0.145.0 up to but excluding 0.146.0; versions at or above 0.146.0 remain allowed with the setup compatibility advisory. Live smoke verification against 0.145.0 confirmed task execution, thread resume, task mode structured output, and native review all matched this contract. Setup also verifies CLI authentication and a writable adapter data directory. A nonempty `CODEX_API_KEY` is accepted as exec authentication when `codex login status` reports logged out because that probe does not inspect environment based authentication. Unknown JSONL event types are retained and ignored unless they affect terminal correctness. Missing or changed required lifecycle events fail closed as protocol errors. Protocol fixtures cover every supported event type, unknown extensions, malformed JSON, failure events, missing terminal events, nonzero exits, signals, aborts, timeouts, resume, native review, cancellation, worker death, lock recovery, worktrees, and concurrent terminal writes. diff --git a/plugins/codex/agents/codex-rescue.md b/plugins/codex/agents/codex-rescue.md index 2867f4e..3877df3 100644 --- a/plugins/codex/agents/codex-rescue.md +++ b/plugins/codex/agents/codex-rescue.md @@ -19,6 +19,7 @@ Forwarding rules: - Use one foreground Bash call to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transport-create`. Do not add arguments. Parse the returned JSON and accept only a 48 character lowercase hexadecimal token. - Use the `Read` tool once on the returned transport file path and require it to be empty. Then use the `Write` tool to replace that same file with the raw request exactly as received. The `Write` call is the only channel for raw request bytes. Do not trim, normalize, quote, escape, encode, summarize, or add a newline. Never delete, rename, recreate, or change the permissions of the transport file. - Use a second foreground Bash call with `timeout: 600000` to run the companion `task` subcommand with `--raw-args-token` followed by the validated token. Add the fixed companion option `--transport-default-write` before `--raw-args-token` when the natural language task asks Codex to modify files. Do not add it for reviews, investigations, diagnoses, planning, and other read only work. Explicit `--write=true` or `--write=false` in the raw request remains authoritative. +- Preserve an explicit task `--output-schema ` option in the opaque raw request. The companion resolves and validates the schema before starting the task. - The second Bash command may contain only the fixed Node invocation, the fixed `task` subcommand, the optional fixed write default, the fixed transport option, and the validated token. Never use Bash background mode. An explicit `--background` remains inside the raw request for the companion to parse and must not change how either Bash call or the Agent itself runs. - If the Read call fails, the file is not empty, or the Write call fails, use a foreground Bash call to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transport-discard --raw-args-token TOKEN` with the validated token before returning the failure. Do not expose the token or transport file to the user. - Do not use Read for any path except the newly allocated empty transport file, and do not read it after writing. Do not search, run Git, execute tests, inspect job state, or perform any check, collection, cancellation, or companion operation beyond the fixed task operation. diff --git a/plugins/codex/schemas/adversarial-review-verdict.schema.json b/plugins/codex/schemas/adversarial-review-verdict.schema.json new file mode 100644 index 0000000..0b6a5a3 --- /dev/null +++ b/plugins/codex/schemas/adversarial-review-verdict.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["verdict", "findings"], + "properties": { + "verdict": { + "type": "string", + "minLength": 1 + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["file", "line", "severity", "summary"], + "properties": { + "file": { + "type": "string", + "minLength": 1 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "severity": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + } + } + } + } + } +} diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 0373c05..42afeb2 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -63,6 +63,7 @@ import { const SELF_PATH = fileURLToPath(import.meta.url); const ROOT_DIR = path.resolve(path.dirname(SELF_PATH), ".."); const ADVERSARIAL_REVIEW_PROMPT_FILE = path.join(ROOT_DIR, "prompts", "adversarial-review.md"); +const ADVERSARIAL_REVIEW_SCHEMA_FILE = path.join(ROOT_DIR, "schemas", "adversarial-review-verdict.schema.json"); const FOREGROUND_TIMEOUT_ENV = "CODEX_COMPANION_TIMEOUT_MS"; const BACKGROUND_TIMEOUT_ENV = "CODEX_COMPANION_BACKGROUND_TIMEOUT_MS"; const BACKGROUND_DELIVERY_ENV = "CODEX_COMPANION_BACKGROUND_DELIVERY"; @@ -78,14 +79,15 @@ const DEFAULT_WAIT_TIMEOUT_MS = 570000; const MAX_WAIT_TIMEOUT_MS = 570000; const MAX_PROMPT_BYTES = 4 * 1024 * 1024; const MAX_RAW_ARGUMENT_BYTES = MAX_PROMPT_BYTES + 64 * 1024; +const MAX_OUTPUT_SCHEMA_BYTES = 256 * 1024; const HEARTBEAT_INTERVAL_MS = 5000; const BACKGROUND_LAUNCH_APPROVAL_TIMEOUT_MS = 10000; const BACKGROUND_LAUNCH_POLL_MS = 20; const BACKGROUND_ABORT_CLAIM_WAIT_MS = 2000; const BACKGROUND_ABORT_CLEANUP_CONFIRM_MS = 1000; const BACKGROUND_ABORT_CLEANUP_POLL_MS = 50; -const TESTED_VERSION_MIN = [0, 144, 0]; -const TESTED_VERSION_MAX = [0, 145, 0]; +const TESTED_VERSION_MIN = [0, 145, 0]; +const TESTED_VERSION_MAX = [0, 146, 0]; const CONTINUE_PROMPT = "Continue from the current Codex thread state. Complete the next highest value step and continue until the task is resolved."; const TRANSPORT_DIRECTORY_PREFIX = "codex-companion-input-"; const TRANSPORT_TOKEN_PATTERN = /^[a-f0-9]{48}$/; @@ -95,10 +97,6 @@ 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 MODELS_CACHE_SCHEMA_DRIFT_NEXT_STEP = "Codex CLI cannot parse the current models cache (schema drift). Upgrade the Codex CLI, then run /codex:setup again."; -const SETUP_LOG_PROBE_BYTES = 64 * 1024; -const SETUP_LOG_PROBE_LIMIT = 5; - let activeCommandArgv = null; class CompanionError extends Error { @@ -112,7 +110,7 @@ function printUsage() { process.stdout.write( [ "Usage:", - " node scripts/codex-companion.mjs task [--prompt-file ] [--write] [--background] [--resume ] [--resume-last] [--fresh] [--model ] [--effort ] [--service-tier ] [--web] [--network] [--skip-git-repo-check] [--cwd ] [--json] [--] [prompt]", + " node scripts/codex-companion.mjs task [--prompt-file ] [--output-schema ] [--write] [--background] [--resume ] [--resume-last] [--fresh] [--model ] [--effort ] [--service-tier ] [--web] [--network] [--skip-git-repo-check] [--cwd ] [--json] [--] [prompt]", " node scripts/codex-companion.mjs review [--base ] [--scope ] [--focus ] [--background] [--model ] [--effort ] [--service-tier ] [--cwd ] [--json]", " node scripts/codex-companion.mjs adversarial-review [--base ] [--scope ] [--focus ] [--background] [--model ] [--effort ] [--cwd ] [--json]", " node scripts/codex-companion.mjs status [job-id] [--all] [--cwd ] [--json]", @@ -215,6 +213,43 @@ function resolveCwd(options = {}) { } } +function resolveOutputSchemaFile(cwd, value) { + if (typeof value !== "string" || !value.trim()) { + throw new CompanionError("--output-schema requires a non-empty path.", "input"); + } + const file = path.resolve(cwd, value); + let stats; + try { + stats = fs.statSync(file); + } catch (error) { + if (error?.code === "ENOENT") { + throw new CompanionError(`Output schema file does not exist: ${file}.`, "input"); + } + throw new CompanionError(`Could not read output schema file ${file}: ${error.message}`, "input"); + } + if (!stats.isFile()) { + throw new CompanionError(`Output schema path is not a regular file: ${file}.`, "input"); + } + if (stats.size > MAX_OUTPUT_SCHEMA_BYTES) { + throw new CompanionError(`Output schema file exceeds the ${MAX_OUTPUT_SCHEMA_BYTES / 1024} KiB limit: ${file}.`, "input"); + } + let source; + try { + source = fs.readFileSync(file); + } catch (error) { + throw new CompanionError(`Could not read output schema file ${file}: ${error.message}`, "input"); + } + if (source.byteLength > MAX_OUTPUT_SCHEMA_BYTES) { + throw new CompanionError(`Output schema file exceeds the ${MAX_OUTPUT_SCHEMA_BYTES / 1024} KiB limit: ${file}.`, "input"); + } + try { + JSON.parse(source.toString("utf8")); + } catch { + throw new CompanionError(`Output schema file must contain valid JSON: ${file}.`, "input"); + } + return file; +} + function nonnegativeInteger(value, label) { const number = Number(value); if (!Number.isSafeInteger(number) || number < 0) { @@ -745,7 +780,7 @@ function preflightCodex(cwd) { } const version = parseVersion(probe.version); if (version && compareVersion(version, TESTED_VERSION_MIN) < 0) { - throw new CompanionError(`Codex CLI version ${probe.version} is unsupported. Upgrade the Codex CLI to version 0.144.0 or later.`, "setup"); + throw new CompanionError(`Codex CLI version ${probe.version} is unsupported. Upgrade the Codex CLI to version 0.145.0 or later.`, "setup"); } return probe; } @@ -765,61 +800,15 @@ function compareVersion(left, right) { return 0; } +function testedVersionInterval() { + return `${TESTED_VERSION_MIN.join(".")} to before ${TESTED_VERSION_MAX.join(".")}`; +} + function supportedVersion(value) { const parsed = parseVersion(value); return Boolean(parsed) && compareVersion(parsed, TESTED_VERSION_MIN) >= 0 && compareVersion(parsed, TESTED_VERSION_MAX) < 0; } -function setupLogTailContainsSchemaDrift(file) { - let descriptor; - try { - const stat = fs.statSync(file); - if (!stat.isFile()) { - return false; - } - const bytes = Math.min(stat.size, SETUP_LOG_PROBE_BYTES); - if (bytes === 0) { - return false; - } - const buffer = Buffer.alloc(bytes); - descriptor = fs.openSync(file, "r"); - const read = fs.readSync(descriptor, buffer, 0, bytes, stat.size - bytes); - const tail = buffer.subarray(0, read).toString("utf8"); - return tail.includes("failed to renew cache TTL") || (tail.includes("missing field `") && tail.includes("codex_models_manager")); - } catch { - return false; - } finally { - if (descriptor !== undefined) { - try { - fs.closeSync(descriptor); - } catch {} - } - } -} - -function hasRecentModelsCacheSchemaDrift(dataDir, cwd) { - let entries; - try { - entries = fs.readdirSync(jobsDir(dataDir, cwd), { withFileTypes: true }); - } catch { - return false; - } - const recentLogs = entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".log")) - .map((entry) => { - const file = path.join(jobsDir(dataDir, cwd), entry.name); - try { - return { file, modifiedAt: fs.statSync(file).mtimeMs }; - } catch { - return null; - } - }) - .filter(Boolean) - .sort((left, right) => right.modifiedAt - left.modifiedAt || left.file.localeCompare(right.file)) - .slice(0, SETUP_LOG_PROBE_LIMIT); - return recentLogs.some(({ file }) => setupLogTailContainsSchemaDrift(file)); -} - function findRequestedJob(dataDir, jobId, options) { if (options.cwd == null) { return findJobRecordById(dataDir, jobId); @@ -1250,6 +1239,7 @@ function executionArgs(record) { } return buildTaskArgs({ ...common, + outputSchemaFile: request.outputSchemaFile ?? undefined, resumeThreadId: request.resumeThreadId ?? undefined, write: request.write === true }); @@ -1287,6 +1277,20 @@ function semanticOutcome(outcome) { return { semanticFailureKind: null, semanticFailureMessage: null, semanticStatus: "unverified" }; } +function structuredOutputOutcome(request, outcome) { + if (!request.outputSchemaFile || outcome.resultSource !== "agent_message" || typeof outcome.finalResponse !== "string") { + return {}; + } + try { + return { structuredOutput: JSON.parse(outcome.finalResponse), structuredOutputError: null }; + } catch (error) { + return { + structuredOutput: null, + structuredOutputError: `Codex final response is not valid JSON: ${oneLine(error.message, "JSON parsing failed.")}` + }; + } +} + function finishExecutionFailure(file, record, error, env = process.env) { const rawMessage = oneLine(error?.message ?? error, "Codex companion execution failed."); appendJobLog(record.logFile, rawMessage); @@ -1400,6 +1404,7 @@ async function executeRecord(found) { } const errorTail = redactDiagnostic(readLogTail(record.logFile, 20) || boundedTail(outcome.stderrTail)); const semantic = semanticOutcome(outcome); + const structured = structuredOutputOutcome(record.request ?? {}, outcome); const resolvedModel = outcome.resolvedModel ?? record.resolvedModel; const modelDrift = taskModelDrift(record, prompt, resolvedModel); return finishJob(found.file, { @@ -1429,6 +1434,7 @@ async function executeRecord(found) { semanticFailureMessage: semantic.semanticFailureMessage, semanticStatus: semantic.semanticStatus, status: outcome.status, + ...structured, threadId: outcome.threadId, tokenUsage: outcome.tokenUsage, tokenUsageAvailability: outcome.tokenUsageAvailability, @@ -1631,7 +1637,7 @@ async function handleTask(rawArgv, transport = {}) { const { options, positionals, positionalText } = commandArgs(rawArgv, { booleanOptions: ["background", "fresh", "json", "network", "resume-last", "skip-git-repo-check", "web", "write"], optionsBeforePositionals: true, - valueOptions: ["cwd", "effort", "model", "prompt-file", "resume", "service-tier"] + valueOptions: ["cwd", "effort", "model", "output-schema", "prompt-file", "resume", "service-tier"] }); const serviceTier = serviceTierOption(options["service-tier"]); const cwd = resolveCwd(options); @@ -1639,6 +1645,7 @@ async function handleTask(rawArgv, transport = {}) { const resume = resolveResume(dataDir, cwd, options); const routing = resume.threadId ? inheritedRouting(latestJobRecordForThread(dataDir, resume.threadId), options, serviceTier) : inheritedRouting(null, options, serviceTier); const write = options.write ?? Boolean(transport.defaultWrite); + const outputSchemaFile = options["output-schema"] ? resolveOutputSchemaFile(cwd, options["output-schema"]) : null; let prompt = readTaskPrompt(cwd, options, positionals, positionalText); if (!prompt.trim() && resume.threadId) { prompt = CONTINUE_PROMPT; @@ -1656,6 +1663,7 @@ async function handleTask(rawArgv, transport = {}) { ingress: transport.ingress ?? "argv", model: routing.model, network: Boolean(options.network), + outputSchemaFile, resumeSourceJobId: resume.sourceJobId, resumeThreadId: resume.threadId, skipGitRepoCheck: Boolean(options["skip-git-repo-check"]), @@ -1720,8 +1728,11 @@ function adversarialReviewPrompt(target, focus) { async function handleReview(rawArgv, adversarial = false, transport = {}) { const { options, positionals, positionalText } = commandArgs(rawArgv, { booleanOptions: ["background", "json"], - valueOptions: ["base", "cwd", "effort", "focus", "model", "scope", "service-tier"] + valueOptions: ["base", "cwd", "effort", "focus", "model", "output-schema", "scope", "service-tier"] }); + if (options["output-schema"] != null) { + throw new CompanionError("Codex review ignores --output-schema on tested CLI versions.", "input"); + } if (positionals.length > 0 && !options.focus) { throw new CompanionError("Review accepts flags only. Pass custom review instructions with --focus.", "input"); } @@ -1737,6 +1748,7 @@ async function handleReview(rawArgv, adversarial = false, transport = {}) { : focus ? focusedReviewPrompt(target, focus) : ""; + const outputSchemaFile = adversarial ? resolveOutputSchemaFile(cwd, ADVERSARIAL_REVIEW_SCHEMA_FILE) : null; const request = { base: target.base, effort: options.effort ?? null, @@ -1744,6 +1756,7 @@ async function handleReview(rawArgv, adversarial = false, transport = {}) { ingress: transport.ingress ?? "argv", model: options.model ?? null, network: false, + outputSchemaFile, serviceTier, transport: usePromptTransport ? (adversarial ? "adversarial-review" : "focused-review") : "native-review", uncommitted: target.uncommitted, @@ -2118,6 +2131,9 @@ function handleSetup(rawArgv) { const codex = getCodexAvailability({ cwd, env: process.env }); const auth = codex.available ? authenticationStatus(codex.bin, cwd, process.env) : { authenticated: false, detail: null }; const compatible = codex.available && supportedVersion(codex.version); + const installedVersion = parseVersion(codex.version); + const newerThanTested = Boolean(installedVersion) && compareVersion(installedVersion, TESTED_VERSION_MAX) >= 0; + const testedInterval = testedVersionInterval(); const dataDir = resolveDataDir(); let writable = true; try { @@ -2134,14 +2150,11 @@ function handleSetup(rawArgv) { nextSteps.push("Run codex login, then run /codex:setup again."); } if (codex.available && !compatible) { - nextSteps.push("Use a Codex CLI version from 0.144.0 to the latest 0.144.x release."); + nextSteps.push(newerThanTested ? `Codex CLI version ${codex.version} is newer than the tested interval (${testedInterval}). A verification pass is advised.` : `Use a Codex CLI version in the tested interval (${testedInterval}).`); } if (!writable) { nextSteps.push(`Make the adapter data directory writable: ${dataDir}.`); } - if (hasRecentModelsCacheSchemaDrift(dataDir, cwd)) { - nextSteps.push(MODELS_CACHE_SCHEMA_DRIFT_NEXT_STEP); - } const report = { authenticated: auth.authenticated, authenticationDetail: auth.detail ? redactDiagnostic(boundedTail(auth.detail, 5)) : null, @@ -2151,7 +2164,7 @@ function handleSetup(rawArgv) { errorMessage: codex.errorMessage ? redactDiagnostic(boundedTail(codex.errorMessage, 5)) : null, rawVersion: codex.rawVersion ? redactDiagnostic(boundedTail(codex.rawVersion, 5)) : null }, - compatibility: compatible ? "tested" : codex.available ? "outside the tested 0.144.x interval" : "unknown", + compatibility: compatible ? "tested" : codex.available ? newerThanTested ? `newer than the tested interval (${testedInterval})` : `outside the tested interval (${testedInterval})` : "unknown", dataDir, nextSteps, ready: codex.available && auth.authenticated && compatible && writable, diff --git a/plugins/codex/scripts/lib/codex-exec.mjs b/plugins/codex/scripts/lib/codex-exec.mjs index 32b3bf0..97e8ecc 100644 --- a/plugins/codex/scripts/lib/codex-exec.mjs +++ b/plugins/codex/scripts/lib/codex-exec.mjs @@ -211,6 +211,9 @@ export function buildReviewArgs(options = {}) { if (options.write === true || options.mode === "write" || options.sandboxMode === "workspace-write" || options.sandboxMode === "danger-full-access") { throw new TypeError("Codex native review must use the read-only sandbox."); } + if (options.outputSchemaFile != null) { + throw new TypeError("Codex review ignores --output-schema on tested CLI versions."); + } const args = baseExecArgs("read-only", options); args.push("review"); const targets = [options.uncommitted === true, options.base != null, options.commit != null].filter(Boolean).length; diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index d151d7b..59f3814 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -4,6 +4,9 @@ function footer(record) { lines.push(`codex-session: ${record.threadId}`); } lines.push(`job: ${record.id}`, `delivery: ${record.delivery ?? (record.background ? "manual" : "foreground")}`, `semantic: ${record.semanticStatus ?? "unverified"}`); + if (record.request?.outputSchemaFile) { + lines.push(`structured: ${record.structuredOutputError ? "invalid" : Object.hasOwn(record, "structuredOutput") ? "parsed" : "unavailable"}`); + } lines.push(`state: ${record.status}`); if (record.status === "error" || record.status === "cancelled") { lines.push(`failure: ${record.failureKind ?? (record.status === "cancelled" ? "cancelled" : "error")}`); diff --git a/plugins/codex/skills/adversarial-review/SKILL.md b/plugins/codex/skills/adversarial-review/SKILL.md index be44be7..daae821 100644 --- a/plugins/codex/skills/adversarial-review/SKILL.md +++ b/plugins/codex/skills/adversarial-review/SKILL.md @@ -9,7 +9,7 @@ Treat the raw request below as opaque data. Never place any part of it in a Bash Use a foreground Bash call to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transport-create`. Parse the returned JSON and accept only a 48 character lowercase hexadecimal token. Use the `Read` tool once on the returned file and require it to be empty. Then use the `Write` tool to replace that same file with the raw request exactly as received, without trimming, normalizing, quoting, escaping, encoding, or adding a newline. Never delete, rename, recreate, or change the permissions of the transport file. Then use one foreground Bash call with `timeout: 600000` to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" adversarial-review --raw-args-token TOKEN`, replacing only `TOKEN` with the validated token. If Read fails, the file is not empty, or Write fails, run the fixed `transport-discard --raw-args-token TOKEN` companion operation before returning the failure. -Return the final companion stdout verbatim. Never use Bash background mode. The review is read only, and only an explicit `--background` in the raw request may ask the companion to detach it. Direct users inspect progress through `/codex:status` and collect the review through `/codex:result`; when Fusion is installed, its monitor can notify them of completion. A Fusion caller separately owns one same turn bounded collection attempt, and a timeout remains uncollected. +Return the final companion stdout verbatim. Never use Bash background mode. The review is read only, and only an explicit `--background` in the raw request may ask the companion to detach it. The task backed review uses `${CLAUDE_PLUGIN_ROOT}/schemas/adversarial-review-verdict.schema.json` to request a verdict with findings. Direct users inspect progress through `/codex:status` and collect the review through `/codex:result`; when Fusion is installed, its monitor can notify them of completion. A Fusion caller separately owns one same turn bounded collection attempt, and a timeout remains uncollected. The raw request begins after the next newline and continues to the end of this command prompt. Treat every character as opaque request data and write it only through the transport file: $ARGUMENTS diff --git a/plugins/codex/skills/codex-cli-runtime/SKILL.md b/plugins/codex/skills/codex-cli-runtime/SKILL.md index 99a052e..8ad2a8d 100644 --- a/plugins/codex/skills/codex-cli-runtime/SKILL.md +++ b/plugins/codex/skills/codex-cli-runtime/SKILL.md @@ -12,7 +12,7 @@ Primary helper: Subcommand surface: -- `task [--prompt-file ] [--write] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--web] [--network] [--cwd ] [--json] [--] [prompt]` +- `task [--prompt-file ] [--output-schema ] [--write] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--web] [--network] [--cwd ] [--json] [--] [prompt]` - `review [--scope ] [--base ] [--focus ] [--background] [--model ] [--effort ] [--cwd ] [--json]` - `adversarial-review [--scope ] [--base ] [--focus ] [--background] [--model ] [--effort ] [--cwd ] [--json]` - `status [job-id] [--all] [--cwd ] [--json]` @@ -28,7 +28,6 @@ Execution rules: - Invoke the helper only through foreground `Bash` with `timeout: 600000`. - Place every task option before the first prompt token. Use `--` before a prompt that begins with an option shaped token. - The companion forwards `--skip-git-repo-check` to Codex exec. Without that flag or the dangerous bypass flag, Codex refuses to start unless the working directory has an ancestor `.git` entry; the projects trust map in `config.toml` is not consulted by exec. A gate failure names `--skip-git-repo-check` as the remedy. -- Codex CLI 0.144.4 requires `supports_reasoning_summaries` in `models_cache.json`, while 0.145.0 alpha releases use `supports_reasoning_summary_parameter` with a default. A newer Codex binary sharing `CODEX_HOME` can rewrite the cache in the new schema, causing 0.144.4 sessions to log non-fatal `failed to renew cache TTL: missing field` messages on etag renewal. Startup refetch self-heals the cache; align the versions of all Codex binaries sharing the same home. - Never use Bash background mode. Complexity, duration, and model choice never justify implicit background execution. - Pass `--background` only when the received request explicitly contains it. The companion owns detachment and returns a durable job receipt. - A direct slash command invocation returns that receipt without automatic collection. Direct users inspect progress through status and collect the deliverable through result; when Fusion is installed, its monitor can notify them of completion. Fusion orchestration separately owns one same turn collection attempt capped at 540000ms for jobs it creates. A timeout remains explicitly uncollected. @@ -37,6 +36,7 @@ Execution rules: - Use `--resume ` only with a real thread identifier returned by Codex. Use `--resume-last` for the newest eligible task thread launched by the current Claude session, or the newest eligible workspace task when no Claude session id is available. - Use `--fresh` only when the caller explicitly requests a new thread. It cannot be combined with either resume form. - Use `--web` only when explicitly requested. Use `--network` only with `--write` and only when explicitly requested. +- Use `--output-schema ` only for task mode. The companion resolves the path, requires a regular JSON file at most 256 KiB, and records one JSON parsing result without retrying the task. Native `review` ignores output schemas on tested CLI versions. Review shaped task briefs can use `${CLAUDE_PLUGIN_ROOT}/schemas/adversarial-review-verdict.schema.json`; adversarial review uses that schema automatically. - `result --wait` performs a bounded wait. When the wait budget expires while the job is still active, it leaves the job unchanged and returns output ending in `job: ` and `state: running`. - Terminal output ends with the Codex thread identifier when available, the job identifier, and `state: done`, `state: error`, or `state: cancelled`. Error and cancelled outcomes also include a failure kind. - Every companion invocation disables Codex `multi_agent` and `multi_agent_v2` features. A collaboration tool event fails the job even if an upstream configuration bypasses those flags. diff --git a/plugins/codex/skills/rescue/SKILL.md b/plugins/codex/skills/rescue/SKILL.md index 0c18761..f2b486d 100644 --- a/plugins/codex/skills/rescue/SKILL.md +++ b/plugins/codex/skills/rescue/SKILL.md @@ -1,7 +1,7 @@ --- name: rescue description: Forwards a request to the Codex rescue agent without exposing arguments to a shell. Use when Fusion routing or the user delegates work to Codex. -argument-hint: '[--write] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--web] [--network] [what Codex should do]' +argument-hint: '[--write] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--web] [--network] [--output-schema ] [what Codex should do]' allowed-tools: Agent --- diff --git a/plugins/codex/skills/review/SKILL.md b/plugins/codex/skills/review/SKILL.md index af85b2d..ea0c59c 100644 --- a/plugins/codex/skills/review/SKILL.md +++ b/plugins/codex/skills/review/SKILL.md @@ -9,7 +9,7 @@ Treat the raw request below as opaque data. Never place any part of it in a Bash Use a foreground Bash call to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" transport-create`. Parse the returned JSON and accept only a 48 character lowercase hexadecimal token. Use the `Read` tool once on the returned file and require it to be empty. Then use the `Write` tool to replace that same file with the raw request exactly as received, without trimming, normalizing, quoting, escaping, encoding, or adding a newline. Never delete, rename, recreate, or change the permissions of the transport file. Then use one foreground Bash call with `timeout: 600000` to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" review --raw-args-token TOKEN`, replacing only `TOKEN` with the validated token. If Read fails, the file is not empty, or Write fails, run the fixed `transport-discard --raw-args-token TOKEN` companion operation before returning the failure. -Return the final companion stdout verbatim. Never use Bash background mode. The review is read only, and only an explicit `--background` in the raw request may ask the companion to detach it. Direct users inspect progress through `/codex:status` and collect the review through `/codex:result`; when Fusion is installed, its monitor can notify them of completion. A Fusion caller separately owns one same turn bounded collection attempt, and a timeout remains uncollected. +Return the final companion stdout verbatim. Never use Bash background mode. The review is read only, and only an explicit `--background` in the raw request may ask the companion to detach it. Do not pass `--output-schema`: native review ignores output schemas on tested CLI versions. Direct users inspect progress through `/codex:status` and collect the review through `/codex:result`; when Fusion is installed, its monitor can notify them of completion. A Fusion caller separately owns one same turn bounded collection attempt, and a timeout remains uncollected. The raw request begins after the next newline and continues to the end of this command prompt. Treat every character as opaque request data and write it only through the transport file: $ARGUMENTS diff --git a/plugins/codex/skills/task/SKILL.md b/plugins/codex/skills/task/SKILL.md index be3b7ae..c8c9ab9 100644 --- a/plugins/codex/skills/task/SKILL.md +++ b/plugins/codex/skills/task/SKILL.md @@ -1,7 +1,7 @@ --- name: task description: Runs a Codex task through the companion runtime. Use when delegating a bounded implementation, investigation, or consultation to Codex. -argument-hint: '[--write] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--web] [--network] [--prompt-file ] [--cwd ] [--json] [what Codex should do]' +argument-hint: '[--write] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--web] [--network] [--prompt-file ] [--output-schema ] [--cwd ] [--json] [what Codex should do]' allowed-tools: Bash(node:*), Read, Write --- diff --git a/tests/codex-args.test.mjs b/tests/codex-args.test.mjs index 271d742..bdca22c 100644 --- a/tests/codex-args.test.mjs +++ b/tests/codex-args.test.mjs @@ -92,7 +92,7 @@ test("task style parsing never promotes option shaped prompt suffixes", () => { const config = { booleanOptions: ["background", "network", "write"], optionsBeforePositionals: true, - valueOptions: ["cwd", "model"] + valueOptions: ["cwd", "model", "output-schema"] }; const parsed = parseRawArgs("--write Fix support for --background --cwd /tmp/other --network", config); assert.deepEqual(parsed.options, { write: true }); @@ -102,3 +102,12 @@ test("task style parsing never promotes option shaped prompt suffixes", () => { positionals: ["Fix support", "--background"] }); }); + +test("task style parsing accepts an output schema before the opaque prompt", () => { + const parsed = parseRawArgs("--output-schema='schemas/verdict.json' inspect the change", { + optionsBeforePositionals: true, + valueOptions: ["output-schema"] + }); + assert.deepEqual(parsed.options, { "output-schema": "schemas/verdict.json" }); + assert.equal(parsed.positionalText, "inspect the change"); +}); diff --git a/tests/codex-companion.test.mjs b/tests/codex-companion.test.mjs index 741649a..faa4ef0 100644 --- a/tests/codex-companion.test.mjs +++ b/tests/codex-companion.test.mjs @@ -215,11 +215,71 @@ test("task stays foreground by default and persists the complete terminal record assert.equal(entry.record.resolvedModel, "gpt-test"); assert.equal(entry.record.resolvedEffort, "max"); assert.equal(entry.record.tokenUsageAvailability, "available"); - assert.equal(entry.record.codexVersion, "0.144.4"); + assert.equal(entry.record.codexVersion, "0.145.0"); assert.equal(fs.statSync(entry.file).mode & 0o777, 0o600); assert.equal(fs.statSync(path.dirname(entry.file)).mode & 0o777, 0o700); }); +test("task resolves an output schema, forwards it, and records parsed structured output", (t) => { + const sandbox = makeSandbox(t); + const schemaDirectory = path.join(sandbox.workDir, "schemas"); + const schema = path.join(schemaDirectory, "verdict.json"); + fs.mkdirSync(schemaDirectory); + fs.writeFileSync(schema, JSON.stringify({ type: "object" })); + const resolvedSchema = fs.realpathSync.native(schema); + const result = runCompanion(["task", "--json", "--output-schema", "schemas/verdict.json", "return a verdict"], { + cwd: sandbox.workDir, + env: envFor(sandbox, { FAKE_CODEX_JSON_OBJECT_MESSAGE: "true" }) + }); + assert.equal(result.status, 0, result.stderr); + const record = JSON.parse(result.stdout); + assert.equal(record.request.outputSchemaFile, resolvedSchema); + assert.equal(record.resultText, '{"verdict":"pass","findings":[]}'); + assert.deepEqual(record.structuredOutput, { verdict: "pass", findings: [] }); + assert.equal(record.structuredOutputError, null); + const args = readArgs(sandbox); + const schemaIndex = args.indexOf("--output-schema"); + assert.equal(args[schemaIndex + 1], resolvedSchema); + assert.equal(jobRecords(sandbox)[0].request.outputSchemaFile, resolvedSchema); +}); + +test("task retains a non-JSON agent message and records the structured parsing error", (t) => { + const sandbox = makeSandbox(t); + const schema = path.join(sandbox.workDir, "verdict.json"); + fs.writeFileSync(schema, JSON.stringify({ type: "object" })); + const result = runCompanion(["task", "--json", "--output-schema", schema, "return a verdict"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 0, result.stderr); + const record = JSON.parse(result.stdout); + assert.equal(record.resultText, "Codex completed the task."); + assert.equal(record.structuredOutput, null); + assert.match(record.structuredOutputError, /^Codex final response is not valid JSON:/); +}); + +test("task rejects missing, nonregular, oversized, and invalid output schemas before execution", (t) => { + const sandbox = makeSandbox(t); + const cases = [ + { path: "missing.json", message: /does not exist/ }, + { path: "schemas", message: /not a regular file/ }, + { path: "oversized.json", message: /exceeds the 256 KiB limit/ }, + { path: "invalid.json", message: /must contain valid JSON/ } + ]; + fs.mkdirSync(path.join(sandbox.workDir, "schemas")); + fs.writeFileSync(path.join(sandbox.workDir, "oversized.json"), "x".repeat(256 * 1024 + 1)); + fs.writeFileSync(path.join(sandbox.workDir, "invalid.json"), "{not-json}"); + for (const entry of cases) { + const result = runCompanion(["task", "--output-schema", entry.path, "return a verdict"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + assert.match(result.stderr, entry.message); + } + assert.equal(fs.existsSync(sandbox.argsFile), false); +}); + test("option shaped text after a task prompt cannot enable background or change execution settings", (t) => { const sandbox = makeSandbox(t); const prompt = "Fix support for --background --cwd /tmp/other --network"; @@ -572,32 +632,32 @@ test("task rejects an outdated Codex CLI before execution", (t) => { const outdated = makeSandbox(t); const outdatedResult = runCompanion(["task", "--json", "do work"], { cwd: outdated.workDir, - env: envFor(outdated, { FAKE_CODEX_VERSION: "0.143.9" }) + env: envFor(outdated, { FAKE_CODEX_VERSION: "0.144.9" }) }); assert.equal(outdatedResult.status, 1); assert.equal(outdatedResult.stdout, ""); const outdatedFailure = JSON.parse(outdatedResult.stderr); assert.equal(outdatedFailure.status, "error"); assert.equal(outdatedFailure.failureKind, "setup"); - assert.match(outdatedFailure.message, /Upgrade the Codex CLI to version 0\.144\.0 or later\./); + assert.match(outdatedFailure.message, /Upgrade the Codex CLI to version 0\.145\.0 or later\./); assert.equal(fs.existsSync(outdated.argsFile), false); assert.deepEqual(jobRecords(outdated), []); const text = makeSandbox(t); const textResult = runCompanion(["task", "do work"], { cwd: text.workDir, - env: envFor(text, { FAKE_CODEX_VERSION: "0.143.9" }) + env: envFor(text, { FAKE_CODEX_VERSION: "0.144.9" }) }); assert.equal(textResult.status, 1); assert.equal(textResult.stdout, ""); - assert.match(textResult.stderr, /Upgrade the Codex CLI to version 0\.144\.0 or later\./); + assert.match(textResult.stderr, /Upgrade the Codex CLI to version 0\.145\.0 or later\./); assert.match(textResult.stderr, /failure: setup/); assert.equal(fs.existsSync(text.argsFile), false); assert.deepEqual(jobRecords(text), []); }); test("task proceeds with tested and newer Codex CLI versions", (t) => { - for (const version of ["0.144.0", "0.145.0"]) { + for (const version of ["0.145.0", "0.146.0"]) { const sandbox = makeSandbox(t); const result = runCompanion(["task", "--json", "do work"], { cwd: sandbox.workDir, @@ -1131,6 +1191,7 @@ test("resume-last inherits model, effort, and service tier from its thread", (t) ingress: "argv", model: "gpt-5.6-terra", network: false, + outputSchemaFile: null, resumeSourceJobId: source.id, resumeThreadId: "thread-routing", serviceTier: "flex", @@ -1287,6 +1348,19 @@ test("plain native review uses target flags and focus switches to a read only ta assert.equal(jobRecords(sandbox)[1].request.transport, "focused-review"); }); +test("review rejects output schemas because tested Codex review versions ignore them", (t) => { + const sandbox = makeSandbox(t); + const result = runCompanion(["review", "--json", "--output-schema", "schemas/verdict.json"], { + cwd: sandbox.workDir, + env: envFor(sandbox) + }); + assert.equal(result.status, 1); + const error = JSON.parse(result.stderr); + assert.equal(error.failureKind, "input"); + assert.match(error.message, /review ignores --output-schema on tested CLI versions/); + assert.equal(fs.existsSync(sandbox.argsFile), false); +}); + test("adversarial review is a normal read only task with the explicit review contract", (t) => { const sandbox = makeSandbox(t); const env = envFor(sandbox); @@ -1301,7 +1375,10 @@ test("adversarial review is a normal read only task with the explicit review con const prompt = fs.readFileSync(sandbox.stdinFile, "utf8"); assert.match(prompt, /strongest evidence that the reviewed change should not ship yet/); assert.match(prompt, /cancellation races/); - assert.equal(jobRecords(sandbox)[0].request.transport, "adversarial-review"); + const [record] = jobRecords(sandbox); + assert.equal(record.request.transport, "adversarial-review"); + assert.equal(record.request.outputSchemaFile, path.join(repoRoot, "plugins", "codex", "schemas", "adversarial-review-verdict.schema.json")); + assert.deepEqual(args.slice(args.indexOf("--output-schema"), args.indexOf("--output-schema") + 2), ["--output-schema", record.request.outputSchemaFile]); }); test("cancel terminates the supervised process group and terminal state cannot be overwritten", async (t) => { @@ -1470,7 +1547,7 @@ test("setup verifies authentication and the tested Codex version interval", (t) assert.doesNotMatch(JSON.stringify(JSON.parse(redacted.stdout)), /sk-secretvalue123/); const redactedVersion = runCompanion(["setup", "--json"], { cwd: sandbox.workDir, - env: envFor(sandbox, { FAKE_CODEX_VERSION_OUTPUT: "codex-cli 0.144.4 access_token=secretversionvalue" }) + env: envFor(sandbox, { FAKE_CODEX_VERSION_OUTPUT: "codex-cli 0.145.0 access_token=secretversionvalue" }) }); assert.equal(redactedVersion.status, 0, redactedVersion.stderr); assert.doesNotMatch(JSON.stringify(JSON.parse(redactedVersion.stdout)), /secretversionvalue/); @@ -1492,41 +1569,13 @@ test("setup verifies authentication and the tested Codex version interval", (t) assert.doesNotMatch(apiKey.stdout, /codex-test-key/); const unsupported = runCompanion(["setup", "--json"], { cwd: sandbox.workDir, - env: envFor(sandbox, { FAKE_CODEX_VERSION: "0.145.0" }) + env: envFor(sandbox, { FAKE_CODEX_VERSION: "0.146.0" }) }); assert.equal(unsupported.status, 1); const unsupportedReport = JSON.parse(unsupported.stdout); assert.equal(unsupportedReport.ready, false); - assert.match(unsupportedReport.compatibility, /outside the tested/); -}); - -test("setup reports recent models cache schema drift from job logs without failing", (t) => { - const sandbox = makeSandbox(t); - const logFile = jobLogPath(sandbox.dataDir, sandbox.workDir, "models-cache-drift"); - fs.mkdirSync(path.dirname(logFile), { recursive: true }); - fs.writeFileSync(logFile, "error: failed to renew cache TTL\n"); - const result = runCompanion(["setup", "--json"], { cwd: sandbox.workDir, env: envFor(sandbox) }); - assert.equal(result.status, 0, result.stderr); - const report = JSON.parse(result.stdout); - assert.equal(report.ready, true); - assert.ok(report.nextSteps.includes("Codex CLI cannot parse the current models cache (schema drift). Upgrade the Codex CLI, then run /codex:setup again.")); -}); - -test("setup only treats a missing field as models cache drift when its log tail names codex_models_manager", (t) => { - const sandbox = makeSandbox(t); - const logFile = jobLogPath(sandbox.dataDir, sandbox.workDir, "models-cache-missing-field"); - const nextStep = "Codex CLI cannot parse the current models cache (schema drift). Upgrade the Codex CLI, then run /codex:setup again."; - fs.mkdirSync(path.dirname(logFile), { recursive: true }); - fs.writeFileSync(logFile, "error: missing field `models`\n"); - - const unrelated = runCompanion(["setup", "--json"], { cwd: sandbox.workDir, env: envFor(sandbox) }); - assert.equal(unrelated.status, 0, unrelated.stderr); - assert.ok(!JSON.parse(unrelated.stdout).nextSteps.includes(nextStep)); - - fs.writeFileSync(logFile, "error: missing field `models` in codex_models_manager\n"); - const related = runCompanion(["setup", "--json"], { cwd: sandbox.workDir, env: envFor(sandbox) }); - assert.equal(related.status, 0, related.stderr); - assert.ok(JSON.parse(related.stdout).nextSteps.includes(nextStep)); + assert.match(unsupportedReport.compatibility, /newer than the tested interval \(0\.145\.0 to before 0\.146\.0\)/); + assert.match(unsupportedReport.nextSteps.join("\n"), /A verification pass is advised\./); }); test("the companion module can be imported without executing its CLI", async () => { diff --git a/tests/codex-exec.test.mjs b/tests/codex-exec.test.mjs index aaead3d..42de1d5 100644 --- a/tests/codex-exec.test.mjs +++ b/tests/codex-exec.test.mjs @@ -136,12 +136,12 @@ async function waitForProcessExit(pid, ownsProcessGroup = false) { test("version and availability probe the configured Codex binary", () => { const env = { ...process.env, CODEX_BIN: fakeCodex }; - assert.equal(getCodexVersion({ env }), "0.144.4"); + assert.equal(getCodexVersion({ env }), "0.145.0"); assert.deepEqual(getCodexAvailability({ env }), { available: true, bin: fakeCodex, - version: "0.144.4", - rawVersion: "codex-cli 0.144.4", + version: "0.145.0", + rawVersion: "codex-cli 0.145.0", exitCode: 0, errorMessage: null }); @@ -278,6 +278,20 @@ test("task arguments leave model and effort unset while pinning networked tools assert.throws(() => buildTaskArgs({ network: true }), /requires a write task/); }); +test("task argv forwards an output schema and native review rejects one", () => { + const schema = "/tmp/codex-verdict.schema.json"; + const args = buildTaskArgs({ outputSchemaFile: schema }); + assert.deepEqual(args.slice(-2), ["--output-schema", schema]); + assert.ok(!buildTaskArgs().includes("--output-schema")); + assert.throws(() => buildReviewArgs({ outputSchemaFile: schema }), /review ignores --output-schema/); +}); + +test("task and review argv retain local rollout persistence", () => { + for (const args of [buildTaskArgs(), buildReviewArgs()]) { + assert.ok(!args.includes("--ephemeral")); + } +}); + test("native review is read-only and maps review targets", () => { assert.deepEqual(buildReviewArgs({ base: "main" }), [ "exec", diff --git a/tests/codex-render.test.mjs b/tests/codex-render.test.mjs index 832cbf7..c038e60 100644 --- a/tests/codex-render.test.mjs +++ b/tests/codex-render.test.mjs @@ -60,6 +60,13 @@ test("model drift is rendered in every terminal footer and never in a running fo assert.doesNotMatch(renderTerminalResult(record()), /warning: brief header names/); }); +test("a structured task result renders its parsing status in the footer", () => { + const request = { outputSchemaFile: "/tmp/verdict.schema.json" }; + assert.match(renderTerminalResult(record({ request, structuredOutput: { verdict: "pass" } })), /semantic: unverified\nstructured: parsed\nstate: done\n$/); + assert.match(renderTerminalResult(record({ request, structuredOutput: null, structuredOutputError: "Codex final response is not valid JSON." })), /semantic: unverified\nstructured: invalid\nstate: done\n$/); + assert.match(renderTerminalResult(record({ request })), /semantic: unverified\nstructured: unavailable\nstate: done\n$/); +}); + test("a cancelled result renders salvaged partial Codex output", () => { const rendered = renderTerminalResult(record({ partialResultText: "Recovered partial Codex output.", diff --git a/tests/fake-codex b/tests/fake-codex index 2da5aee..a6594d3 100755 --- a/tests/fake-codex +++ b/tests/fake-codex @@ -40,7 +40,7 @@ async function waitForFile(file) { } if (args.includes("--version")) { - process.stdout.write(`${process.env.FAKE_CODEX_VERSION_OUTPUT || `codex-cli ${process.env.FAKE_CODEX_VERSION || "0.144.4"}`}\n`); + process.stdout.write(`${process.env.FAKE_CODEX_VERSION_OUTPUT || `codex-cli ${process.env.FAKE_CODEX_VERSION || "0.145.0"}`}\n`); process.exit(0); } @@ -103,6 +103,9 @@ function start() { } function complete({ message = "Codex completed the task.", includeMessage = true } = {}) { + if (process.env.FAKE_CODEX_JSON_OBJECT_MESSAGE === "true") { + message = JSON.stringify({ verdict: "pass", findings: [] }); + } message = process.env.FAKE_CODEX_MESSAGE_BYTES ? "x".repeat(Number(process.env.FAKE_CODEX_MESSAGE_BYTES)) : message; if (includeMessage) { emit({ type: "item.completed", item: { id: "item_1", type: "agent_message", text: message } }); From d30227fd55490f13b92a385212244ca25316f7c3 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Wed, 22 Jul 2026 15:38:10 +0800 Subject: [PATCH 4/7] feat(grok): adapt to grok cli 0.2.110 and retire best-of-n roles --- CONTRIBUTING.md | 2 +- README.md | 17 ++- SECURITY.md | 4 +- docs/grok-contract.md | 21 ++-- plugins/fusion/rules-manifest.json | 1 + plugins/fusion/rules/orchestration.md | 8 +- plugins/fusion/skills/panel/SKILL.md | 2 +- plugins/fusion/skills/ultra/SKILL.md | 2 +- plugins/grok/.claude-plugin/plugin.json | 2 +- plugins/grok/agents/grok-rescue.md | 6 +- plugins/grok/scripts/grok-companion.mjs | 82 ++++++-------- plugins/grok/scripts/lib/grok-exec.mjs | 51 +++------ plugins/grok/scripts/lib/render.mjs | 3 +- plugins/grok/skills/best-of-n/SKILL.md | 28 ----- plugins/grok/skills/grok-cli-runtime/SKILL.md | 13 ++- plugins/grok/skills/grok-prompting/SKILL.md | 4 +- plugins/grok/skills/setup/SKILL.md | 5 +- plugins/grok/skills/task/SKILL.md | 10 +- tests/continuity-memory.test.mjs | 41 +------ tests/exec-args.test.mjs | 41 +------ tests/fake-grok | 8 ++ tests/grok-transport.test.mjs | 28 +---- tests/grok-upstream-contract.test.mjs | 101 ++++-------------- tests/history.test.mjs | 2 +- tests/lib/companion-harness.mjs | 2 - tests/routing-policy.test.mjs | 16 +-- tests/rules-sync.test.mjs | 14 +-- 27 files changed, 148 insertions(+), 366 deletions(-) delete mode 100644 plugins/grok/skills/best-of-n/SKILL.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f7f58c..c5578f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Any change to `plugins/fusion/rules/orchestration.md` or `plugins/fusion/rules/t 1. Diff every changed sentence against the previous release and justify each deletion or softening in the PR or release notes. Wording changes are behavior changes; a softer phrase can stop a lane from being chosen even when the surrounding code is untouched. 2. Check that the load bearing invariants survived the edit: - 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 five protected roles (`burst`, `independence`, `live-web`, `large-context`, `best-of-n`) or an explicit user choice; it is not a generic alternative merely because it is idle. + - 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. - 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. diff --git a/README.md b/README.md index 4726849..130fc1a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Multi-model orchestration for Claude Code. Your chosen Claude session model is t |---|---|---| | 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 | | 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 five protected roles: `burst`, `independence`, `live-web`, `large-context`, and `best-of-n`. 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. | +| 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. @@ -15,7 +15,7 @@ Direct `/codex:*` and `/grok:*` commands are user selected engine escape hatches ## Quick start -Prerequisites: Claude Code 2.1.198 or later and Node.js 22 or later (tested on Claude Code 2.1.211 and Node 22; the floors are not enforced), git, macOS or Linux, and both peer CLIs installed and authenticated on PATH (contract verified against codex-cli 0.144.4 and grok 0.2.101; the companions probe capabilities at launch rather than enforce version floors). Verify them with `codex --version` and `grok --version`. Claude workers and peer wrapper Agents use foreground delivery by default. The main Claude session remains the control plane and must collect or explicitly cancel any Agent that the runtime still launches asynchronously. Without one peer, Fusion still runs and routes eligible work to the remaining lanes. +Prerequisites: Claude Code 2.1.198 or later and Node.js 22 or later (tested on Claude Code 2.1.211 and Node 22; the floors are not enforced), git, macOS or Linux, and both peer CLIs installed and authenticated on PATH (contract verified against codex-cli 0.145.0 and grok 0.2.110; the companions probe capabilities at launch rather than enforce version floors). Verify them with `codex --version` and `grok --version`. Claude workers and peer wrapper Agents use foreground delivery by default. The main Claude session remains the control plane and must collect or explicitly cancel any Agent that the runtime still launches asynchronously. Without one peer, Fusion still runs and routes eligible work to the remaining lanes. If `codex@openai-codex` is installed, migrate before installing this marketplace's Codex plugin. In a session where the old plugin is still enabled, run `/codex:status --all`, use the old `/codex:result --wait` or `/codex:cancel ` for every authoritative running job, confirm that no result you need remains uncollected, then exit that session. Disable and uninstall the old plugin while preserving its data: @@ -24,7 +24,7 @@ claude plugin disable codex@openai-codex claude plugin uninstall codex@openai-codex --keep-data ``` -Never enable `codex@openai-codex` and `codex@claude-code-fusion` together because both register the same `codex` namespace. The preserved old state is ignored by Fusion stats and the breaker by default, so new reports cannot silently mix pre-migration activity with current data. `/fusion:stats --include-legacy` or `FUSION_CODEX_INCLUDE_LEGACY=1` enables explicit read only compatibility analysis without copying or modifying old records. Open a new session after the replacement is installed. +Never enable `codex@openai-codex` and `codex@claude-code-fusion` together because both register the same `codex` namespace. Open a new session after the replacement is installed. ```bash claude plugin marketplace add okisdev/claude-code-fusion @@ -44,13 +44,12 @@ Then, in a new session, run `/fusion:setup` once per machine (it writes the rout | `/codex:status [job-id]`, `/codex:result --wait`, `/codex:cancel ` | Lifecycle for explicitly detached Codex jobs. Status inspects progress, result collects the deliverable, and Fusion's optional monitor can notify completion. Fusion gives jobs detached by its own orchestration one same turn collection attempt of at most 540000ms; a timeout remains explicitly uncollected | | `/codex:history [--json]` | List canonical local Codex companion jobs across workspaces, including resumable thread ids, delivery state, semantic status, and observed model and effort. Codex exec threads remain persisted locally, but Codex Desktop does not guarantee that exec sourced threads appear in its sidebar | | `/codex:setup` | Codex CLI, authentication, adapter path, and compatibility health check | -| `/grok:task ` | Delegate directly to the user selected Grok lane. Consult, `--write`, and `--best-of-n` all use the strict sandbox; `--write` allows edits through its fixed tool policy, `--web` enables web search and fetch, and explicit `--background` returns a manual receipt for `/grok:status` and `/grok:result`. Without that flag, the Agent and companion stay foreground regardless of expected duration; a typed timeout is split or rerouted instead of being silently detached. Cross-session memory is off by default and can be enabled for an ordinary task with `--memory`; `--resume ` or `--resume-last` continues only a compatible companion-recorded strict task with the same mode and memory boundary; `--fresh` bypasses continuity and starts a new session. `--model`, `--effort`, and `--max-turns` forward explicit overrides. `--best-of-n` always starts fresh, keeps memory off, and runs a tournament on the same brief. | +| `/grok:task ` | Delegate directly to the user selected Grok lane. Consult and `--write` both use the strict sandbox; `--write` allows edits through its fixed tool policy, `--web` enables web search and fetch, and explicit `--background` returns a manual receipt for `/grok:status` and `/grok:result`. Without that flag, the Agent and companion stay foreground regardless of expected duration; a typed timeout is split or rerouted instead of being silently detached. Cross-session memory is off by default and can be enabled for an ordinary task with `--memory`; `--resume ` or `--resume-last` continues only a compatible companion-recorded strict task with the same mode and memory boundary; `--fresh` bypasses continuity and starts a new session. `--model`, `--effort`, and `--max-turns` forward explicit overrides. | | `/grok:review [--base ] [--focus ] [--cwd ] [--json]` | Read only adversarial review of the working tree or a branch range (untracked files reach the review as names only); the review run never edits, and acting on confirmed findings is a separate orchestrator decision after it returns | -| `/grok:best-of-n [--n ] ` | Implementation tournament in isolated worktrees; the winning candidate is applied. Keep n at 2; the companion rejects values outside 2 to 10 | | `/grok:status [job-id]`, `/grok:result [--wait]`, `/grok:cancel ` | Background job lifecycle. Each accepts `--cwd ` and `--json`; a successful JSON background launch preserves its task or review envelope through result collection, while cancellation, process death, and other failures return a stable typed failure envelope | | `/grok:history [--all] [--limit ] [--cwd ] [--json]` | List canonical local Grok companion jobs and resumable session identifiers. The default scope is the current workspace and 50 newest records; `--all` spans recorded workspaces and `--limit` accepts 1 to 500. The command exposes safe metadata, not native Grok conversation contents, briefs, results, or logs | | `/grok:stats [--all]` | Grok delegation history by status, mode, model, failure kind, and exact reported token totals with complete, incomplete, and unreported coverage | -| `/fusion:stats [--all]` | Delegation history across Claude workers and peer engines, including stable task identity, Agent identity, delivery state, transport status, semantic acceptance, actual model and effort when observable, and exact reported token coverage. Current Codex state is canonical by default; add `--include-legacy` only for an explicit migration comparison. `--audit [--days ]` summarizes the retained inline guard ledger | +| `/fusion:stats [--all]` | Delegation history across Claude workers and peer engines, including stable task identity, Agent identity, delivery state, transport status, semantic acceptance, actual model and effort when observable, and exact reported token coverage. `--audit [--days ]` summarizes the retained inline guard ledger | | `/grok:setup` | Health check; `--continuity manual` keeps explicit resume as the default, while `--continuity claude-session` enables automatic affinity for compatible ordinary direct tasks in the same Claude session and exact resolved working directory. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed. `--enable-stop-gate` / `--disable-stop-gate` toggles the stop-time review gate | | `/fusion:panel ` | Blind multi-model panel with attributed adjudication, for decisions where a wrong answer is expensive. Also fires from plain language ("help me decide", "compare these") | | `/fusion:ultra ` | Fans a large task out as a fleet while preserving lane ownership: Codex keeps the primary deep implementation seat and Grok supplies protected burst, research, large context, and independent breadth. It synthesizes and verifies the combined result, fires from "go deep", "audit everything", or "be exhaustive", and returns small tasks to ordinary routing. | @@ -68,11 +67,11 @@ Two layers with different strength, stated plainly. Enforced by companion runtimes (code, covered by the test suite): the Codex companion invokes `codex exec --json`, uses the read-only or workspace-write sandbox with `approval_policy="never"`, disables both Codex multi-agent feature generations, fails closed if a collaboration tool call is still observed, forces web search and workspace-write network access off by default, supervises the owned process group, and permits detachment only through an explicit `--background` already present in the incoming request. A tool that deliberately creates a new session or daemonizes leaves that portable containment boundary. `--web` opts into live search, while network access requires the separate `--write --network` combination. Fusion monitor and ordinary stats observation paths never signal recorded PIDs or repair canonical Codex records; the explicit stats prune command remains user-authorized cleanup. The Codex adapter does not impose a companion-owned shell command allow list and does not rewrite configured MCP servers or other Codex tools; those remain part of the user's Codex configuration trust boundary. Its exact runtime and durability rules are in [docs/codex-contract.md](docs/codex-contract.md). -Grok consult, write, review, and best-of-n all pin `--sandbox strict`. Consult overrides inherited approval bypass with `--permission-mode default` and hard filters built in tools to file read, list, and search, optionally adding web search and fetch. Every mode denies native questions, delegated search and tool execution, and MCP. 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. Write and best-of-n auto approve a fixed list containing read, grep, list, `search_replace`, and terminal tools; `search_replace` creates files, and no nonexistent `write` tool id is requested. Strict does not confine reads to the workspace or Grok home; its broader `system_read` roots are described below, and user toolchains outside those readable roots may still be unavailable. Its configuration requests child process network restriction. Linux enforces that request through seccomp, while macOS network blocking is currently a no-op and a write shell can still reach the network; `--web` controls only Grok's built in web tools. The companion never silently downgrades to workspace. +Grok consult, write, and review all pin `--sandbox strict`. Consult overrides inherited approval bypass with `--permission-mode default` and hard filters built in tools to file read, list, and search, optionally adding web search and fetch. Every mode denies native questions, delegated search and tool execution, and MCP. 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. Write auto approves a fixed list containing read, grep, list, `search_replace`, and terminal tools; `search_replace` creates files, and no nonexistent `write` tool id is requested. Strict does not confine reads to the workspace or Grok home; its broader `system_read` roots are described below, and user toolchains outside those readable roots may still be unavailable. Its configuration requests child process network restriction. Linux enforces that request through seccomp, while macOS network blocking is currently a no-op and a write shell can still reach the network; `--web` controls only Grok's built in web tools. The companion never silently downgrades to workspace. Before the companion sends a prompt, it requires a new `ProfileApplied` event from the shared `sandbox-events.jsonl` that contains this run's unique private `TMPDIR` and matches the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration; the event proves that the profile and configuration were applied, not that the platform actually blocked every network path. Upstream records have no run id or pid, so unrelated `ProfileApplied` and `ApplyFailed` events are not attributed to this run and failure events are auxiliary diagnostics only. A matching warning on the owned stderr stream, handshake timeout, missing or malformed matching evidence, shared-log rotation or disappearance, and matching field mismatches fail closed. Forced builder tracing starts only after Grok consumes stdin, so it is not a pre-prompt or pre-side-effect attestation. Fallback or unmatched warnings trigger early termination, and a nominally successful close is rejected without positive allowlist applied evidence. -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 disables subagents outside best-of-n, removes inherited Claude session and plugin variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, scrubs unrelated secret-bearing variables while preserving required xAI authentication, and disables automatic updates with `--no-auto-update`. Cross-session memory is force-disabled for every run except an ordinary task with explicit `--memory`; review, stop gate, and best-of-n always keep it off. When enabled, upstream may inject relevant Grok memory into the model context sent to xAI. Upstream automatic saving usually requires at least three real user prompts in the same resumed session, so one Fusion task usually only reads existing memory and does not guarantee that new memory is saved. The write tool and deny rules reduce exposure but are not complete confinement. Command-pattern denies for common direct grok, claude, and codex calls can be bypassed with absolute paths, aliases or functions, and indirect scripts; a hard boundary requires removing `run_terminal_cmd` or applying an OS-level executable or network policy. Run write delegations on a clean branch or an orchestrator-created disposable worktree and launch Grok with that worktree as its actual cwd. Best-of-n alone adds native Agent, always starts fresh, and keeps memory off, with its background wait bounded below the companion deadline. Prompts reach headless Grok through `/dev/stdin`, and stdout is parsed from a 0600 file that is unlinked immediately after open. The capability preflight checks the universal safety flags and the exact consult, write, review, no-web, ordinary-wait, and tournament surfaces before each applicable run; `/grok:setup` reports the complete surface. Upstream minimum-version enforcement can still force an update and remains a residual boundary. `--web` deliberately uses Grok's built in network tools and may transmit inspected content to xAI. Continuity defaults to manual; optional `claude-session` affinity applies only to compatible ordinary direct tasks. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed. The optional stop gate runs from the Stop hook, not SessionEnd, and parses its first nonempty reply line: `ALLOW` allows the stop, while `BLOCK: ` blocks it. A `BLOCK` after a preamble and every infrastructure failure fail open. SessionEnd attempts verified process cleanup and removes unused raw transports, but it does not delete the terminal companion ledger. Grok permission matcher notes are in [docs/grok-contract.md](docs/grok-contract.md), and the shared outcome rules are in [docs/companion-contract.md](docs/companion-contract.md). +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 disables subagents, removes inherited Claude session and plugin variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, scrubs unrelated secret-bearing variables while preserving required xAI authentication, and disables automatic updates with `--no-auto-update`. Cross-session memory is force-disabled for every run except an ordinary task with explicit `--memory`; review and stop gate always keep it off. When enabled, upstream may inject relevant Grok memory into the model context sent to xAI. Upstream automatic saving usually requires at least three real user prompts in the same resumed session, so one Fusion task usually only reads existing memory and does not guarantee that new memory is saved. The write tool and deny rules reduce exposure but are not complete confinement. Command-pattern denies for common direct grok, claude, and codex calls can be bypassed with absolute paths, aliases or functions, and indirect scripts; a hard boundary requires removing `run_terminal_cmd` or applying an OS-level executable or network policy. Run write delegations on a clean branch or an orchestrator-created disposable worktree and launch Grok with that worktree as its actual cwd. Prompts reach headless Grok through `/dev/stdin`, and stdout is parsed from a 0600 file that is unlinked immediately after open. The capability preflight checks the universal safety flags and the exact consult, write, review, no-web, and ordinary-wait surfaces before each applicable run; `/grok:setup` reports the complete surface. Upstream minimum-version enforcement can still force an update and remains a residual boundary. `--web` deliberately uses Grok's built in network tools and may transmit inspected content to xAI. Continuity defaults to manual; optional `claude-session` affinity applies only to compatible ordinary direct tasks. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed. The optional stop gate runs from the Stop hook, not SessionEnd, and parses its first nonempty reply line: `ALLOW` allows the stop, while `BLOCK: ` blocks it. A `BLOCK` after a preamble and every infrastructure failure fail open. SessionEnd attempts verified process cleanup and removes unused raw transports, but it does not delete the terminal companion ledger. Grok permission matcher notes are in [docs/grok-contract.md](docs/grok-contract.md), and the shared outcome rules are in [docs/companion-contract.md](docs/companion-contract.md). Upstream strict has broader read and write surfaces than the workspace and adapter-private run directory. Linux `system_read` includes `/var` and `/tmp`; macOS includes `/private` and the entire `~/Library`, so shared temporary content and substantial user application configuration and caches may be readable. Strict always permits writes to `/tmp` and `/var/tmp`; macOS also permits `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`. The entire Grok home is read-write. The sandbox permits consult `read_file` to reach `~/.grok/auth.json`, config, and sessions, and write terminal commands can receive preserved xAI authentication variables. The companion adds best-effort Read denies for `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` under the Grok home through absolute paths and standard `**/.grok` patterns, but raw path variants, symbolic links, and shell or indirect scripts can bypass those tool-level rules. Those Read denies govern model tool calls only. They do not block upstream's internal first-turn memory search and injection when the user explicitly supplies `--memory`. Environment scrubbing and path-pattern denies do not isolate credentials or memory content. Model-facing MCP and meta-tool denies do not prove that native MCP servers, plugins, or hooks configured under `~/.grok` did not start during agent construction or had no side effects; those bridge variables do not disable native Grok configuration. Narrower exposure requires a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization. @@ -92,7 +91,7 @@ No results are published yet. The task suite currently holds one of the planned ## Data and uninstall -The Codex plugin keeps job records, prompts, logs, event ledgers, and stored results under `~/.claude/plugins/data/codex-claude-code-fusion/`; prompts and logs can contain task text, diffs, command output, and diagnostics. Managed terminal history defaults to 256 records and 512 MiB, while running jobs, resume safety records, and uncollected background results are protected even when they temporarily exceed that quota. Unknown, corrupt, future-schema, and crash-orphaned content is left untouched rather than counted as safely managed history. Private raw-command staging is removed at SessionEnd for the matching session or after one hour during a later transport allocation. A preserved `~/.claude/plugins/data/codex-openai-codex/` root is read only compatibility history that current reporting ignores unless explicitly enabled; the new plugin cannot collect its deliverables. +The Codex plugin keeps job records, prompts, logs, event ledgers, and stored results under `~/.claude/plugins/data/codex-claude-code-fusion/`; prompts and logs can contain task text, diffs, command output, and diagnostics. Managed terminal history defaults to 256 records and 512 MiB, while running jobs, resume safety records, and uncollected background results are protected even when they temporarily exceed that quota. Unknown, corrupt, future-schema, and crash-orphaned content is left untouched rather than counted as safely managed history. Private raw-command staging is removed at SessionEnd for the matching session or after one hour during a later transport allocation. The Grok plugin keeps its companion ledger, private job records, briefs, and logs under `~/.claude/plugins/data/grok-claude-code-fusion/`; briefs can contain prompts and diffs, logs can contain Grok stderr, and new records retain the CLI reported `usage` and `model_usage` objects. `/grok:history` reads a safe metadata projection from this ledger. The companion does not promise automatic garbage collection for terminal records, briefs, or logs, so clearing this history requires manually deleting that data directory after no running job, uncollected result, or resume evidence is needed. Native Grok conversations are separate under `~/.grok/sessions`; upstream retains them for 30 days by default. `~/.grok/config.toml` accepts a positive integer at `[storage] cleanup_ttl_days`; `0` falls back to the default 30 days. Cross-session memory is separate again under `~/.grok/memory` and can be read or written by upstream only when memory is enabled. Deleting the companion ledger does not delete native sessions or memory, and deleting native sessions does not clear companion history. A `model_usage` row contains input, output, cache-read, and model-call counts plus optional cost, so reporting never invents reasoning or total token channels. `usage_is_incomplete` means the usage ledger may have missed open subagents, usage application, or a drain timeout; present tokens are observed lower bounds and both job-total token and cost coverage are incomplete. Exact positive cost pairs use integer ticks at 10000000000 ticks per USD as authoritative. Grok raw-command staging uses one-time private directories under validated system temporary storage and is consumed before parsing or pruned after one hour. Its headless stdout uses a separate 0600 file that is unlinked as soon as it is opened. diff --git a/SECURITY.md b/SECURITY.md index 06b59e2..ea17cc7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,10 +1,10 @@ # Security -The Grok plugin executes the local Grok CLI with your xAI credentials. Consult, write, review, and best-of-n all pin `--sandbox strict`; the companion never silently downgrades to workspace. Strict does not confine reads to the workspace or Grok home; its broader `system_read` roots are described below, while user toolchains outside those readable roots may still be unavailable. Its configuration requests child process network restriction, which Linux enforces through seccomp while macOS currently treats network blocking as a no-op; a write shell can therefore reach the network on macOS, and `--web` controls only Grok's built in web tools. Consult overrides inherited approval bypass, exposes only file read, list, and search tools plus explicitly requested web search and fetch, and denies shell, edits, native questions, delegated search and tools, MCP, and Agent. Write and best-of-n auto approve a fixed list containing read, grep, list, `search_replace`, and terminal tools; `search_replace` is the file creation path, and no nonexistent `write` tool id is requested. 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. Best-of-n alone retains Agent with a bounded native background wait. +The Grok plugin executes the local Grok CLI with your xAI credentials. Consult, write, and review all pin `--sandbox strict`; the companion never silently downgrades to workspace. Strict does not confine reads to the workspace or Grok home; its broader `system_read` roots are described below, while user toolchains outside those readable roots may still be unavailable. Its configuration requests child process network restriction, which Linux enforces through seccomp while macOS currently treats network blocking as a no-op; a write shell can therefore reach the network on macOS, and `--web` controls only Grok's built in web tools. Consult overrides inherited approval bypass, exposes only file read, list, and search tools plus explicitly requested web search and fetch, and denies shell, edits, native questions, delegated search and tools, MCP, and Agent. Write auto approves a fixed list containing read, grep, list, `search_replace`, and terminal tools; `search_replace` is the file creation path, and no nonexistent `write` tool id is requested. 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. Before sending the prompt, the companion requires a new `ProfileApplied` event from the shared `sandbox-events.jsonl` that contains this run's private temporary path and matches the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. The event proves that the profile and configuration were applied and marked enforced, not that the platform blocked every network path. Upstream records have no run id or pid, so unrelated `ProfileApplied` and `ApplyFailed` events are not attributed to the current run and failure events are auxiliary diagnostics only. A matching warning on the owned stderr stream, handshake timeout, missing or malformed matching evidence, shared-log rotation or disappearance, and matching field mismatches fail closed. Forced builder tracing initializes only after Grok consumes stdin, so it is not a pre-prompt or pre-side-effect attestation. Fallback or unmatched warnings trigger early termination, and a successful close is rejected without positive tool allowlist evidence. -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 disables subagents outside best-of-n, disables automatic updates through `--no-auto-update`, removes inherited Claude session and plugin variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, and broadly scrubs secret-bearing variables while preserving required xAI authentication. Upstream minimum-version enforcement can still force an update. Memory is force-disabled unless the user explicitly passes `--memory` to an ordinary task. Review, stop gate, and best-of-n always keep memory off. A memory-enabled task allows upstream to inject selected global or workspace memory into the model context sent to xAI and to update memory according to the user's Grok configuration. Upstream automatic saving usually requires at least three real user prompts in the same resumed session, so one Fusion task usually only reads existing memory and does not guarantee that new memory is saved. Continuity defaults to manual. Optional `claude-session` affinity resumes only a compatible completed ordinary direct task from the same Claude session, exact resolved working directory, mode, strict profile, and memory boundary. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed; `--fresh` disables affinity for one task. Write and best-of-n include terminal commands, so treat them as giving Grok your own shell inside the strict workspace view and run them on a clean branch or an orchestrator-created disposable worktree. Command-pattern denies for common direct grok, claude, and codex calls do not cover absolute paths, aliases or functions, or indirect scripts; a hard nested-execution boundary requires removing `run_terminal_cmd` or applying an OS-level executable or network policy. Headless stdout is captured through a 0600 file that is unlinked immediately after open. A resumable Grok task keeps its strict profile, mode, and memory boundary for its lifetime, so incompatible resumes fail closed. Job briefs and logs are stored in plain text under `~/.claude/plugins/data/grok-claude-code-fusion/`. The routing policy, panel blindness, and circuit breaker are prompt level conventions, not runtime enforcement; see the Safety model section of the README for the full split between what code enforces and what prompts request. +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 disables subagents, disables automatic updates through `--no-auto-update`, removes inherited Claude session and plugin variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, and broadly scrubs secret-bearing variables while preserving required xAI authentication. Upstream minimum-version enforcement can still force an update. Memory is force-disabled unless the user explicitly passes `--memory` to an ordinary task. Review and stop gate always keep memory off. A memory-enabled task allows upstream to inject selected global or workspace memory into the model context sent to xAI and to update memory according to the user's Grok configuration. Upstream automatic saving usually requires at least three real user prompts in the same resumed session, so one Fusion task usually only reads existing memory and does not guarantee that new memory is saved. Continuity defaults to manual. Optional `claude-session` affinity resumes only a compatible completed ordinary direct task from the same Claude session, exact resolved working directory, mode, strict profile, and memory boundary. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed; `--fresh` disables affinity for one task. Write includes terminal commands, so treat it as giving Grok your own shell inside the strict workspace view and run it on a clean branch or an orchestrator-created disposable worktree. Command-pattern denies for common direct grok, claude, and codex calls do not cover absolute paths, aliases or functions, or indirect scripts; a hard nested-execution boundary requires removing `run_terminal_cmd` or applying an OS-level executable or network policy. Headless stdout is captured through a 0600 file that is unlinked immediately after open. A resumable Grok task keeps its strict profile, mode, and memory boundary for its lifetime, so incompatible resumes fail closed. Job briefs and logs are stored in plain text under `~/.claude/plugins/data/grok-claude-code-fusion/`. The routing policy, panel blindness, and circuit breaker are prompt level conventions, not runtime enforcement; see the Safety model section of the README for the full split between what code enforces and what prompts request. Upstream strict's `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS. Shared temporary content and substantial user application configuration and caches may therefore be readable. Strict always permits writes to `/tmp` and `/var/tmp`; macOS also permits `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`. The adapter's unique private `TMPDIR` is preferred and handshake-verified, not the only writable temporary surface. On Linux, a strict profile with deny paths re-executes under bubblewrap and refuses to start when `bwrap` is missing or unusable; macOS has no equivalent hard enforcement and relies on Seatbelt application plus best-effort tool denies. The entire Grok home is read-write. The sandbox permits consult `read_file` to reach `~/.grok/auth.json`, config, and sessions, while write terminal commands can receive preserved xAI authentication variables. The companion applies best-effort Read denies to `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` under the Grok home with absolute paths and standard `**/.grok` patterns, but raw path variants, symbolic links, and shell or indirect scripts can bypass those tool-level rules. These Read denies apply to model tool calls. They do not prevent upstream's internal first-turn memory search and injection when `--memory` is enabled, so memory snippets can enter the xAI request even though direct model reads of `~/.grok/memory/**` are denied. Environment scrubbing and path-pattern denies do not isolate credentials or memory content. Model-facing MCP and meta-tool denies do not prove that native MCP servers, plugins, or hooks configured under `~/.grok` did not start during agent construction or had no side effects; those bridge variables disable compatibility imports, not native Grok configuration. Narrower exposure requires a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization. diff --git a/docs/grok-contract.md b/docs/grok-contract.md index 1e06dc9..71995ca 100644 --- a/docs/grok-contract.md +++ b/docs/grok-contract.md @@ -21,11 +21,11 @@ Job statuses, liveness, rendered outcome footers, failure kind definitions, back ## Session threading -- Capture a syntactically valid UUID `sessionId` from the JSON output and resume an ordinary task with `-r `. Invalid session identifiers are never persisted, used as paths, or accepted for resume. The `-s` flag does not upsert named sessions because each run with an unknown name silently creates a fresh uuid session. The companion resumes only a uuid owned by a terminal ordinary task in the exact workspace ledger. Review, stop gate, and best-of-n sessions are not resumable companion tasks. Resume requires the same task mode, `strict` sandbox profile, and memory boundary as the source. Legacy `workspace` or unknown profiles, a consult to write mode change, a memory-enabled task resumed without `--memory`, a memory-disabled task resumed with `--memory`, and inconsistent memory evidence all fail closed. `--resume-last` selects the newest compatible terminal task and requires ownership by the current Claude session when one is available. Before Grok starts, the companion atomically claims a workspace session lease under the existing record lock. A live owner or surviving process group rejects a concurrent resume with failure kind `resource`; an owner whose recorded leaders and process groups are gone is repaired to `died`, while a terminal owner permits the next task to replace the lease. For a short cwd, resolved model and effort metadata reads only the exact URL-encoded cwd directory. For a long cwd, it searches bounded session directories by session id but accepts only an exact cwd recovered by decoding the directory name or reading `.cwd`. A single session-id candidate from another cwd is never a fallback. +- Capture a syntactically valid UUID `sessionId` from the JSON output and resume an ordinary task with `-r `. Invalid session identifiers are never persisted, used as paths, or accepted for resume. The `-s` flag does not upsert named sessions because each run with an unknown name silently creates a fresh uuid session. The companion resumes only a uuid owned by a terminal ordinary task in the exact workspace ledger. Review and stop gate sessions are not resumable companion tasks. Resume requires the same task mode, `strict` sandbox profile, and memory boundary as the source. Legacy `workspace` or unknown profiles, a consult to write mode change, a memory-enabled task resumed without `--memory`, a memory-disabled task resumed with `--memory`, and inconsistent memory evidence all fail closed. `--resume-last` selects the newest compatible terminal task and requires ownership by the current Claude session when one is available. Before Grok starts, the companion atomically claims a workspace session lease under the existing record lock. A live owner or surviving process group rejects a concurrent resume with failure kind `resource`; an owner whose recorded leaders and process groups are gone is repaired to `died`, while a terminal owner permits the next task to replace the lease. For a short cwd, resolved model and effort metadata reads only the exact URL-encoded cwd directory. For a long cwd, it searches bounded session directories by session id but accepts only an exact cwd recovered by decoding the directory name or reading `.cwd`. A single session-id candidate from another cwd is never a fallback. ## Memory and continuity -- Cross-session memory is off by default. Only an ordinary `task` accepts explicit `--memory`; it sets `GROK_MEMORY=1` for that child, while every other task sets `GROK_MEMORY=0`. Review, stop gate, and best-of-n always force memory off. Best-of-n rejects `--memory`, `--resume`, and `--resume-last` before creating a job because a tournament always starts fresh. +- Cross-session memory is off by default. Only an ordinary `task` accepts explicit `--memory`; it sets `GROK_MEMORY=1` for that child, while every other task sets `GROK_MEMORY=0`. Review and stop gate always force memory off. - Published Grok headless source parses `--experimental-memory` and `--no-memory` in the top-level CLI but does not carry those fields into its single-turn `HeadlessOptions`; the single-turn resolver hardcodes both CLI memory switches to false. The companion therefore controls the effective behavior through `GROK_MEMORY` and overwrites any inherited value. The upstream config resolver then applies that environment value ahead of `[memory]` and remote settings. Memory subconfiguration such as search, initial injection, session save, watcher, and dream settings still comes from the user's Grok configuration when memory is enabled. - Memory is not equivalent to complete conversation resume. When enabled, upstream can perform a first-turn search across global and workspace memory and inject selected snippets into the model context sent to xAI. It can also save or consolidate memory according to the user's Grok configuration. Upstream automatic saving normally requires at least three real user prompts in the same resumed session and at least 50 bytes of combined prompt content. A single Fusion task therefore usually only reads existing memory and does not guarantee that new memory is saved. This internal search and injection happens below the model tool layer, so the companion's Read denies for `~/.grok/memory/**` do not block it. Worktrees or clones that upstream identifies as the same repository can share workspace memory. - Continuity policy defaults to `manual`, where only `--resume` and `--resume-last` attach an earlier session. `setup --continuity claude-session` persists automatic affinity for an ordinary task with a prompt when no explicit resume or `--fresh` is present. The affinity source must be the newest `done` ordinary task from the same Claude session, exact resolved cwd, task mode, `strict` profile, and memory boundary. A Fusion routed brief identified by `fusion-brief: v1`, `grok-role:`, or `lane: panel` in its leading header does not receive automatic affinity and stays fresh unless explicitly resumed. `--fresh` disables affinity for one task and cannot be combined with either resume option. `GROK_COMPANION_CONTINUITY_POLICY=manual|claude-session` overrides the persisted policy. @@ -37,23 +37,23 @@ Job statuses, liveness, rendered outcome footers, failure kind definitions, back ## Permissions -- Consult mode pins `--sandbox strict --permission-mode default` and passes `--tools read_file,grep,list_dir`. `--web` extends that hard built in tool filter with `web_search,web_fetch`; without it, `--disable-web-search` is required. Write mode and best-of-n also pin `--sandbox strict`; they auto approve a fixed allowlist containing `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`, plus direct web tools only when requested and Agent only for best-of-n. Grok has no `write` tool id; `search_replace` creates files as well as updating them. Explicit denies reject Edit, Write, and Bash in consult and reject `search_tool`, `use_tool`, `ask_user_question`, and all MCP tools in every mode. Every non-best-of-n call also passes bare `--disallowed-tools Agent`, sets `GROK_SUBAGENTS=0`, and retains `--no-subagents` only as a compatibility hint. `GROK_MEMORY=0` applies unless an ordinary task explicitly enables memory, when that task receives `GROK_MEMORY=1`. 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 single-turn resolver also does not forward `--no-ask-user`, `--no-plan`, `--experimental-memory`, `--no-memory`, or `--worktree-ref`. -- The open-source snapshot is per-crate versioned, unversioned as a release train with no tags, and can lag the installed binary, whose stable channel ships weekly. The companion probes the installed binary instead of trusting semantic version or the published source snapshot. Every managed run requires `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`. Consult additionally requires `--permission-mode` and `--allow`; no-web runs require `--disable-web-search`; write requires `--always-approve`; review requires `--json-schema`; best-of-n requires `--best-of-n` and `--background-wait-timeout`; ordinary calls require `--no-wait-for-background` or the bounded wait flag. Required flags must appear in the effective capability surface before launch. The companion never silently drops a requested mode or downgrades strict to workspace. +- Consult mode pins `--sandbox strict --permission-mode default` and passes `--tools read_file,grep,list_dir`. `--web` extends that hard built in tool filter with `web_search,web_fetch`; without it, `--disable-web-search` is required. Write mode also pins `--sandbox strict` and auto approves a fixed allowlist containing `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`, plus direct web tools only when requested. Grok has no `write` tool id; `search_replace` creates files as well as updating them. Explicit denies reject Edit, Write, and Bash in consult and reject `search_tool`, `use_tool`, `ask_user_question`, and all MCP tools in every mode. Every call also passes bare `--disallowed-tools Agent`, sets `GROK_SUBAGENTS=0`, and retains `--no-subagents` only as a compatibility hint. `GROK_MEMORY=0` applies unless an ordinary task explicitly enables memory, when that task receives `GROK_MEMORY=1`. 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 single-turn resolver also does not forward `--no-ask-user`, `--no-plan`, `--experimental-memory`, `--no-memory`, or `--worktree-ref`. +- The open-source snapshot is per-crate versioned, unversioned as a release train with no tags, and can lag the installed binary, whose stable channel ships weekly. The companion probes the installed binary instead of trusting semantic version or the published source snapshot. Every managed run requires `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, `--no-auto-update`, and `--no-wait-for-background`. Consult additionally requires `--permission-mode` and `--allow`; no-web runs require `--disable-web-search`; write requires `--always-approve`; review requires `--json-schema`. Required flags must appear in the effective capability surface before launch. The companion never silently drops a requested mode or downgrades strict to workspace. - Every run forces `RUST_LOG=xai_grok_agent::builder=debug,xai_grok_sandbox=warn`. The `xai_grok_sandbox=warn` directive carries upstream sandbox failure warnings to the owned stderr stream. Upstream initializes builder tracing only after it has consumed the complete stdin prompt, so tool policy cannot be attested before prompt delivery or before every possible side effect. A fallback or unmatched warning triggers immediate verified termination as soon as it appears, and a nominally successful close is rejected unless positive `tools allowlist applied` evidence was observed. An unmappable tool id makes Grok retain the full tool surface, and an unmatched disallowed rule may hide a misspelled boundary. Consult retains independent explicit Bash, Edit, Write, MCP, Agent, and meta-tool denies; write is already a user-authorized modification surface. - 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_CODE_*` and `CLAUDE_PLUGIN_*` variables, deletes `_GROK_CLAUDE_MARKER_OVERRIDE`, and broadly scrubs secret-bearing environment variables except the xAI authentication material required by Grok. Automatic updates are disabled with `--no-auto-update`; upstream minimum-version enforcement can still force an update and remains a residual execution and network boundary. -- Strict's upstream `system_read` surface is broader than the workspace and Grok home. Linux includes `/var` and `/tmp`; macOS includes `/private` and additionally exposes the entire `~/Library`, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and the entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`; the companion adds its unique run `TMPDIR` and verifies that it appears in the applied profile. The private directory is the adapter's preferred temporary path, not the only writable temporary surface. On Linux, a strict profile with deny paths re-executes under bubblewrap and refuses to start when `bwrap` is missing or unusable; macOS has no equivalent hard enforcement and relies on Seatbelt application plus best-effort tool denies. User toolchains outside strict's readable paths may be unavailable in write and best-of-n runs. Strict requests child process network restriction. Linux enforces it through seccomp, while the current macOS network blocker is a no-op, so write and best-of-n shell commands can still reach the network there. `--web` controls only Grok's built in web tools and may transmit inspected content to xAI services; it is not a shell network gate. The companion never retries under workspace or treats a missing host tool as permission to weaken the profile. +- Strict's upstream `system_read` surface is broader than the workspace and Grok home. Linux includes `/var` and `/tmp`; macOS includes `/private` and additionally exposes the entire `~/Library`, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and the entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`; the companion adds its unique run `TMPDIR` and verifies that it appears in the applied profile. The private directory is the adapter's preferred temporary path, not the only writable temporary surface. On Linux, a strict profile with deny paths re-executes under bubblewrap and refuses to start when `bwrap` is missing or unusable; macOS has no equivalent hard enforcement and relies on Seatbelt application plus best-effort tool denies. User toolchains outside strict's readable paths may be unavailable in write runs. Strict requests child process network restriction. Linux enforces it through seccomp, while the current macOS network blocker is a no-op, so write shell commands can still reach the network there. `--web` controls only Grok's built in web tools and may transmit inspected content to xAI services; it is not a shell network gate. The companion never retries under workspace or treats a missing host tool as permission to weaken the profile. - The entire Grok home is an upstream trust boundary, not private adapter state. The sandbox permits consult's `read_file` tool to reach files such as `~/.grok/auth.json`, configuration, and sessions, while write's terminal can receive the retained xAI authentication environment. The companion layers best-effort Read denies over `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` under the Grok home, using both absolute paths and standard `**/.grok` patterns. Raw path variants, symbolic links, and shell or indirect scripts can bypass those tool-level patterns, so they are exposure reduction rather than credential isolation. The Read denies apply to model tools and do not block upstream's internal first-turn memory injection when `--memory` is enabled. Scrubbing unrelated environment secrets does not isolate filesystem credentials, opted-in memory content, or the authentication Grok itself needs. Grok's builder specially preserves MCP meta-tools; the explicit `search_tool`, `use_tool`, and MCP denies block model invocation but cannot prove that native MCP servers, plugins, or hooks configured under `~/.grok` did not start during agent construction or had no side effects. Those bridge variables disable compatibility bridges, not native Grok configuration. Narrower exposure requires a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization; the current companion does not claim a hard path boundary. - Before every managed launch, the companion creates a unique private `TMPDIR`, snapshots Grok's shared `sandbox-events.jsonl`, starts the child, and withholds the prompt until a new `ProfileApplied` event containing that private path also proves the expected canonical workspace, `strict` profile, enforced state, and requested `restrict_network: true` configuration. Upstream events have no run id or pid, so the unique `TMPDIR` is the correlation key for positive evidence; unrelated `ProfileApplied` and `ApplyFailed` records are not attributed to the current run, and failure records remain auxiliary diagnostics. The matching event proves that the profile and configuration were applied and marked enforced, not that the platform blocked every network path. Upstream appends to `~/.grok/sandbox-events.jsonl` with no rotation or size cap, so the file grows unbounded; the companion's rotation and truncation detection therefore signals external interference, not normal upstream behavior. A matching warning on the owned stderr stream, a handshake timeout, missing or malformed matching evidence, shared-log disappearance or rotation, or any matching field mismatch terminates the owned process and fails closed as `sandbox`. Stderr can fail the owned handshake but cannot prove successful enforcement. - Upstream wires only `bypassPermissions` at spawn; the remaining `--permission-mode` values are forward-compatibility placeholders. The companion still passes `default` in headless consult runs, where a call that would prompt is cancelled, but consult isolation rests on the hard `--tools` filter and deny rules rather than permission mode. - `GROK_CONSULT_ALLOW` is retained only for read, grep, web search, and web fetch permission patterns. Shell, edit, MCP, and Agent rules are discarded, and the variable cannot add a tool omitted by the hard `--tools` filter. -- Write mode: `--sandbox strict --always-approve` with the fixed tool allowlist above and deny rules for sudo, `rm -rf`, `git push`, `gh`, common direct grok, claude, and codex invocations, `search_tool`, `use_tool`, `ask_user_question`, Agent, and all MCP tools. The nested CLI command patterns do not cover absolute executable paths, aliases or functions, or indirect scripts. Because `run_terminal_cmd` remains available, those patterns reduce accidental recursion but are not a hard execution boundary; hard prevention requires removing `run_terminal_cmd` or an OS-level executable or network policy. Best-of-n uses the same strict profile and is the only Agent exception because its tournament is implemented through native subagents. +- Write mode: `--sandbox strict --always-approve` with the fixed tool allowlist above and deny rules for sudo, `rm -rf`, `git push`, `gh`, common direct grok, claude, and codex invocations, `search_tool`, `use_tool`, `ask_user_question`, Agent, and all MCP tools. The nested CLI command patterns do not cover absolute executable paths, aliases or functions, or indirect scripts. Because `run_terminal_cmd` remains available, those patterns reduce accidental recursion but are not a hard execution boundary; hard prevention requires removing `run_terminal_cmd` or an OS-level executable or network policy. - When a consult mode turn calls a tool outside the hard set, the filter or headless permission policy cancels the call. A turn ending with exit 0 and `stopReason: "Cancelled"` remains a shared `permission` failure rather than a misleading success. -- `--no-subagents` stays on every ordinary call for compatibility, but the hard Agent tool deny and `GROK_SUBAGENTS=0` are the headless controls because the single-turn and agent resolvers do not forward that parsed flag, while the interactive TUI does apply it. Denying Agent also removes native task, output, kill, and wait tools and disables terminal auto-background behavior. Best-of-n runs are the exception because the tournament needs subagents. Web tools are disabled by default (`--disable-web-search`); `--web` adds only the two direct web tools to the consult filter. +- `--no-subagents` stays on every call for compatibility, but the hard Agent tool deny and `GROK_SUBAGENTS=0` are the headless controls because the single-turn and agent resolvers do not forward that parsed flag, while the interactive TUI does apply it. Denying Agent also removes native task, output, kill, and wait tools and disables terminal auto-background behavior. Web tools are disabled by default (`--disable-web-search`); `--web` adds only the two direct web tools to the consult filter. ## Process lifecycle - Grok is spawned in its own supervised process group, and the child PID is recorded on the job record as `grokPid`. This process boundary is not a user background job. Tasks and their wrapper Agents remain foreground unless the incoming user request explicitly includes `--background`; task size and expected duration never authorize implicit detachment. An explicit task background worker records `delivery: manual`. The dedicated review runner is the managed exception: it sets `GROK_COMPANION_BACKGROUND_DELIVERY=managed`, owns the review receipt, and collects the terminal result before returning. Terminal result collection records `deliveryCollectedAt`. The monitor suppresses collected managed jobs and gives an uncollected managed job a 60 second owner grace before emitting a fallback notification. Text background launch footers end with `job: `, `delivery: managed` or `delivery: manual`, and `state: running`; forwarders use `delivery:` as the mechanical collection discriminator. Worker and Grok launches check that the record is still running with no cancellation or pending cleanup, spawn the process, and publish the role-specific PID and identity while holding the same record lock. The record stores `pidIdentity` for the driving companion or worker and `grokPidIdentity` for the Grok CLI child. Timeout signals use the live child handle owned by the current process. Cancel, SessionEnd cleanup, and died repair verify persisted process identity before signalling a recorded process group. PID 1 is never a signal or liveness target, and an identity mismatch is treated as PID reuse without signalling the current process. -- Grok's own background tool waiting is separate from companion detachment. Upstream headless waits up to 600 seconds after the first turn for Bash, monitor, or subagent work unless configured otherwise, which exceeds the companion's 570 second foreground budget. Ordinary non-best-of-n calls prefer `--no-wait-for-background` and use an explicit bounded `--background-wait-timeout` only as a compatibility fallback; best-of-n keeps Agent and requires the bounded timeout below the outer companion deadline. That native wait never creates a companion receipt. +- Grok's own background tool waiting is separate from companion detachment. The companion passes `--no-wait-for-background` for every managed run. That native wait never creates a companion receipt. - Timeouts: foreground runs default to 570000ms (deliberately below the 600000ms Bash timeout the forwarder agent uses, so the companion always reaps first and writes the record); background workers cap at 1800000ms; the stop gate caps its grok call at 240000ms inside the hook's 900 second budget; the `/grok:review` background flow overrides the foreground default with `GROK_COMPANION_TIMEOUT_MS=1800000`. Identity verified cleanup sends SIGTERM, polls for up to 2000ms, then escalates to SIGKILL when the owned process group is still alive. A fresh child owned by the current process may use the same escalation when persistent identity capture is unavailable; a legacy persisted record without identity receives at most SIGTERM. A terminal outcome is written only after the selected process groups are verifiably gone or their identities prove that the PIDs were reused. Unverified cleanup keeps the record `running`, sets `cleanupRequired: true`, retains both PIDs and identities, and returns a failure so a later status, result, or cancel can retry. Exit codes: 0 ok, 1 error, 130 SIGINT, 137 SIGKILL, 143 SIGTERM, and other signals map to 128 plus the signal number. Interrupted runs keep their file modifications and are resumable via the session uuid when Grok reported one before the interruption; a run killed before emitting its JSON envelope leaves no session id on the record. - Grok uses `pid` for the driving worker PID and `grokPid` for the Grok CLI child PID. The pidless launcher grace window defaults to 15000ms and can be overridden with `GROK_COMPANION_PIDLESS_RUNNING_GRACE_MS`. - Job ids contain 128 random bits and render as 32 lowercase hexadecimal characters. Initial job records and briefs use exclusive creation so a collision cannot overwrite an earlier job. Job record writes use token scoped symbolic link locks backed by immutable owner records containing the process boot, start, and command identity. Dead or PID reused owners are reclaimed only after a single reaper claim; a dead reaper can be succeeded, release is token conditional, and unreadable ownership fails closed. Prepared and released owner directories are scavenged without deleting live waiters. @@ -72,11 +72,12 @@ Job statuses, liveness, rendered outcome footers, failure kind definitions, back ## Environment overrides -- `GROK_BIN`: Grok binary override (tests point it at a fake). `GROK_COMPANION_DATA`: absolute state directory override (default `~/.claude/plugins/data/grok-claude-code-fusion`); relative values are rejected. `GROK_COMPANION_CONTINUITY_POLICY`: `manual` or `claude-session`, overriding the persisted setup choice. `GROK_COMPANION_TIMEOUT_MS`: foreground timeout override. `GROK_COMPANION_BACKGROUND_DELIVERY`: internal value `managed` marks a detached review that its dedicated runner owns and collects; unset explicit background jobs are manual. `GROK_JOBS_MONITOR_MANAGED_GRACE_MS`: delayed fallback window for an uncollected managed result, default 60000ms. `GROK_COMPANION_PIDLESS_RUNNING_GRACE_MS`: pidless launcher grace override. `GROK_COMPANION_WAIT_POLL_MS`: result wait poll interval override. `GROK_COMPANION_WAIT_TIMEOUT_MS`: result wait budget override, clamped to 570000ms. `GROK_COMPANION_SANDBOX_HANDSHAKE_MS`: sandbox event handshake budget, capped by the runtime maximum. `GROK_CONSULT_ALLOW`: comma separated read, grep, web search, or web fetch permission patterns; it cannot expand the hard consult tool set and write mode ignores it. The child receives a unique private `TMPDIR`, an explicit `GROK_MEMORY=0` or `1` selected by the ordinary task contract, `GROK_SUBAGENTS=0` outside best-of-n and `1` inside it, all eighteen disabled `GROK_CLAUDE_*_ENABLED`, `GROK_CURSOR_*_ENABLED`, and `GROK_CODEX_*_ENABLED` bridge variables, `GROK_MANAGED_MCPS_ENABLED=false`, and forced builder tracing. Inherited Claude session and plugin variables, `_GROK_CLAUDE_MARKER_OVERRIDE`, inherited `GROK_MEMORY`, and secret-bearing variables unrelated to xAI authentication cannot override those controls. +- `GROK_BIN`: Grok binary override (tests point it at a fake). `GROK_COMPANION_DATA`: absolute state directory override (default `~/.claude/plugins/data/grok-claude-code-fusion`); relative values are rejected. `GROK_COMPANION_CONTINUITY_POLICY`: `manual` or `claude-session`, overriding the persisted setup choice. `GROK_COMPANION_TIMEOUT_MS`: foreground timeout override. `GROK_COMPANION_BACKGROUND_DELIVERY`: internal value `managed` marks a detached review that its dedicated runner owns and collects; unset explicit background jobs are manual. `GROK_JOBS_MONITOR_MANAGED_GRACE_MS`: delayed fallback window for an uncollected managed result, default 60000ms. `GROK_COMPANION_PIDLESS_RUNNING_GRACE_MS`: pidless launcher grace override. `GROK_COMPANION_WAIT_POLL_MS`: result wait poll interval override. `GROK_COMPANION_WAIT_TIMEOUT_MS`: result wait budget override, clamped to 570000ms. `GROK_COMPANION_SANDBOX_HANDSHAKE_MS`: sandbox event handshake budget, capped by the runtime maximum. `GROK_CONSULT_ALLOW`: comma separated read, grep, web search, or web fetch permission patterns; it cannot expand the hard consult tool set and write mode ignores it. The child receives a unique private `TMPDIR`, an explicit `GROK_MEMORY=0` or `1` selected by the ordinary task contract, `GROK_SUBAGENTS=0`, all eighteen disabled `GROK_CLAUDE_*_ENABLED`, `GROK_CURSOR_*_ENABLED`, and `GROK_CODEX_*_ENABLED` bridge variables, `GROK_MANAGED_MCPS_ENABLED=false`, and forced builder tracing. Inherited Claude session and plugin variables, `_GROK_CLAUDE_MARKER_OVERRIDE`, inherited `GROK_MEMORY`, and secret-bearing variables unrelated to xAI authentication cannot override those controls. ## Setup probe -- `setup` reports Grok version availability, private data directory access, the selected continuity policy, and the complete plugin capability surface. `--continuity manual|claude-session` persists the selected policy in companion configuration; an invalid value fails as input, and absence preserves the current value or the `manual` default. Its per-flag verdicts include `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, `--no-auto-update`, `--permission-mode`, `--allow`, `--disable-web-search`, `--always-approve`, `--json-schema`, `--best-of-n`, `--no-wait-for-background`, and `--background-wait-timeout`, while `--no-subagents` remains an informational compatibility hint. Top-level `ready` requires the binary, private data directory access, the universal safety flags, consult, write, review, and tournament surfaces, plus a bounded background-wait path. Exact tool ids and tool policy application are validated again for each run through mandatory builder tracing rather than inferred from the version string. +- `setup` reports Grok version availability, private data directory access, the selected continuity policy, and the complete plugin capability surface. `--continuity manual|claude-session` persists the selected policy in companion configuration; an invalid value fails as input, and absence preserves the current value or the `manual` default. Its per-flag verdicts include `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, `--no-auto-update`, `--permission-mode`, `--allow`, `--disable-web-search`, `--always-approve`, `--json-schema`, and `--no-wait-for-background`, while `--no-subagents` remains an informational compatibility hint. Top-level `ready` requires the binary, private data directory access, the universal safety flags, and the consult, write, and review surfaces. Exact tool ids and tool policy application are validated again for each run through mandatory builder tracing rather than inferred from the version string. +- `setup` additionally reports `doctorCommand.available` and `doctorCommand.detail`, reporting whether the installed CLI exposes the `grok doctor` subcommand. This advisory is informational only and never changes top-level `ready`. ## Stop gate diff --git a/plugins/fusion/rules-manifest.json b/plugins/fusion/rules-manifest.json index c5b13f0..24f41a3 100644 --- a/plugins/fusion/rules-manifest.json +++ b/plugins/fusion/rules-manifest.json @@ -19,6 +19,7 @@ "9b6d1f1e857020604219a1095928748dcad5d25070a2b33a2e9747273370038b", "9e3213d69a08713ffa97f22042ecee8493fb4aa4a2d91150da4f1b6e4573b42a", "a04a6b16fdf5ff42e8ab8da398331da505bdffa0a002e3073c4e73d3298bd3fc", + "a90f49b6ce745c9184bd11509418ca798b26f88351fbb5d9322975fc3c2a94e1", "aead643e6dee6b0de04e97c5dbbb2f9f8c5e04dfbd12e8305b0abb97ab33524e", "b3649bf8c8fb1f3ede0ccfc7fb727a8adf1e13628a18f4a84d08efbee75bd934", "b5283c0356c9004385157f3360ca81a3601253e370216b390049d069a29a2250", diff --git a/plugins/fusion/rules/orchestration.md b/plugins/fusion/rules/orchestration.md index aaf3cb7..ca62bce 100644 --- a/plugins/fusion/rules/orchestration.md +++ b/plugins/fusion/rules/orchestration.md @@ -17,7 +17,7 @@ Run the session like the founder of a well staffed startup: you decide, employee - 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. - 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`, `large-context`, and `best-of-n`; it remains implementation capable inside those roles. +- 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. - Explore owns open ended read only repository reconnaissance. The built in Plan agent has no automatic routing seat: routine planning stays in the main session, and high stakes decisions use the panel. - Prefer delegation for parallelizable or long running work. A small, well understood step done in the main loop, where you can see the result directly, is legitimate too; the goal is not to avoid tool use but to avoid spending main loop tokens on what an employee could do as well. If a tool call is producing the user's artifact after the micro step has been spent, that call belongs to an employee. @@ -109,12 +109,12 @@ Questions to the user are interrupts, and most are self inflicted. Before asking ## Peer engagement -Codex owns ordinary delegated implementation by default. Grok is a complementary specialist and burst lane with five protected routing roles: `burst` for independent capacity or availability overflow and Ultra fleet breadth, `independence` for cross family second opinions or alternative implementations, `live-web` for current research, `large-context` for unusually broad reads, and `best-of-n` for tournaments. Grok remains implementation capable inside those roles, but it is not a generic alternative merely because it is idle or fast. The single header line for every automatically routed Grok brief includes `grok-role: `; a direct `/grok:*` command or explicit user request for Grok is a user selected override and does not need that field. Automatically routed Grok briefs never enable cross-session memory and do not receive configured automatic continuity affinity. An explicit `--resume` or `--resume-last` is allowed only for a sequential follow up to the same package under the warm thread rules. A Grok burst dispatch passes `--effort low` as a leading companion option, and the brief header line repeats it. +Codex owns ordinary delegated implementation by default. Grok is a complementary specialist and burst lane with four protected routing roles: `burst` for independent capacity or availability overflow and Ultra fleet breadth, `independence` for cross family second opinions or alternative implementations, `live-web` for current research, and `large-context` for unusually broad reads. Grok remains implementation capable inside those roles, but it is not a generic alternative merely because it is idle or fast. The single header line for every automatically routed Grok brief includes `grok-role: `, where `` is exactly one of `burst`, `independence`, `live-web`, or `large-context`; a direct `/grok:*` command or explicit user request for Grok is a user selected override and does not need that field. Automatically routed Grok briefs never enable cross-session memory and do not receive configured automatic continuity affinity. An explicit `--resume` or `--resume-last` is allowed only for a sequential follow up to the same package under the warm thread rules. A Grok burst dispatch passes `--effort low` as a leading companion option, and the brief header line repeats it. 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`, `independence`, or `best-of-n`; use read only consult with `--web` under `live-web`; use read only file analysis under `large-context`. Consult, write, review, and best-of-n 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 non-tournament 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, stop gate, and best-of-n 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`, best-of-n adds `--best-of-n` and `--background-wait-timeout`, 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. +- 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. - 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: 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. @@ -145,7 +145,7 @@ 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 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 non-tournament 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. Best-of-n keeps native Agent support and requires that bounded timeout. 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 and single triage edit 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. After collecting and verifying any delegated result, settle its verdict in one call with `/fusion:stats --record =`, where the id is the fusion task id or the engine job id; one settle writes the worker ledger and the linked engine record together, 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. Stats default every terminal job without a recorded judgment to `unverified`; never infer acceptance from `state: done`, `delivery: complete`, or any transport status. diff --git a/plugins/fusion/skills/panel/SKILL.md b/plugins/fusion/skills/panel/SKILL.md index 66049c5..5d16bfa 100644 --- a/plugins/fusion/skills/panel/SKILL.md +++ b/plugins/fusion/skills/panel/SKILL.md @@ -13,7 +13,7 @@ Decision or question to adjudicate: Compose the brief: -- Write ONE neutral, self contained brief containing: the decision or question, the constraints that bound the answer, the relevant file paths, and explicit coverage or acceptance criteria for what a good answer must address. Collection review verifies this consultation brief; it does not need a shell verification command. Its first line is the single routing header required by policy and includes `lane: panel`, `grok-role: independence`, and the explicit acceptance criteria; the shared routing header is also included in the identical prompt sent to Codex. +- Write ONE neutral, self contained brief containing: the decision or question, the constraints that bound the answer, the relevant file paths, and explicit coverage or acceptance criteria for what a good answer must address. Collection review verifies this consultation brief; it does not need a shell verification command. Its first line is the single routing header required by policy and includes `lane: panel`, `grok-role: independence` (one of the four permitted Grok roles), and the explicit acceptance criteria; the shared routing header is also included in the identical prompt sent to Codex. - Never include a candidate answer, a leaning, any prior model opinion, or any context from this conversation. The panel is blind; a brief that hints at a preferred answer is invalid. - End the brief with: "This is a consultation. Analyze and recommend; do not modify any files." - Keep the exact brief in the Agent calls and pass it directly as each Agent prompt. Do not create a transport file, temp brief, or plugin-data payload with Write. Companion adapters own any private staging required by their transports. Panel consultations do not use isolated implementation worktrees or a Codex only `--cwd` prefix because either would violate the identical brief invariant. diff --git a/plugins/fusion/skills/ultra/SKILL.md b/plugins/fusion/skills/ultra/SKILL.md index 0aaf439..872595e 100644 --- a/plugins/fusion/skills/ultra/SKILL.md +++ b/plugins/fusion/skills/ultra/SKILL.md @@ -20,7 +20,7 @@ Size gate first (cheap, in the main loop): Compose one self contained brief per facet: - Each brief states its facet's goal, the shared context needed to work it alone, the relevant paths, and what a good result must cover. Claude worker facets use the `fusion-brief: v1` isolated envelope. Implementation facets carry a verification command. Consult and research facets instead carry explicit coverage or acceptance criteria, with collection review as their verification. A facet brief never depends on another facet's output. Pass every brief directly as the Agent prompt; do not write transport files. Companion adapters own any private staging required by their transports. -- Every Grok facet brief begins with a single routing header line containing exactly one `grok-role: ` field, choosing one of `burst`, `independence`, `live-web`, `large-context`, or `best-of-n` from the facet's actual purpose. Do not include a second Grok role anywhere in that brief. +- Every Grok facet brief begins with a single routing header line containing exactly one `grok-role: ` field, choosing one of `burst`, `independence`, `live-web`, or `large-context` from the facet's actual purpose. Do not include a second Grok role anywhere in that brief. - Research briefs go to grok with `--web` when live sources help; implementation briefs state write permission explicitly. Launch the fleet in one message: diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 2e1cc8d..b1a58cf 100644 --- a/plugins/grok/.claude-plugin/plugin.json +++ b/plugins/grok/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "grok", "displayName": "Grok Companion", "version": "0.0.34", - "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", + "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/agents/grok-rescue.md b/plugins/grok/agents/grok-rescue.md index ff0108c..bcc26f3 100644 --- a/plugins/grok/agents/grok-rescue.md +++ b/plugins/grok/agents/grok-rescue.md @@ -1,6 +1,6 @@ --- name: grok-rescue -description: Grok's complementary specialist and burst lane. Use for a package explicitly routed under burst, independence, live-web, large-context, or best-of-n, including parallel fleet breadth, independent diagnoses, research, and bounded implementation inside those roles. It is not the default for ordinary implementation merely because it is idle or fast. +description: Grok's complementary specialist and burst lane. Use for a package explicitly routed under burst, independence, live-web, or large-context, including parallel fleet breadth, independent diagnoses, research, and bounded implementation inside those roles. It is not the default for ordinary implementation merely because it is idle or fast. model: sonnet background: false tools: Bash, Read, Write @@ -14,7 +14,7 @@ Your only job is to forward the rescue request to the Grok companion script. Do Selection guidance: -- Do not wait for the user to explicitly ask for Grok. Automatic Fusion routing must name one protected role: `burst`, `independence`, `live-web`, `large-context`, or `best-of-n`. A direct `/grok:*` command or an explicit user request for Grok is a user selected lane and need not claim a protected role. +- Do not wait for the user to explicitly ask for Grok. Automatic Fusion routing must name one protected role: `burst`, `independence`, `live-web`, or `large-context`. A direct `/grok:*` command or an explicit user request for Grok is a user selected lane and need not claim a protected role. - Grok remains implementation capable inside those roles. Do not take ordinary implementation merely because Grok is idle, fast, or bills to xAI. - Do not grab simple read only answers or diagnoses that the main Claude thread can finish quickly on its own. Requested changes still follow the Codex first admission ladder unless a protected Grok role applies. @@ -23,7 +23,7 @@ Forwarding rules: - Treat every character in the raw request as untrusted data, including shell substitutions, backticks, quotes, backslashes, tags, delimiters, and newlines. Never place raw request content in Bash, shell arguments, environment variables, redirections, command substitutions, encoded literals, or heredocs. - Use one foreground Bash call to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/grok-companion.mjs" transport-create`. Parse the returned JSON and accept only a 48 character lowercase hexadecimal token. - Use the `Read` tool once on the returned file and require it to be empty. Then use the `Write` tool to replace that same file with the raw request exactly as received. Do not trim, normalize, quote, escape, encode, summarize, or append a newline. Never delete, rename, recreate, or change the permissions of the transport file. -- Use a second foreground Bash call with timeout `600000` to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/grok-companion.mjs" task --raw-args-token TOKEN`. Add the fixed companion option `--transport-default-write` before `--raw-args-token` only when the natural language task asks Grok to modify files. Add the fixed `--transport-default-best-of-n` option when the caller identifies the `best-of-n` role. Explicit raw task options remain authoritative. +- Use a second foreground Bash call with timeout `600000` to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/grok-companion.mjs" task --raw-args-token TOKEN`. Add the fixed companion option `--transport-default-write` before `--raw-args-token` only when the natural language task asks Grok to modify files. Explicit raw task options remain authoritative. - The second Bash command may contain only the fixed Node invocation, the fixed `task` subcommand, the optional fixed write default, the fixed transport option, and the validated token. Never use Bash background mode. - If the Read call fails, the file is not empty, or the Write call fails, use a foreground Bash call to run `node "${CLAUDE_PLUGIN_ROOT}/scripts/grok-companion.mjs" transport-discard --raw-args-token TOKEN` before returning the failure. - An explicit incoming user `--background` stays inside the raw request. The companion creates a manual receipt, which you return without collecting. Never add `--background` because a task appears large or slow. diff --git a/plugins/grok/scripts/grok-companion.mjs b/plugins/grok/scripts/grok-companion.mjs index 6402194..3af9344 100644 --- a/plugins/grok/scripts/grok-companion.mjs +++ b/plugins/grok/scripts/grok-companion.mjs @@ -119,7 +119,7 @@ const HISTORY_FAILURE_KINDS = new Set([ "policy", "turn_limit" ]); -const GROK_ROLES = new Set(["burst", "independence", "live-web", "large-context", "best-of-n"]); +const GROK_ROLES = new Set(["burst", "independence", "live-web", "large-context"]); const REVIEW_OUTPUT_SCHEMA = { type: "object", additionalProperties: false, @@ -157,7 +157,7 @@ function printUsage() { console.log( [ "Usage:", - " node scripts/grok-companion.mjs task [--prompt-file ] [--write] [--web] [--memory] [--background] [--resume ] [--resume-last] [--fresh] [--model ] [--effort ] [--max-turns ] [--best-of-n ] [--cwd ] [--json] [--] [prompt]", + " node scripts/grok-companion.mjs task [--prompt-file ] [--write] [--web] [--memory] [--background] [--resume ] [--resume-last] [--fresh] [--model ] [--effort ] [--max-turns ] [--cwd ] [--json] [--] [prompt]", " node scripts/grok-companion.mjs review [--base ] [--focus ] [--cwd ] [--background] [--json]", " node scripts/grok-companion.mjs status [job-id] [--cwd ] [--json]", " node scripts/grok-companion.mjs history [--all] [--limit ] [--cwd ] [--json]", @@ -532,31 +532,27 @@ function consumeRawCommandTransport(token) { } function resolveCommandTransport(rawArgv) { - const defaults = { defaultBackground: false, defaultBestOfN: false, defaultWrite: false }; + const defaults = { defaultBackground: false, defaultWrite: false }; const transportArgv = [...rawArgv]; while ( transportArgv[0] === "--transport-default-write" || - transportArgv[0] === "--transport-default-background" || - transportArgv[0] === "--transport-default-best-of-n" + transportArgv[0] === "--transport-default-background" ) { const option = transportArgv.shift(); const key = option === "--transport-default-write" ? "defaultWrite" - : option === "--transport-default-background" - ? "defaultBackground" - : "defaultBestOfN"; + : "defaultBackground"; defaults[key] = true; } if (transportArgv[0] !== "--raw-args-token") { if ( transportArgv.some((value) => value === "--raw-args-token") || defaults.defaultWrite || - defaults.defaultBackground || - defaults.defaultBestOfN + defaults.defaultBackground ) { throw errorWithFailure("Raw command transport options must not be combined with normal arguments.", "input"); } - return { argv: rawArgv, defaultBackground: false, defaultBestOfN: false, defaultWrite: false, ingress: "argv" }; + return { argv: rawArgv, defaultBackground: false, defaultWrite: false, ingress: "argv" }; } if (transportArgv.length !== 2) { throw errorWithFailure("Raw command transport requires exactly one token.", "input"); @@ -707,14 +703,6 @@ function parseNonnegativeInteger(value, flag) { return parsed; } -function parseBestOfN(value) { - const parsed = parsePositiveInteger(value, "--best-of-n"); - if (parsed < 2 || parsed > 10) { - throw inputError("Expected --best-of-n between 2 and 10."); - } - return parsed; -} - function commandArgs(argv, config, transport) { activeCommandConfig = config; activeCommandOptions = null; @@ -891,10 +879,7 @@ function jobClassForRecord(job) { if (request && typeof request === "object" && !Array.isArray(request) && typeof request.reviewTargetLabel === "string") { return "review"; } - if (request && typeof request === "object" && !Array.isArray(request) && request.bestOfN != null) { - return "best_of_n"; - } - if (job?.jobClass === "review" || job?.jobClass === "best_of_n" || job?.jobClass === "stop_gate") { + if (job?.jobClass === "review" || job?.jobClass === "stop_gate") { return job.jobClass; } if ((job?.jobClass === "task" || job?.jobClass == null) && ordinaryTaskRequest(request)) { @@ -1495,9 +1480,8 @@ function recordSpawnFailure(jobFile, error, context = {}) { async function handleTask(argv, transport = {}) { const { options, positionals, positionalText } = commandArgs(argv, { - valueOptions: ["prompt-file", "resume", "model", "effort", "max-turns", "best-of-n", "cwd"], + valueOptions: ["prompt-file", "resume", "model", "effort", "max-turns", "cwd"], booleanOptions: ["write", "background", "resume-last", "fresh", "memory", "web", "json"], - aliasMap: transport.defaultBestOfN ? { n: "best-of-n" } : {}, optionsBeforePositionals: true }, transport); @@ -1505,15 +1489,10 @@ async function handleTask(argv, transport = {}) { const dataDir = resolveDataDir(); const claudeSessionId = currentClaudeSessionId(); - const bestOfN = options["best-of-n"] - ? parseBestOfN(options["best-of-n"]) - : transport.defaultBestOfN - ? 2 - : null; const maxTurns = options["max-turns"] ? parsePositiveInteger(options["max-turns"], "--max-turns") : null; const write = options.write === undefined ? Boolean(transport.defaultWrite) : Boolean(options.write); const background = Boolean(options.background); - const mode = write || bestOfN ? "write" : "consult"; + const mode = write ? "write" : "consult"; const memory = Boolean(options.memory); const selectedContinuityPolicy = continuityPolicy(dataDir); const explicitResume = options.resume != null; @@ -1526,13 +1505,6 @@ async function handleTask(argv, transport = {}) { if (fresh && (explicitResume || resumeLast)) { throw inputError("--fresh cannot be combined with --resume or --resume-last."); } - if (bestOfN && (explicitResume || resumeLast)) { - throw inputError("--best-of-n always starts fresh and cannot be combined with resume options."); - } - if (bestOfN && memory) { - throw inputError("--best-of-n always disables cross-session memory and cannot be combined with --memory."); - } - let prompt = readTaskPrompt(cwd, options, positionals, positionalText); const fusionRouted = isFusionRoutedPrompt(prompt); if (memory && fusionRouted) { @@ -1549,7 +1521,6 @@ async function handleTask(argv, transport = {}) { } else if ( prompt && !fresh && - !bestOfN && selectedContinuityPolicy === "claude-session" && claudeSessionId && !fusionRouted @@ -1581,13 +1552,12 @@ async function handleTask(argv, transport = {}) { background, delivery: background ? backgroundDelivery() : "foreground", claudeSessionId, - jobClass: bestOfN ? "best_of_n" : "task", + jobClass: "task", role, request: { model: options.model ?? null, effort: options.effort ?? null, maxTurns, - bestOfN, web: Boolean(options.web), memory, sandboxProfile: sandboxProfileForMode(mode), @@ -1646,7 +1616,6 @@ async function handleTask(argv, transport = {}) { model: options.model ?? null, effort: options.effort ?? null, maxTurns, - bestOfN, web: Boolean(options.web), memory, cwd, @@ -1732,7 +1701,6 @@ async function handleTaskWorker(argv) { model: request.model ?? null, effort: request.effort ?? null, maxTurns: request.maxTurns ?? null, - bestOfN: request.bestOfN ?? null, web: Boolean(request.web), memory: request.memory === true, cwd: record.cwd, @@ -2756,6 +2724,20 @@ function handleStats(argv, transport = {}) { output(options.json ? stats : renderStatsReport(stats), options.json); } +const DOCTOR_PROBE_TIMEOUT_MS = 3000; + +function doctorCommandAdvisory(bin, available) { + if (!available) { + return { available: false, detail: "grok is unavailable" }; + } + const probe = spawnSync(bin, ["doctor", "--help"], { encoding: "utf8", timeout: DOCTOR_PROBE_TIMEOUT_MS }); + const supported = !probe.error && probe.status === 0; + return { + available: supported, + detail: supported ? "grok doctor is available" : "grok doctor is not available on this Grok CLI" + }; +} + function handleSetup(argv, transport = {}) { const { options } = commandArgs(argv, { valueOptions: ["continuity"], @@ -2790,19 +2772,15 @@ function handleSetup(argv, transport = {}) { "--disable-web-search", "--always-approve", "--json-schema", - "--best-of-n", "--no-subagents", - "--no-wait-for-background", - "--background-wait-timeout" + "--no-wait-for-background" ]; let capabilities = { ready: false, detail: available ? "capability probe did not run" : "grok is unavailable", flags: {} }; if (available) { try { const supported = resolveGrokCapabilities(bin, process.env); const flags = Object.fromEntries(capabilityFlags.map((flag) => [flag, supported.has(flag)])); - const critical = capabilityFlags.filter( - (flag) => !["--no-subagents", "--no-wait-for-background"].includes(flag) - ); + const critical = capabilityFlags.filter((flag) => flag !== "--no-subagents"); const missing = critical.filter((flag) => !flags[flag]); capabilities = { ready: missing.length === 0, @@ -2814,6 +2792,8 @@ function handleSetup(argv, transport = {}) { } } + const doctorCommand = doctorCommandAdvisory(bin, available); + const dataDir = resolveDataDir(); let writable = true; let writeDetail = dataDir; @@ -2838,6 +2818,7 @@ function handleSetup(argv, transport = {}) { ready: available && capabilities.ready && writable, grok: { available, detail, bin }, capabilities, + doctorCommand, dataDir: { path: dataDir, writable, detail: writeDetail }, stopGate: Boolean(readConfig(dataDir).stopGate), continuityPolicy: continuityPolicy(dataDir) @@ -3053,9 +3034,6 @@ async function main() { if (transport.defaultBackground && subcommand !== "review") { throw errorWithFailure("The raw command transport background default is valid only for review.", "input"); } - if (transport.defaultBestOfN && subcommand !== "task") { - throw errorWithFailure("The raw command transport best-of-n default is valid only for task.", "input"); - } const argv = transport.argv; switch (subcommand) { diff --git a/plugins/grok/scripts/lib/grok-exec.mjs b/plugins/grok/scripts/lib/grok-exec.mjs index 24098a0..eb801c4 100644 --- a/plugins/grok/scripts/lib/grok-exec.mjs +++ b/plugins/grok/scripts/lib/grok-exec.mjs @@ -20,7 +20,6 @@ const CONSULT_ALLOW_ENV = "GROK_CONSULT_ALLOW"; const CAPABILITIES_ENV = "GROK_COMPANION_CAPABILITIES"; const DEFAULT_FOREGROUND_TIMEOUT_MS = 570000; const BACKGROUND_TIMEOUT_CAP_MS = 1800000; -const BACKGROUND_CLEANUP_RESERVE_MS = 30000; const DEFAULT_PIDLESS_RUNNING_GRACE_MS = 15000; const TERMINATION_GRACE_MS = 2000; const TERMINATION_POLL_MS = 50; @@ -178,7 +177,7 @@ export function resolveConsultAllowRules(env = process.env) { .filter((entry) => /^(?:Read|Grep|WebSearch|WebFetch)(?:\([^(),\r\n]*\))?$/.test(entry)); } -export function buildGrokChildEnv(env = process.env, { bestOfN = null, memory = false } = {}) { +export function buildGrokChildEnv(env = process.env, { memory = false } = {}) { const childEnv = { ...env }; for (const key of Object.keys(childEnv)) { if ( @@ -196,7 +195,7 @@ export function buildGrokChildEnv(env = process.env, { bestOfN = null, memory = childEnv.GROK_DISABLE_AUTOUPDATER = "1"; childEnv.GROK_MANAGED_MCPS_ENABLED = "false"; childEnv.GROK_MEMORY = memory ? "1" : "0"; - childEnv.GROK_SUBAGENTS = bestOfN ? "1" : "0"; + childEnv.GROK_SUBAGENTS = "0"; for (const key of COMPATIBILITY_ENV_KEYS) { childEnv[key] = "false"; } @@ -243,7 +242,6 @@ export function resolveGrokCapabilities(bin, env = process.env) { const capabilities = new Set(`${probe.stdout ?? ""}\n${probe.stderr ?? ""}`.match(CAPABILITY_PATTERN) ?? []); for (const [flag, probeArgs] of [ ["--no-wait-for-background", ["--no-wait-for-background", "--help"]], - ["--background-wait-timeout", ["--background-wait-timeout", "1", "--help"]], ["--no-auto-update", ["--no-auto-update", "--help"]] ]) { if (!capabilities.has(flag) && spawnSync(bin, probeArgs, probeOptions).status === 0) { @@ -261,14 +259,8 @@ function requireGrokCapabilities(capabilities, flags) { } } -function backgroundWaitSeconds(timeoutMs) { - const budget = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_FOREGROUND_TIMEOUT_MS; - return Math.max(1, Math.floor((Math.max(1000, budget) - BACKGROUND_CLEANUP_RESERVE_MS) / 1000)); -} - export function buildGrokArgs(options) { - const bestOfN = options.bestOfN ?? null; - const mode = bestOfN || options.mode === "write" ? "write" : "consult"; + const mode = options.mode === "write" ? "write" : "consult"; const maxTurns = options.maxTurns ?? DEFAULT_MAX_TURNS[mode]; const capabilities = options.capabilities ?? null; const env = options.env ?? process.env; @@ -291,17 +283,11 @@ export function buildGrokArgs(options) { if (supports("--no-auto-update")) { args.push("--no-auto-update"); } - if (!bestOfN) { - if (supports("--no-subagents")) { - args.push("--no-subagents"); - } - if (supports("--no-wait-for-background")) { - args.push("--no-wait-for-background"); - } else if (supports("--background-wait-timeout")) { - args.push("--background-wait-timeout", String(backgroundWaitSeconds(options.timeoutMs))); - } - } else if (supports("--background-wait-timeout")) { - args.push("--background-wait-timeout", String(backgroundWaitSeconds(options.timeoutMs))); + if (supports("--no-subagents")) { + args.push("--no-subagents"); + } + if (supports("--no-wait-for-background")) { + args.push("--no-wait-for-background"); } if (!options.web) { args.push("--disable-web-search"); @@ -326,7 +312,7 @@ export function buildGrokArgs(options) { args.push("--json-schema", schema); } - const disallowedTools = [...(bestOfN ? [] : ["Agent"]), ...DISALLOWED_META_TOOL_IDS]; + const disallowedTools = ["Agent", ...DISALLOWED_META_TOOL_IDS]; if (mode === "consult") { args.push( @@ -358,7 +344,7 @@ export function buildGrokArgs(options) { args.push( "--always-approve", "--tools", - [...WRITE_TOOL_IDS, ...(options.web ? CONSULT_WEB_TOOL_IDS : []), ...(bestOfN ? ["Agent"] : [])].join(","), + [...WRITE_TOOL_IDS, ...(options.web ? CONSULT_WEB_TOOL_IDS : [])].join(","), "--disallowed-tools", disallowedTools.join(",") ); @@ -370,10 +356,6 @@ export function buildGrokArgs(options) { } } - if (bestOfN) { - args.push("--best-of-n", String(bestOfN)); - } - return args; } @@ -1683,7 +1665,7 @@ export function runGrok(options) { const bin = options.bin ?? resolveGrokBin(env); const timeoutMs = options.timeoutMs ?? resolveTimeoutMs({ background: Boolean(options.background), env }); const managedRun = !options.args; - const requestedMode = options.bestOfN || options.mode === "write" ? "write" : "consult"; + const requestedMode = options.mode === "write" ? "write" : "consult"; let capabilities = null; if (managedRun) { capabilities = resolveGrokCapabilities(bin, env); @@ -1699,17 +1681,10 @@ export function runGrok(options) { if (options.jsonSchema) { requireGrokCapabilities(capabilities, ["--json-schema"]); } - if (options.bestOfN) { - requireGrokCapabilities(capabilities, ["--best-of-n", "--background-wait-timeout"]); - } else if (!capabilities.has("--no-wait-for-background") && !capabilities.has("--background-wait-timeout")) { - throw capabilityError("The installed Grok CLI cannot bound headless background work. Upgrade Grok to a version with --no-wait-for-background or --background-wait-timeout."); - } + requireGrokCapabilities(capabilities, ["--no-wait-for-background"]); } const args = options.args ?? buildGrokArgs({ ...options, capabilities, timeoutMs }); - const childEnv = buildGrokChildEnv(env, { - bestOfN: options.bestOfN ?? null, - memory: options.memory === true - }); + const childEnv = buildGrokChildEnv(env, { memory: options.memory === true }); const captureProcessIdentity = options.captureProcessIdentity ?? getProcessIdentity; let prompt = typeof options.prompt === "string" ? options.prompt : null; if (managedRun && prompt == null && options.briefFile) { diff --git a/plugins/grok/scripts/lib/render.mjs b/plugins/grok/scripts/lib/render.mjs index 0057780..d03eb1b 100644 --- a/plugins/grok/scripts/lib/render.mjs +++ b/plugins/grok/scripts/lib/render.mjs @@ -501,7 +501,8 @@ export function renderSetupReport(report) { `- headless safety capabilities: ${report.capabilities?.ready ? "ready" : `not ready, ${report.capabilities?.detail ?? "not checked"}`}`, `- data dir: ${report.dataDir.writable ? `writable (${report.dataDir.path})` : `not writable (${report.dataDir.detail})`}`, `- stop gate: ${report.stopGate ? "enabled" : "disabled"}`, - `- continuity: ${report.continuityPolicy ?? "manual"}` + `- continuity: ${report.continuityPolicy ?? "manual"}`, + `- grok doctor: ${report.doctorCommand?.available ? "available" : `not available, ${report.doctorCommand?.detail ?? "not checked"}`}` ]; const nextSteps = []; diff --git a/plugins/grok/skills/best-of-n/SKILL.md b/plugins/grok/skills/best-of-n/SKILL.md deleted file mode 100644 index b9fd85a..0000000 --- a/plugins/grok/skills/best-of-n/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: best-of-n -description: Runs a Grok best-of-n implementation tournament and applies the winner. Use when several independent candidates and an automatic pick are wanted. -argument-hint: '[--n ] [task text]' -allowed-tools: Agent ---- - -Forward the request to the Grok companion task runtime as a best-of-n tournament. - -The tournament is implemented by the Grok CLI `--best-of-n` flag, not by orchestration inside the companion. - -Execution rules: - -- Treat the complete raw slash command request as opaque data. Do not parse, strip, quote, escape, normalize, or place any part of it in Bash. -- Dispatch the run as one foreground subagent of type `grok:grok-rescue` via the `Agent` tool with `run_in_background: false`. Tell the rescue agent this direct user selected Grok lane uses the `best-of-n` protected role and that the opaque raw request begins after a fixed delimiter and continues to the end of the Agent prompt. Parallel work uses multiple foreground Agent calls in one message. Without an explicit incoming user `--background`, the companion remains foreground and returns a terminal outcome. Never run the companion in the main loop and never ask the user how to run it. -- Preserve the raw request exactly. The rescue agent supplies the fixed `--transport-default-best-of-n` companion option, which applies `--best-of-n 2` only when the raw request does not already select a tournament size. The companion rejects values outside 2 to 10. -- A best-of-n tournament always starts a fresh Grok session and always disables cross-session memory. The companion rejects `--memory`, `--resume`, and `--resume-last` instead of silently changing their meaning. Continuity affinity never applies to a tournament. -- The tournament runs in write mode with auto approval under `--sandbox strict`; it never downgrades to workspace. Grok builds n candidate implementations in isolated worktrees, selects a candidate, and applies that candidate's edits to the workspace. It is the only mode that adds native Agent to the fixed write tool allowlist. The list uses `search_replace` for both new and existing files because Grok has no `write` tool id. The companion gives native background tasks an explicit wait timeout shorter than its outer deadline, which is not companion detachment. That selection is not semantic acceptance; the main session verifies the combined workspace and makes the final judgment. Only run it when the user accepts edits. Strict can hide user toolchains outside its readable roots and macOS does not enforce its requested child process network restriction, so terminal commands can still reach the network there. The allowlist and deny rules reduce exposure but are not complete confinement; recommend a clean branch or a disposable worktree when the tree is dirty. -- A tournament multiplies xAI usage by roughly n, so keep n at 2 unless the user explicitly asks for more candidates. -- Grok may leave its candidate worktrees behind next to the workspace after applying the winner; if the user asks about stray `bestofn-candidate-*` directories, point them at `git worktree list` and `git worktree remove `. -- Relay the subagent's returned companion output verbatim, exactly as-is, including the grok-session and job lines. -- Do not paraphrase, summarize, or add commentary before or after it. -- For a background run, the companion prints the job id plus `/grok:status` and `/grok:result` usage hints. Preserve them. -- If the user did not supply a request, ask what Grok should do. -- If the companion reports that Grok is missing, point the user at `/grok:setup`. - -The opaque raw tournament request begins after the next newline and continues to the end of this command prompt: -$ARGUMENTS diff --git a/plugins/grok/skills/grok-cli-runtime/SKILL.md b/plugins/grok/skills/grok-cli-runtime/SKILL.md index 8568e52..1fb4d02 100644 --- a/plugins/grok/skills/grok-cli-runtime/SKILL.md +++ b/plugins/grok/skills/grok-cli-runtime/SKILL.md @@ -12,7 +12,7 @@ Primary helper: Subcommand surface: -- `task [--prompt-file ] [--write] [--web] [--memory] [--background] [--resume ] [--resume-last] [--fresh] [--model ] [--effort ] [--max-turns ] [--best-of-n ] [--cwd ] [--json] [--] [prompt]` +- `task [--prompt-file ] [--write] [--web] [--memory] [--background] [--resume ] [--resume-last] [--fresh] [--model ] [--effort ] [--max-turns ] [--cwd ] [--json] [--] [prompt]` - The upstream Grok CLI has no default turn limit. An unset limit is unlimited, and one turn is one main-agent model call plus its tool cycle, excluding subagent calls. The companion defaults consult and write to `--max-turns 60`; consult previously defaulted to 25. When the limit is reached, Grok prints the complete final JSON envelope to stdout, including partial text, usage, `num_turns`, and `modelUsage`, then exits 1 with stderr `Error: max turns reached`. The companion salvages that envelope and records `failureKind: "turn_limit"`. - Grok headless JSON has no top-level `model` field. Model names are available only as keys in `modelUsage` when usage attaches, so model capture depends on that map and on error-path salvage. Missing `modelUsage` leaves the resolved model unavailable. - `review [--base ] [--focus ] [--cwd ] [--background] [--json]` @@ -30,16 +30,15 @@ Execution rules: - The companion always invokes Grok with `--prompt-file /dev/stdin` and pipes the complete prompt to the child. Never pass a private staging path to Grok. Headless installs the sandbox before it reads `--prompt-file`, so a brief under Claude plugin data can be unreadable even when the companion already consumed it successfully. - Task and review options must precede the first positional text. Once prompt or focus text starts, every remaining token is data, including option shaped words such as `--background`, `--write`, `--cwd`, and `--json`. - Background has three distinct layers. The Grok rescue Agent and every helper `Bash` call remain foreground. Parallel orchestration starts multiple foreground Agent calls in one message. The Grok CLI runs in its own supervised process group, which is not itself a detached user job. -- Ordinary delegations use one foreground helper call with timeout `600000`. Only an explicit incoming user `--background` detaches a task and creates a manual receipt that crosses the Agent boundary. The dedicated review runner is the sole managed worker exception because it owns and collects the review before returning. Native Grok background tool waiting is a fourth, internal lifecycle. Ordinary non-tournament calls prefer `--no-wait-for-background` and use an explicit bounded wait only as a compatibility fallback; best-of-n requires that bounded wait because it retains Agent. Neither setting authorizes companion detachment. +- Ordinary delegations use one foreground helper call with timeout `600000`. Only an explicit incoming user `--background` detaches a task and creates a manual receipt that crosses the Agent boundary. The dedicated review runner is the sole managed worker exception because it owns and collects the review before returning. Native Grok background tool waiting is a fourth, internal lifecycle. Ordinary calls pass `--no-wait-for-background`. That setting does not authorize companion detachment. - Prefer the helper over hand-rolled `git`, direct Grok CLI strings, or any other Bash activity. -- Consult mode is the default. Consult, write, review, and best-of-n all pin `--sandbox strict`; never downgrade to workspace. Consult overrides inherited always approve mode with `--permission-mode default` and hard filters built in tools to file read, list, and search. `--web` additionally exposes Grok's web search and fetch tools. Strict's upstream `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`; the unique private run `TMPDIR` is the adapter's preferred and verified path, not the only temporary write surface. Its configuration requests child process network restriction, which Linux enforces through seccomp while macOS currently treats network blocking as a no-op; a write shell can reach the network on macOS, and `--web` controls only built in web tools. The hard tool filter and deny set provide the consult no-write model contract. Shell commands, tests, git, builds, file edits, MCP tools, and subagents are unavailable. `--write` keeps strict, enables auto approval, and uses `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`; `search_replace` creates files, and Grok has no `write` tool id. Denies for common direct grok, claude, and codex commands do not cover absolute paths, aliases or functions, or indirect scripts. They are not hard confinement while `run_terminal_cmd` remains enabled; that requires removing the terminal tool or an OS-level executable or network policy. User toolchains outside strict's readable roots may be unavailable, which is a reroute or environment problem rather than permission to weaken the profile. -- Every consult and write call explicitly denies `search_tool`, `use_tool`, `ask_user_question`, and all MCP tools. Every ordinary non-tournament call also passes bare `--disallowed-tools Agent` and `GROK_SUBAGENTS=0`. Cross-session memory is force-disabled with `GROK_MEMORY=0` unless an ordinary task explicitly supplies `--memory`, in which case the child receives `GROK_MEMORY=1`. Review, stop gate, and best-of-n always keep memory off. Published headless Grok parses `--experimental-memory` and `--no-memory` without forwarding them through the single-turn path, so the companion uses the environment variable as the effective control and never trusts an inherited value. When memory is enabled, upstream first-turn injection may read relevant global or workspace memory and place it in the model context sent to xAI. That internal injection is not a model `read_file` call and is not blocked by the companion's Read denies for `~/.grok/memory/**`. Upstream automatic saving normally requires at least three real user prompts in the same resumed session and enough content, so a single Fusion task usually only reads existing memory and does not guarantee that new memory is saved. 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_CODE_*` and `CLAUDE_PLUGIN_*` variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, and broadly scrubs secret-bearing variables except required xAI authentication. The entire Grok home remains read-write, and the sandbox permits consult `read_file` to reach `~/.grok/auth.json`, config, and sessions while a write shell can receive retained xAI authentication variables. The companion adds best-effort Read denies for `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` through absolute paths and standard `**/.grok` patterns. Raw path variants, symbolic links, and shell or indirect scripts can bypass those rules, so environment scrubbing and path-pattern denies do not isolate the credentials. The model-facing meta-tool denies cannot prove that native MCP servers, plugins, or hooks configured under `~/.grok` did not start during agent construction or had no side effects; those bridge variables do not disable native Grok configuration. Narrower exposure needs a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization. The child passes `--no-auto-update`, although upstream minimum-version enforcement can still force an update and remains a residual execution and network boundary. -- `--best-of-n` runs an implementation tournament (see docs/grok-contract.md; the companion accepts 2 to 10); it implies write mode with auto approval and keeps native Agent support because the winning candidate is applied to the workspace. It always starts fresh and always disables memory, and the companion rejects resume options or `--memory` before launch. Pass it only when edits are acceptable. Its explicit `--background-wait-timeout` remains below the companion's outer timeout. -- The companion probes the installed binary before using source-derived flags. Every run requires `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, and `--no-auto-update`. Consult also requires `--permission-mode` and `--allow`; no-web runs require `--disable-web-search`; write requires `--always-approve`; review requires `--json-schema`; best-of-n requires `--best-of-n` and `--background-wait-timeout`; ordinary calls require `--no-wait-for-background` or the bounded wait flag. A missing applicable capability fails before launch with failure kind `setup`; do not retry the unchanged task, and instead upgrade or repair the CLI and rerun `/grok:setup`. Every managed run forces builder tracing. Upstream initializes tracing only after it consumes the complete stdin prompt, so it is not a pre-prompt or pre-side-effect attestation. Fallback, unmappable, or unmatched-policy warnings trigger early verified termination, and a successful close is rejected without positive `tools allowlist applied` evidence. Before sending stdin, the companion creates a unique private `TMPDIR` and requires a new `ProfileApplied` record in the incremental shared `sandbox-events.jsonl` delta that contains that path and matches the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. Upstream supplies no run id or pid, so unrelated `ProfileApplied` and `ApplyFailed` records are not attributed; failure events are auxiliary diagnostics. The matching event proves profile and configuration application, not platform-level network isolation. A matching owned-stderr warning, handshake timeout, missing or malformed matching evidence, shared-log disappearance or rotation, or matching field mismatch fails closed. +- Consult mode is the default. Consult, write, and review pin `--sandbox strict`; never downgrade to workspace. Consult overrides inherited always approve mode with `--permission-mode default` and hard filters built in tools to file read, list, and search. `--web` additionally exposes Grok's web search and fetch tools. Strict's upstream `system_read` roots include `/var` and `/tmp` on Linux, and `/private` plus the entire `~/Library` on macOS, so shared temporary content and substantial user application configuration and caches may be readable. Its write roots include the workspace and entire Grok home, always include `/tmp` and `/var/tmp`, and on macOS also include `/private/tmp`, `/private/var/tmp`, and all of `/private/var/folders`; the unique private run `TMPDIR` is the adapter's preferred and verified path, not the only temporary write surface. Its configuration requests child process network restriction, which Linux enforces through seccomp while macOS currently treats network blocking as a no-op; a write shell can reach the network on macOS, and `--web` controls only built in web tools. The hard tool filter and deny set provide the consult no-write model contract. Shell commands, tests, git, builds, file edits, MCP tools, and subagents are unavailable. `--write` keeps strict, enables auto approval, and uses `read_file`, `grep`, `list_dir`, `search_replace`, and `run_terminal_cmd`; `search_replace` creates files, and Grok has no `write` tool id. Denies for common direct grok, claude, and codex commands do not cover absolute paths, aliases or functions, or indirect scripts. They are not hard confinement while `run_terminal_cmd` remains enabled; that requires removing the terminal tool or an OS-level executable or network policy. User toolchains outside strict's readable roots may be unavailable, which is a reroute or environment problem rather than permission to weaken the profile. +- Every consult and write call explicitly denies `search_tool`, `use_tool`, `ask_user_question`, and all MCP tools. Every ordinary call also passes bare `--disallowed-tools Agent` and `GROK_SUBAGENTS=0`. Cross-session memory is force-disabled with `GROK_MEMORY=0` unless an ordinary task explicitly supplies `--memory`, in which case the child receives `GROK_MEMORY=1`. Review and stop gate always keep memory off. Published headless Grok parses `--experimental-memory` and `--no-memory` without forwarding them through the single-turn path, so the companion uses the environment variable as the effective control and never trusts an inherited value. When memory is enabled, upstream first-turn injection may read relevant global or workspace memory and place it in the model context sent to xAI. That internal injection is not a model `read_file` call and is not blocked by the companion's Read denies for `~/.grok/memory/**`. Upstream automatic saving normally requires at least three real user prompts in the same resumed session and enough content, so a single Fusion task usually only reads existing memory and does not guarantee that new memory is saved. 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_CODE_*` and `CLAUDE_PLUGIN_*` variables plus `_GROK_CLAUDE_MARKER_OVERRIDE`, and broadly scrubs secret-bearing variables except required xAI authentication. The entire Grok home remains read-write, and the sandbox permits consult `read_file` to reach `~/.grok/auth.json`, config, and sessions while a write shell can receive retained xAI authentication variables. The companion adds best-effort Read denies for `auth.json`, `mcp_credentials.json`, `config.toml`, `sessions`, `memory`, `logs`, and `debug` through absolute paths and standard `**/.grok` patterns. Raw path variants, symbolic links, and shell or indirect scripts can bypass those rules, so environment scrubbing and path-pattern denies do not isolate the credentials. The model-facing meta-tool denies cannot prove that native MCP servers, plugins, or hooks configured under `~/.grok` did not start during agent construction or had no side effects; those bridge variables do not disable native Grok configuration. Narrower exposure needs a dedicated sandbox profile, an isolated Grok home, an authentication broker, or upstream path-level authorization. The child passes `--no-auto-update`, although upstream minimum-version enforcement can still force an update and remains a residual execution and network boundary. +- The companion probes the installed binary before using source-derived flags. Every run requires `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, `--no-auto-update`, and `--no-wait-for-background`. Consult also requires `--permission-mode` and `--allow`; no-web runs require `--disable-web-search`; write requires `--always-approve`; review requires `--json-schema`. A missing applicable capability fails before launch with failure kind `setup`. Every managed run forces builder tracing. Upstream initializes tracing only after it consumes the complete stdin prompt, so it is not a pre-prompt or pre-side-effect attestation. Fallback, unmappable, or unmatched-policy warnings trigger early verified termination, and a successful close is rejected without positive `tools allowlist applied` evidence. Before sending stdin, the companion creates a unique private `TMPDIR` and requires a new `ProfileApplied` record in the incremental shared `sandbox-events.jsonl` delta that contains that path and matches the canonical workspace, strict profile, enforced state, and requested `restrict_network: true` configuration. Upstream supplies no run id or pid, so unrelated `ProfileApplied` and `ApplyFailed` records are not attributed; failure events are auxiliary diagnostics. The matching event proves profile and configuration application, not platform-level network isolation. A matching owned-stderr warning, handshake timeout, missing or malformed matching evidence, shared-log disappearance or rotation, or matching field mismatch fails closed. - `--background` detaches `task` or `review` into a companion worker; the helper prints the job id plus `/grok:status` and `/grok:result` hints. A task accepts it only from an explicit incoming user flag and creates manual delivery. The dedicated review runner sets `GROK_COMPANION_BACKGROUND_DELIVERY=managed`, owns that review job, and collects it with repeated `result --wait` calls before returning. The runner accepts only a 32 character lowercase hexadecimal job id and omits the raw launch `--cwd` because companion job ids resolve globally across workspaces; an original `--json` stays on both launch and result. Text collection continues only after a zero exit whose output ends in `state: running`; JSON collection continues only after a zero exit with top level `status: "running"` and `cleanupRequired` not true. A text `phase: cleanup-required` or JSON `cleanupRequired: true` result is returned as a nonterminal failure receipt instead of looping. Terminal result collection records successful companion output, not later Agent message delivery. The monitor suppresses collected managed jobs and emits a delayed fallback after the grace period when a terminal managed job remains uncollected, as a best effort owner loss fallback. - `result --wait` blocks while a job is running, refreshes liveness on each poll, and prints the same terminal output as `result` when the job finishes. If its bounded wait budget elapses first, it prints a compact running render ending in `state: running` and exits zero so the forwarder can issue another foreground wait call. If verified cleanup cannot complete, it exits nonzero and returns the explicit cleanup-required receipt even though the durable record remains running for a later cleanup retry. - A user asking to resume maps to the companion's `--resume ` or `--resume-last`. Never invent a session uuid; only uuids recorded by finished ordinary companion tasks are resumable. Every mode records strict, so legacy workspace or unknown profiles fail closed and require a fresh task. Resume also requires the same mode and memory boundary as the source task. A memory-enabled session must be resumed with `--memory`, a memory-disabled session must be resumed without it, and inconsistent recorded memory modes fail closed. `--resume-last` selects the newest compatible terminal task and requires the current Claude session when one is available. Resolved metadata for a short cwd uses only its exact URL-encoded directory. A long cwd may scan bounded session-id candidates but accepts only an exact decoded or `.cwd` match; it never falls back to a sole candidate from another cwd. -- Continuity policy defaults to `manual`, which never resumes without `--resume` or `--resume-last`. `/grok:setup --continuity claude-session` persists automatic affinity for an ordinary task that has a prompt, is not best-of-n, does not request explicit resume, and does not pass `--fresh`. Affinity selects only the newest `done` ordinary task from the same Claude session, exact resolved cwd, mode, strict profile, and memory boundary. Fusion routed briefs identified by their routing header do not receive automatic affinity and stay fresh unless explicitly resumed. `--fresh` starts a new session for one task and cannot be combined with either resume option. `GROK_COMPANION_CONTINUITY_POLICY` may override the persisted policy with `manual` or `claude-session`. +- Continuity policy defaults to `manual`, which never resumes without `--resume` or `--resume-last`. `/grok:setup --continuity claude-session` persists automatic affinity for an ordinary task that has a prompt, does not request explicit resume, and does not pass `--fresh`. Affinity selects only the newest `done` ordinary task from the same Claude session, exact resolved cwd, mode, strict profile, and memory boundary. Fusion routed briefs identified by their routing header do not receive automatic affinity and stay fresh unless explicitly resumed. `--fresh` starts a new session for one task and cannot be combined with either resume option. `GROK_COMPANION_CONTINUITY_POLICY` may override the persisted policy with `manual` or `claude-session`. - `history` exposes a safe canonical projection of companion job metadata and resumable session identifiers. It defaults to the current workspace and 50 newest records, `--all` includes every recorded workspace, `--limit` accepts 1 to 500, and `--cwd` selects an exact workspace when `--all` is absent. It does not read native Grok conversation contents, briefs, stored results, or logs. - Leave `--model` and `--effort` unset so Grok's own config rules apply, unless the user explicitly asks for a specific model or effort level. - `--cwd` scopes the workspace for task, review, status, history, result, cancel, and stats. Status, result, and cancel search all recorded workspaces by strict job id when `--cwd` is omitted, while history remains workspace-scoped unless `--all` is explicit. A bad working directory fails before a job record is created. Worktree isolation means creating a real Git worktree before launch and supplying that canonical path as the actual cwd. Headless `--worktree` and `--worktree-ref` are not a substitute. diff --git a/plugins/grok/skills/grok-prompting/SKILL.md b/plugins/grok/skills/grok-prompting/SKILL.md index c90a53b..e6c4019 100644 --- a/plugins/grok/skills/grok-prompting/SKILL.md +++ b/plugins/grok/skills/grok-prompting/SKILL.md @@ -21,7 +21,7 @@ Context rules: - Include context generously. Grok's context window is large; size briefs against the live `grok models` output rather than hardcoded numbers (the flagship default carries a 500k window). Err on the side of pasting relevant code, error output, and prior findings rather than referring to them. - Do not reference the Claude conversation, earlier turns, or "the change we discussed". The brief is the only context Grok gets. -- Do not rely on continuity or memory to supply required task facts. Automatic affinity is limited to ordinary direct tasks with a matching Claude session, exact resolved working directory, mode, and memory setting. Fusion routed work does not receive automatic affinity, while review, stop gate, best-of-n, and `--fresh` start without prior Grok session context. +- Do not rely on continuity or memory to supply required task facts. Automatic affinity is limited to ordinary direct tasks with a matching Claude session, exact resolved working directory, mode, and memory setting. Fusion routed work does not receive automatic affinity, while review, stop gate, and `--fresh` start without prior Grok session context. - Treat `--memory` as a data disclosure choice. Upstream first-turn memory injection can add relevant global or workspace memory to the model context sent to xAI, and that internal injection is not blocked by the companion's model-facing Read deny for `~/.grok/memory/**`. Upstream automatic saving usually requires at least three real user prompts in the same resumed session, so one Fusion task should not be expected to create new memory. Output contract: @@ -33,4 +33,4 @@ Output contract: Permissions: - Always state whether Grok has write permission. In write mode, tell it to edit files directly and report the touched paths. In consult mode, tell it to propose changes without editing. -- Read only consult briefs must state that Grok may invoke only file read, list, and search tools, plus web search and fetch when `--web` is present. Shell commands, tests, git, builds, edits, MCP tools, native questions, search delegation, tool delegation, and subagents are unavailable to the model. The hard tool filter or permission gate cancels calls outside that set, but it does not prove that native MCP servers, plugins, or hooks configured under `~/.grok` did not start during agent construction. Write capable briefs may use `search_replace` for existing or new files and may run shell commands, but still cannot invoke native questions, delegated search or tools, MCP, or Agent outside best-of-n. Every mode uses strict, so a requested user toolchain outside its readable roots may be unavailable; never suggest a workspace downgrade. +- Read only consult briefs must state that Grok may invoke only file read, list, and search tools, plus web search and fetch when `--web` is present. Shell commands, tests, git, builds, edits, MCP tools, native questions, search delegation, tool delegation, and subagents are unavailable to the model. The hard tool filter or permission gate cancels calls outside that set, but it does not prove that native MCP servers, plugins, or hooks configured under `~/.grok` did not start during agent construction. Write capable briefs may use `search_replace` for existing or new files and may run shell commands, but still cannot invoke native questions, delegated search or tools, MCP, or Agent. Every mode uses strict, so a requested user toolchain outside its readable roots may be unavailable; never suggest a workspace downgrade. diff --git a/plugins/grok/skills/setup/SKILL.md b/plugins/grok/skills/setup/SKILL.md index 461de82..e6221de 100644 --- a/plugins/grok/skills/setup/SKILL.md +++ b/plugins/grok/skills/setup/SKILL.md @@ -12,8 +12,9 @@ Present the setup output to the user. - If the Grok CLI is missing, preserve the install guidance from the command output. - Model and effort defaults belong in `~/.grok/config.toml`, not in plugin flags. Preserve that guidance when present. -- Preserve the reported continuity policy. `manual` is the default and resumes only when the caller explicitly supplies `--resume` or `--resume-last`. `/grok:setup --continuity claude-session` persists automatic affinity for ordinary direct tasks from the same Claude session, exact resolved working directory, mode, and memory setting. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed. Review, stop gate, and best-of-n remain fresh. `/grok:task --fresh` bypasses affinity for one task. -- Preserve the capability readiness line in text output. With `--json`, also preserve capability detail and every per-flag verdict across the plugin surface: `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, `--no-auto-update`, `--permission-mode`, `--allow`, `--disable-web-search`, `--always-approve`, `--json-schema`, `--best-of-n`, `--no-wait-for-background`, and `--background-wait-timeout`; `--no-subagents` is an informational compatibility hint because current headless mode accepts it without applying it. Top-level `ready` requires the binary, private data directory access, every universal and mode-specific capability, and a bounded background-wait path. Every managed run requires a new matching `ProfileApplied` sandbox event containing its unique private `TMPDIR` before sending the prompt because the shared event log exposes no run id or pid. Builder tracing starts only after stdin is consumed; fallback or unmatched warnings terminate early, and a successful close is rejected without positive applied evidence. `/fusion:doctor` audits the transport, environment isolation, worktree, and future ACP boundaries after an upgrade. +- Preserve the reported continuity policy. `manual` is the default and resumes only when the caller explicitly supplies `--resume` or `--resume-last`. `/grok:setup --continuity claude-session` persists automatic affinity for ordinary direct tasks from the same Claude session, exact resolved working directory, mode, and memory setting. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed. Review and stop gate runs remain fresh. `/grok:task --fresh` bypasses affinity for one task. +- Preserve the capability readiness line in text output. With `--json`, also preserve capability detail and every per-flag verdict across the plugin surface: `--prompt-file`, `--output-format`, `--sandbox`, `--tools`, `--disallowed-tools`, `--deny`, `--max-turns`, `--no-auto-update`, `--permission-mode`, `--allow`, `--disable-web-search`, `--always-approve`, `--json-schema`, and `--no-wait-for-background`; `--no-subagents` is an informational compatibility hint because current headless mode accepts it without applying it. Top-level `ready` requires the binary, private data directory access, and every universal and mode-specific capability. Every managed run requires a new matching `ProfileApplied` sandbox event containing its unique private `TMPDIR` before sending the prompt because the shared event log exposes no run id or pid. Builder tracing starts only after stdin is consumed; fallback or unmatched warnings terminate early, and a successful close is rejected without positive applied evidence. `/fusion:doctor` audits the transport, environment isolation, worktree, and future ACP boundaries after an upgrade. +- Preserve the advisory line covering whether a `grok doctor` subcommand is available, probed with a short bounded spawn regardless of its output format. It is informational only and never changes top-level `ready`. - A CLI installation, version, or required capability preflight failure persists and renders failure kind `setup`. Preserve its upgrade or repair guidance and tell the user to rerun `/grok:setup`; do not retry the unchanged task against the incompatible surface. - The primary switch for the stop-time review gate is the plugin's `Stop gate review` setting in Claude Code's plugin configuration: when enabled, the Stop hook asks Grok to review the working tree diff whenever Claude Code stops with uncommitted changes. Only `BLOCK: ` on the first nonempty reply line blocks the stop; a preamble before `BLOCK` and infrastructure failures fail open. SessionEnd attempts verified process cleanup for jobs owned by that Claude session and removes verified unused raw transports, but it does not delete terminal job records, briefs, or logs. `--enable-stop-gate` and `--disable-stop-gate` remain as a scripting fallback that persists the same toggle locally; they only take effect when the plugin setting is left unset. The report shows the current gate state. - Native Grok sessions are retained by upstream storage for 30 days by default. `~/.grok/config.toml` accepts a positive integer at `[storage] cleanup_ttl_days`; `0` falls back to the default 30 days. The companion does not promise automatic garbage collection for its own job ledger under `~/.claude/plugins/data/grok-claude-code-fusion/`; clearing that ledger requires manually deleting the data directory after no jobs need collection or resume evidence. diff --git a/plugins/grok/skills/task/SKILL.md b/plugins/grok/skills/task/SKILL.md index 446463d..6826ff6 100644 --- a/plugins/grok/skills/task/SKILL.md +++ b/plugins/grok/skills/task/SKILL.md @@ -1,7 +1,7 @@ --- name: task -description: Delegates a coding or consultation task to the local Grok CLI. Use under a protected Grok role (burst, independence, live-web, large-context, best-of-n) or on explicit user request for Grok. -argument-hint: '[--write] [--web] [--memory] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--max-turns ] [--best-of-n ] [--cwd ] [--json] [what Grok should do]' +description: Delegates a coding or consultation task to the local Grok CLI. Use under a protected Grok role (burst, independence, live-web, large-context) or on explicit user request for Grok. +argument-hint: '[--write] [--web] [--memory] [--background] [--resume |--resume-last|--fresh] [--model ] [--effort ] [--max-turns ] [--cwd ] [--json] [what Grok should do]' allowed-tools: Agent --- @@ -11,12 +11,12 @@ Execution rules: - Treat the complete raw slash command request as opaque data. Do not parse, strip, quote, escape, normalize, or place any part of it in Bash. - Dispatch the run as one foreground subagent of type `grok:grok-rescue` via the `Agent` tool with `run_in_background: false`. Tell the rescue agent this is a direct user selected Grok lane and that the raw request begins after a fixed delimiter and continues to the end of the Agent prompt. Parallel work uses multiple foreground Agent calls in one message. Never run the companion in the main loop and never ask the user how to run it. -- The rescue agent stages the raw request through the private one time transport. Runtime flags such as `--write`, `--web`, `--memory`, `--background`, resume, `--fresh`, model, effort, max turns, best of n, cwd, and JSON stay inside that raw request and are parsed only by the companion. -- Cross-session memory is off by default and can be enabled only for an ordinary task through an explicit `--memory`. Relevant Grok memory may then be injected internally into the model context sent to xAI. `--best-of-n`, review, and stop gate runs always keep memory off. Upstream automatic saving usually requires at least three real user prompts in the same resumed session, so a single Fusion task usually only reads existing memory and does not guarantee that new memory is saved. +- The rescue agent stages the raw request through the private one time transport. Runtime flags such as `--write`, `--web`, `--memory`, `--background`, resume, `--fresh`, model, effort, max turns, cwd, and JSON stay inside that raw request and are parsed only by the companion. +- Cross-session memory is off by default and can be enabled only for an ordinary task through an explicit `--memory`. Relevant Grok memory may then be injected internally into the model context sent to xAI. Review and stop gate runs always keep memory off. Upstream automatic saving usually requires at least three real user prompts in the same resumed session, so a single Fusion task usually only reads existing memory and does not guarantee that new memory is saved. - Session continuity defaults to manual. When `/grok:setup --continuity claude-session` is selected, an ordinary direct task may automatically resume the newest compatible completed task from the same Claude session, exact resolved working directory, mode, and memory setting. `--fresh` bypasses that affinity and starts a new Grok session. Fusion routed briefs do not receive automatic affinity and stay fresh unless explicitly resumed. - Explicit and automatic resume preserve the recorded memory boundary. A session created with `--memory` must be resumed with `--memory`, and a session created without it must be resumed without it. - An explicit raw `--background` creates a manual detached job. Without that flag, the companion remains foreground regardless of task size or expected duration. -- Ordinary runs hard disable native Agent and suppress or explicitly bound native background tool waiting. Best-of-n is the only task mode that retains native Agent, and its bounded native wait remains inside the foreground companion deadline. Neither behavior creates a detached receipt. +- Ordinary runs hard disable native Agent and pass `--no-wait-for-background`. Neither control creates a detached receipt. - Relay the subagent's returned companion output verbatim, exactly as-is, including the grok-session and job lines. - Do not paraphrase, summarize, or add commentary before or after it. - For a background run, the companion prints the job id plus `/grok:status` and `/grok:result` usage hints. Preserve them. diff --git a/tests/continuity-memory.test.mjs b/tests/continuity-memory.test.mjs index 9352906..9dc680b 100644 --- a/tests/continuity-memory.test.mjs +++ b/tests/continuity-memory.test.mjs @@ -191,7 +191,7 @@ test("Fusion routed briefs reject memory and never receive automatic continuity" assert.equal(grokInvocations(sandbox).length, 2); }); -test("legacy review, stop gate, and best-of-n jobs mislabeled as tasks are never resume-last candidates", (t) => { +test("legacy review and stop gate jobs mislabeled as tasks are never resume-last candidates", (t) => { const sandbox = makeSandbox(t); const env = envFor(sandbox, { CLAUDE_CODE_SESSION_ID: "claude-current" }); seedFinishedJob(sandbox, { @@ -204,13 +204,6 @@ test("legacy review, stop gate, and best-of-n jobs mislabeled as tasks are never sessionId: "33333333-3333-7333-8333-333333333333", request: { maxTurns: 15 } }); - seedFinishedJob(sandbox, { - jobClass: "task", - mode: "write", - sessionId: "44444444-4444-7444-8444-444444444444", - request: { bestOfN: 2 } - }); - const consult = runTask(sandbox, ["--resume-last", "continue consult"], env); assert.notEqual(consult.status, 0); assert.match(consult.stderr, /No finished consult grok task/); @@ -218,37 +211,7 @@ test("legacy review, stop gate, and best-of-n jobs mislabeled as tasks are never assert.notEqual(write.status, 0); assert.match(write.stderr, /No finished write grok task/); assert.equal(grokInvocations(sandbox).length, 0); - assert.equal(jobRecords(sandbox.dataDir).length, 3); -}); - -test("best-of-n rejects resume and memory before creating a job", (t) => { - const resumeSandbox = makeSandbox(t); - const resume = runTask( - resumeSandbox, - [ - "--best-of-n", - "2", - "--resume", - "55555555-5555-7555-8555-555555555555", - "tournament" - ], - envFor(resumeSandbox) - ); - assert.notEqual(resume.status, 0); - assert.match(resume.stderr, /best-of-n always starts fresh/); - assert.equal(jobRecords(resumeSandbox.dataDir).length, 0); - assert.equal(grokInvocations(resumeSandbox).length, 0); - - const memorySandbox = makeSandbox(t); - const memory = runTask( - memorySandbox, - ["--best-of-n", "2", "--memory", "tournament"], - envFor(memorySandbox) - ); - assert.notEqual(memory.status, 0); - assert.match(memory.stderr, /best-of-n always disables cross-session memory/); - assert.equal(jobRecords(memorySandbox.dataDir).length, 0); - assert.equal(grokInvocations(memorySandbox).length, 0); + assert.equal(jobRecords(sandbox.dataDir).length, 2); }); test("memory is disabled by default and explicit --memory enables it in the Grok child", (t) => { diff --git a/tests/exec-args.test.mjs b/tests/exec-args.test.mjs index fee65e6..f0220ae 100644 --- a/tests/exec-args.test.mjs +++ b/tests/exec-args.test.mjs @@ -31,9 +31,7 @@ const consultAllows = ["Read", "Grep"]; const consultTools = "read_file,grep,list_dir"; const consultWebTools = "read_file,grep,list_dir,web_search,web_fetch"; const writeTools = "read_file,grep,list_dir,search_replace,run_terminal_cmd"; -const tournamentTools = `${writeTools},Agent`; -const nonTournamentDisallowedTools = "Agent,search_tool,use_tool,ask_user_question"; -const tournamentDisallowedTools = "search_tool,use_tool,ask_user_question"; +const disallowedTools = "Agent,search_tool,use_tool,ask_user_question"; const consultDenies = ["Edit", "Write", "Bash", "MCPTool(*)"]; @@ -141,7 +139,7 @@ test("consult task argv pins the strict sandbox and hard read-only tool surface" assert.ok(hasPair(argv, "--max-turns", "60")); assert.ok(hasPair(argv, "--permission-mode", "default")); assert.ok(hasPair(argv, "--tools", consultTools)); - assert.ok(hasPair(argv, "--disallowed-tools", nonTournamentDisallowedTools)); + assert.ok(hasPair(argv, "--disallowed-tools", disallowedTools)); assert.deepStrictEqual([...flagValues(argv, "--allow")].sort(), [...consultAllows].sort()); const denyRules = flagValues(argv, "--deny"); for (const rule of consultDenies) { @@ -152,7 +150,6 @@ test("consult task argv pins the strict sandbox and hard read-only tool surface" assert.ok(!argv.includes("-m"), "Model must not be passed by default."); assert.ok(!argv.includes("--effort"), "Effort must not be passed by default."); assert.ok(!argv.includes("--always-approve")); - assert.ok(!argv.includes("--best-of-n")); assert.ok(!argv.includes("-r")); assert.ok(!argv.includes("Bash(gh pr view*)")); assert.ok(!argv.includes("Bash(git diff*)")); @@ -212,7 +209,7 @@ test("write task argv includes its explicit tool surface, always-approve, and sa assert.ok(argv.includes("--always-approve")); assert.ok(hasPair(argv, "--sandbox", "strict")); assert.ok(hasPair(argv, "--tools", writeTools)); - assert.ok(hasPair(argv, "--disallowed-tools", nonTournamentDisallowedTools)); + assert.ok(hasPair(argv, "--disallowed-tools", disallowedTools)); const denyRules = flagValues(argv, "--deny"); for (const rule of writeDenies) { assert.ok(denyRules.includes(rule), rule); @@ -225,26 +222,6 @@ test("write task argv includes its explicit tool surface, always-approve, and sa assert.ok(!argv.includes("--effort")); }); -test("best-of-n argv enables Agent, keeps meta tools disabled, implies write mode, and keeps the denies", (t) => { - const sandbox = makeSandbox(t); - const result = runCompanion(["task", "--best-of-n", "2", "compete on this"], { - cwd: sandbox.workDir, - env: envFor(sandbox), - }); - assert.strictEqual(result.status, 0, result.stderr); - const argv = singleInvocation(sandbox); - assert.ok(hasPair(argv, "--best-of-n", "2")); - assert.ok(!argv.includes("--no-subagents")); - assert.ok(hasPair(argv, "--tools", tournamentTools)); - assert.ok(hasPair(argv, "--disallowed-tools", tournamentDisallowedTools)); - assert.ok(argv.includes("--always-approve")); - const denyRules = flagValues(argv, "--deny"); - for (const rule of writeDenies) { - assert.ok(denyRules.includes(rule), rule); - } - assert.ok(denyRules.includes("Read(**/.grok/auth.json)")); -}); - test("resume maps to -r with the given session uuid", (t) => { const sandbox = makeSandbox(t); const stdinFile = path.join(sandbox.root, "resume-stdin.txt"); @@ -453,14 +430,6 @@ test("max-turns is overridable from the command line", (t) => { assert.deepStrictEqual(flagValues(argv, "--max-turns"), ["7"]); }); -test("best-of-n outside 2 to 10 is rejected", (t) => { - const sandbox = makeSandbox(t); - const result = runCompanion(["task", "--best-of-n", "11", "hello"], { cwd: sandbox.workDir, env: envFor(sandbox) }); - assert.notStrictEqual(result.status, 0); - assert.match(result.stderr, /between 2 and 10/); - assert.strictEqual(readInvocations(sandbox.argsFile).length, 0); -}); - test("write task argv omits the gh read-only allow rules", (t) => { const sandbox = makeSandbox(t); const result = runCompanion(["task", "--write", "change the code"], { @@ -495,7 +464,7 @@ test("consult denies every shell, MCP, edit, delegation, and meta-tool surface", for (const rule of ["Edit", "Write", "Bash", "MCPTool(*)"]) { assert.ok(denies.includes(rule), `Expected ${rule} in consult deny rules.`); } - assert.ok(hasPair(argv, "--disallowed-tools", nonTournamentDisallowedTools)); + assert.ok(hasPair(argv, "--disallowed-tools", disallowedTools)); }); test("web flag drops the web search disable and stays consult", (t) => { @@ -535,7 +504,7 @@ test("write mode never gains web tool allow rules", () => { const argv = buildArgv({ mode: "write", web }); assert.strictEqual(flagValues(argv, "--allow").length, 0); assert.ok(hasPair(argv, "--tools", web ? `${writeTools},web_search,web_fetch` : writeTools)); - assert.ok(hasPair(argv, "--disallowed-tools", nonTournamentDisallowedTools)); + assert.ok(hasPair(argv, "--disallowed-tools", disallowedTools)); assert.ok(!argv.includes("WebSearch")); assert.ok(!argv.includes("WebFetch")); assert.ok(argv.includes("--always-approve")); diff --git a/tests/fake-grok b/tests/fake-grok index abeb49b..e1b34fd 100755 --- a/tests/fake-grok +++ b/tests/fake-grok @@ -14,6 +14,14 @@ if (argsFile) { fs.appendFileSync(argsFile, JSON.stringify(process.argv.slice(2)) + "\n"); } + if (process.argv[2] === "doctor") { + if (process.env.FAKE_GROK_DOCTOR_UNAVAILABLE === "1") { + process.stderr.write("error: unrecognized subcommand 'doctor'\n"); + process.exit(2); + } + process.stdout.write("grok doctor: environment diagnostics\n"); + process.exit(0); + } if (process.env.FAKE_GROK_ENV_FILE) { const trackedEnv = Object.fromEntries( Object.entries(process.env) diff --git a/tests/grok-transport.test.mjs b/tests/grok-transport.test.mjs index 518d3fd..20ffeec 100644 --- a/tests/grok-transport.test.mjs +++ b/tests/grok-transport.test.mjs @@ -58,7 +58,7 @@ function ageRawTransport(transport, ageMs) { } test("every Grok slash command keeps raw arguments out of shell command text", () => { - for (const name of ["task", "review", "best-of-n", "status", "history", "result", "cancel", "stats", "setup"]) { + for (const name of ["task", "review", "status", "history", "result", "cancel", "stats", "setup"]) { const file = path.join(repoRoot, "plugins", "grok", "skills", name, "SKILL.md"); const content = fs.readFileSync(file, "utf8"); assert.equal(content.match(/\$ARGUMENTS/g)?.length, 1, name); @@ -376,32 +376,6 @@ test("task records valid Fusion roles and rejects invalid role values", (t) => { assert.equal(invalidRecord.request.role, null); }); -test("fixed best-of-n transport defaults preserve opaque prompts and accept the slash command n alias", (t) => { - const sandbox = makeSandbox(t); - const stdinLog = path.join(sandbox.root, "best-of-n-stdin.jsonl"); - const env = envFor(sandbox, { FAKE_GROK_STDIN_LOG: stdinLog }); - const requests = [ - { raw: "--json -- tournament default", expected: "2", prompt: "tournament default" }, - { raw: "--json --n 4 -- tournament explicit", expected: "4", prompt: "tournament explicit" } - ]; - - for (const request of requests) { - const transport = createRawTransport(sandbox, env, request.raw); - const result = runCompanion([ - "task", - "--transport-default-best-of-n", - "--raw-args-token", - transport.token - ], { cwd: sandbox.workDir, env }); - assert.equal(result.status, 0, result.stderr); - } - - const invocations = readInvocations(sandbox.argsFile); - assert.deepEqual(invocations.map((argv) => flagValues(argv, "--best-of-n")[0]), ["2", "4"]); - const prompts = fs.readFileSync(stdinLog, "utf8").trim().split("\n").map((line) => JSON.parse(line)); - assert.deepEqual(prompts, requests.map((request) => request.prompt)); -}); - test("invalid Grok session identifiers fail as transport errors and cannot reach summary paths or resume", (t) => { const sandbox = makeSandbox(t); const grokHome = path.join(sandbox.root, "grok-home"); diff --git a/tests/grok-upstream-contract.test.mjs b/tests/grok-upstream-contract.test.mjs index 056badc..82a9a42 100644 --- a/tests/grok-upstream-contract.test.mjs +++ b/tests/grok-upstream-contract.test.mjs @@ -100,7 +100,7 @@ function directOptions(sandbox, mode, extra = {}) { }; } -test("non-tournament runs disable Agent through the CLI and environment while tournaments retain their background budget", () => { +test("all managed runs disable Agent through the CLI and environment", () => { const env = { GROK_COMPANION_CAPABILITIES: grokCompanionCapabilities }; const toolSurfaces = { consult: "read_file,grep,list_dir", @@ -116,30 +116,7 @@ test("non-tournament runs disable Agent through the CLI and environment while to ); assert.deepEqual(flagValues(argv, "--tools"), [toolSurfaces[mode]], mode); assert.ok(argv.includes("--no-wait-for-background"), mode); - assert.equal(flagValues(argv, "--background-wait-timeout").length, 0, mode); } - - const tournament = buildGrokArgs({ - briefFile: "/tmp/grok-brief.md", - mode: "write", - bestOfN: 3, - timeoutMs: 90000, - env, - }); - assert.ok(!tournament.includes("--no-subagents")); - assert.deepEqual( - flagValues(tournament, "--disallowed-tools"), - ["search_tool,use_tool,ask_user_question"] - ); - assert.deepEqual( - flagValues(tournament, "--tools"), - ["read_file,grep,list_dir,search_replace,run_terminal_cmd,Agent"] - ); - assert.ok(!tournament.includes("--no-wait-for-background")); - const [backgroundWait] = flagValues(tournament, "--background-wait-timeout").map(Number); - assert.ok(Number.isInteger(backgroundWait) && backgroundWait >= 1); - assert.ok(backgroundWait * 1000 < 90000); - assert.ok(90000 - backgroundWait * 1000 >= 30000); }); for (const requiredCapability of ["--prompt-file", "--output-format", "--sandbox", "--tools", "--disallowed-tools", "--deny", "--max-turns", "--no-auto-update", "--permission-mode", "--allow", "--disable-web-search"]) { @@ -176,11 +153,11 @@ test("write mode fails closed before launch without always-approve support", (t) assert.deepEqual(readInvocations(sandbox.argsFile), []); }); -test("ordinary runs fail closed when Grok cannot skip or bound background work", (t) => { +test("managed runs fail closed when Grok cannot skip background work", (t) => { const sandbox = makeSandbox(t); const capabilities = grokCompanionCapabilities .split(",") - .filter((capability) => !["--no-wait-for-background", "--background-wait-timeout"].includes(capability)) + .filter((capability) => capability !== "--no-wait-for-background") .join(","); const result = runCompanion(["task", "inspect the repository"], { cwd: sandbox.workDir, @@ -189,76 +166,44 @@ test("ordinary runs fail closed when Grok cannot skip or bound background work", assert.notEqual(result.status, 0); assert.match(result.stderr, /--no-wait-for-background/); - assert.match(result.stderr, /--background-wait-timeout/); assert.deepEqual(readInvocations(sandbox.argsFile), []); }); -test("ordinary runs fall back to a bounded wait when no-wait is unavailable", (t) => { +test("setup reports the injected capability verdict without an extra help probe", (t) => { const sandbox = makeSandbox(t); - const capabilities = grokCompanionCapabilities - .split(",") - .filter((capability) => capability !== "--no-wait-for-background") - .join(","); - const result = runCompanion(["task", "inspect the repository"], { + const result = runCompanion(["setup", "--json"], { cwd: sandbox.workDir, - env: envFor(sandbox, { - GROK_COMPANION_CAPABILITIES: capabilities, - GROK_COMPANION_TIMEOUT_MS: "90000", - }), + env: envFor(sandbox), }); assert.equal(result.status, 0, result.stderr); - const [argv] = readInvocations(sandbox.argsFile); - assert.ok(!argv.includes("--no-wait-for-background")); - assert.deepEqual(flagValues(argv, "--background-wait-timeout"), ["60"]); -}); - -test("best-of-n fails closed without bounded background wait support", (t) => { - const sandbox = makeSandbox(t); - const capabilities = grokCompanionCapabilities - .split(",") - .filter((capability) => capability !== "--background-wait-timeout") - .join(","); - const result = runCompanion(["task", "--best-of-n", "2", "compare implementations"], { - cwd: sandbox.workDir, - env: envFor(sandbox, { GROK_COMPANION_CAPABILITIES: capabilities }), - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /--background-wait-timeout/); - assert.deepEqual(readInvocations(sandbox.argsFile), []); -}); - -test("best-of-n fails closed before launch without tournament flag support", (t) => { - const sandbox = makeSandbox(t); - const capabilities = grokCompanionCapabilities - .split(",") - .filter((capability) => capability !== "--best-of-n") - .join(","); - const result = runCompanion(["task", "--best-of-n", "2", "compare implementations"], { - cwd: sandbox.workDir, - env: envFor(sandbox, { GROK_COMPANION_CAPABILITIES: capabilities }) - }); - - assert.notEqual(result.status, 0); - assert.match(result.stderr, /--best-of-n/); - assert.deepEqual(readInvocations(sandbox.argsFile), []); + const report = JSON.parse(result.stdout); + assert.equal(report.ready, true); + assert.equal(report.capabilities.ready, true); + assert.equal(report.capabilities.flags["--tools"], true); + assert.equal(report.doctorCommand.available, true); + assert.deepEqual(readInvocations(sandbox.argsFile), [["--version"], ["doctor", "--help"]]); }); -test("setup reports the injected capability verdict without an extra help probe", (t) => { +test("setup reports grok doctor as unavailable when the subcommand is missing", (t) => { const sandbox = makeSandbox(t); const result = runCompanion(["setup", "--json"], { cwd: sandbox.workDir, - env: envFor(sandbox), + env: envFor(sandbox, { FAKE_GROK_DOCTOR_UNAVAILABLE: "1" }), }); assert.equal(result.status, 0, result.stderr); const report = JSON.parse(result.stdout); + assert.equal(report.doctorCommand.available, false); assert.equal(report.ready, true); - assert.equal(report.capabilities.ready, true); - assert.equal(report.capabilities.flags["--tools"], true); - assert.equal(report.capabilities.flags["--background-wait-timeout"], true); - assert.deepEqual(readInvocations(sandbox.argsFile), [["--version"]]); + assert.deepEqual(readInvocations(sandbox.argsFile), [["--version"], ["doctor", "--help"]]); + + const rendered = runCompanion(["setup"], { + cwd: sandbox.workDir, + env: envFor(sandbox, { FAKE_GROK_DOCTOR_UNAVAILABLE: "1" }), + }); + assert.equal(rendered.status, 0, rendered.stderr); + assert.match(rendered.stdout, /grok doctor: not available/); }); test("Grok child environment removes Claude integration state and disables implicit context bridges", async (t) => { diff --git a/tests/history.test.mjs b/tests/history.test.mjs index 340672c..2d60e51 100644 --- a/tests/history.test.mjs +++ b/tests/history.test.mjs @@ -218,7 +218,7 @@ test("history reports resumability, job class, and Claude session ownership with }, { id: "88888888888888888888888888888888", - jobClass: "best_of_n", + jobClass: "unknown", status: "cancelled", mode: "write", sessionId: "88888888-8888-7888-8888-888888888888", diff --git a/tests/lib/companion-harness.mjs b/tests/lib/companion-harness.mjs index 8359efe..2188b43 100644 --- a/tests/lib/companion-harness.mjs +++ b/tests/lib/companion-harness.mjs @@ -24,10 +24,8 @@ export const grokCompanionCapabilities = [ "--allow", "--disable-web-search", "--always-approve", - "--best-of-n", "--no-subagents", "--no-wait-for-background", - "--background-wait-timeout", "--json-schema", ].join(","); diff --git a/tests/routing-policy.test.mjs b/tests/routing-policy.test.mjs index 9609c16..55a66ad 100644 --- a/tests/routing-policy.test.mjs +++ b/tests/routing-policy.test.mjs @@ -98,11 +98,13 @@ test("Codex admission preserves primary ownership before overflow", () => { assert.equal(frontmatterValue(codexRescue, "background"), "false"); }); -test("Grok automatic routing requires exactly one of five protected roles", () => { - const expected = ["burst", "independence", "live-web", "large-context", "best-of-n"]; - assert.deepEqual(protectedRoles(rules, /five protected routing roles: (.+?)\. Grok remains/), expected); - assert.deepEqual(protectedRoles(grokRescue, /Automatic Fusion routing must name one protected role: (.+?)\./), expected); +test("Grok automatic routing exposes four protected roles while the companion retains legacy support", () => { + const expected = ["burst", "independence", "live-web", "large-context"]; + const legacyExpected = [...expected]; + assert.deepEqual(protectedRoles(rules, /four protected routing roles: (.+?)\. Grok remains/), expected); + assert.deepEqual(protectedRoles(grokRescue, /Automatic Fusion routing must name one protected role: (.+?)\./), legacyExpected); assert.match(rules, /The single header line for every automatically routed Grok brief includes `grok-role: `/); + assert.match(rules, /where `` is exactly one of `burst`, `independence`, `live-web`, or `large-context`/); assert.match(rules, /a direct `\/grok:\*` command or explicit user request for Grok is a user selected override and does not need that field/); assert.match(rules, /it is not a generic alternative merely because it is idle or fast/); assert.match(grokRescue, /Do not take ordinary implementation merely because Grok is idle, fast, or bills to xAI\./); @@ -114,7 +116,7 @@ test("Grok automatic routing requires exactly one of five protected roles", () = assert.match(grokRescue, /An explicit incoming user `--background` stays inside the raw request\./); assert.match(grokReviewRunner, /return an output containing `phase: cleanup-required` instead of chaining/); assert.match(grokReviewRunner, /top level `status` is `running`, and `cleanupRequired` is not true/); - assert.match(readme, /Grok is a complementary specialist and burst lane with five protected roles/); + assert.match(readme, /Grok is a complementary specialist and burst lane with four protected roles/); assert.equal(frontmatterValue(grokRescue, "background"), "false"); }); @@ -125,7 +127,7 @@ test("Ultra expands capacity without changing lane ownership", () => { assert.ok(ultra.includes(`\`${role}\``), `Ultra must preserve the ${role} Grok role`); } assert.match(ultra, /Every Grok facet brief begins with a single routing header line containing exactly one `grok-role: ` field/); - assert.match(ultra, /choosing one of `burst`, `independence`, `live-web`, `large-context`, or `best-of-n` from the facet's actual purpose/); + assert.match(ultra, /choosing one of `burst`, `independence`, `live-web`, or `large-context` from the facet's actual purpose/); assert.match(ultra, /Do not include a second Grok role anywhere in that brief\./); assert.match(ultra, /Additional independent Codex implementation facets may use distinct orchestrator owned worktrees when their files are disjoint and verification needs no heavy setup\./); assert.match(ultra, /For each such facet, use `--write --cwd "" -- ` as the complete direct prompt to `codex:codex-rescue`\./); @@ -145,7 +147,7 @@ test("Ultra expands capacity without changing lane ownership", () => { test("the panel stays blind, asymmetric, evidence weighted, and main judged", () => { assert.match(panel, /In the standard external panel, every engine receives the same neutral brief, works with no knowledge of the other panelists/); assert.match(panel, /Never include a candidate answer, a leaning, any prior model opinion, or any context from this conversation\./); - assert.match(panel, /Its first line is the single routing header required by policy and includes `lane: panel`, `grok-role: independence`, and the explicit acceptance criteria/); + assert.match(panel, /Its first line is the single routing header required by policy and includes `lane: panel`, `grok-role: independence` \(one of the four permitted Grok roles\), and the explicit acceptance criteria/); assert.match(panel, /invoke `grok:grok-rescue` and `codex:codex-rescue` with the identical brief as their direct prompt/); assert.match(panel, /Do not set `run_in_background`; parallel tool calls provide overlap while each result remains owned and collected/); assert.match(panel, /A panel never runs with fewer than two tracks\./); diff --git a/tests/rules-sync.test.mjs b/tests/rules-sync.test.mjs index 1754e30..eebcce0 100644 --- a/tests/rules-sync.test.mjs +++ b/tests/rules-sync.test.mjs @@ -223,7 +223,6 @@ test("Grok rules document source verified headless boundaries", () => { const doctor = fs.readFileSync(path.join(repoRoot, "plugins", "fusion", "skills", "doctor", "SKILL.md"), "utf8"); const setup = fs.readFileSync(path.join(repoRoot, "plugins", "grok", "skills", "setup", "SKILL.md"), "utf8"); const stats = fs.readFileSync(path.join(repoRoot, "plugins", "grok", "skills", "stats", "SKILL.md"), "utf8"); - const bestOfN = fs.readFileSync(path.join(repoRoot, "plugins", "grok", "skills", "best-of-n", "SKILL.md"), "utf8"); const review = fs.readFileSync(path.join(repoRoot, "plugins", "grok", "skills", "review", "SKILL.md"), "utf8"); const contract = fs.readFileSync(path.join(repoRoot, "docs", "grok-contract.md"), "utf8"); const codexContract = fs.readFileSync(path.join(repoRoot, "docs", "codex-contract.md"), "utf8"); @@ -255,14 +254,13 @@ test("Grok rules document source verified headless boundaries", () => { assert.match(text, /--no-subagents/); assert.match(text, /--disallowed-tools Agent/); assert.match(text, /--no-wait-for-background/); - assert.match(text, /--background-wait-timeout/); assert.match(text, /--prompt-file \/dev\/stdin/); assert.match(text, /search_tool/); assert.match(text, /use_tool/); assert.match(text, /ask_user_question/); } - for (const text of [rules, troubleshooting, runtime, contract, readme, security, bestOfN]) { + for (const text of [rules, troubleshooting, runtime, contract, readme, security]) { assert.match(text, /--sandbox strict/); assert.doesNotMatch(text, /--sandbox workspace/); assert.match(text, /(?:never|no silent)[^\n.]*downgrade[^\n.]*workspace/i); @@ -346,9 +344,7 @@ test("Grok rules document source verified headless boundaries", () => { "--disable-web-search", "--always-approve", "--json-schema", - "--best-of-n", - "--no-wait-for-background", - "--background-wait-timeout" + "--no-wait-for-background" ]) { assert.match(setup, new RegExp(flag.replaceAll("-", "\\-"))); } @@ -367,8 +363,8 @@ test("Grok rules document source verified headless boundaries", () => { assert.match(contract, /Upstream wires only `bypassPermissions` at spawn/); assert.match(contract, /Upstream ships ACP today/); assert.match(contract, /The companion has not adopted ACP yet and continues per-call invocation/); - assert.match(codexContract, /failureKind: "setup".*below the tested minimum 0\.144\.0/); - assert.match(codexContract, /Versions above the tested 0\.144\.x window remain allowed with the setup compatibility advisory/); + assert.match(codexContract, /failureKind: "setup".*below the tested minimum 0\.145\.0/); + assert.match(codexContract, /The tested interval runs from 0\.145\.0 up to but excluding 0\.146\.0; versions at or above 0\.146\.0 remain allowed with the setup compatibility advisory/); assert.match(codexContract, /Codex configuration parse failures under `--strict-config`.*failureKind: "process"/); assert.match(sharedContract, /The Grok instance adds `sandbox`[^\n]*`transport`[^\n]*and `policy`/); assert.match(sharedContract, /`setup`: The installed CLI version or installation lacks a required adapter capability and fails capability preflight/); @@ -385,7 +381,7 @@ test("Grok rules document source verified headless boundaries", () => { assert.match(doctor, /raw path variants, symbolic links, and shell or indirect scripts can bypass/i); assert.match(doctor, /Bash\(grok inspect:\*\)/); assert.match(doctor, /Run `grok inspect --json` and review the reported `permissions`, `hooks`, `plugins`, `agents`, `mcpServers`, and `externalCompat` surfaces/); - assert.match(doctor, /successful Grok collection remains unverified until `\/fusion:stats --record-worker-acceptance/i); + assert.match(doctor, /successful Grok collection remains unverified until `\/fusion:stats --record =`/i); for (const text of [readme, security]) { assert.match(text, /minimum-version enforcement can still force an update/); assert.match(text, /file that is unlinked (?:immediately after open|as soon as it is opened)/); From eaaf7479c08f6917d4005155edc2e067b815bc2d Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Wed, 22 Jul 2026 15:38:10 +0800 Subject: [PATCH 5/7] fix(fusion): always normalize inline delegation guard dispatch counters --- .../scripts/inline-delegation-guard.mjs | 5 ++--- tests/inline-delegation-guard.test.mjs | 22 ------------------- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/plugins/fusion/scripts/inline-delegation-guard.mjs b/plugins/fusion/scripts/inline-delegation-guard.mjs index a79a832..132f432 100644 --- a/plugins/fusion/scripts/inline-delegation-guard.mjs +++ b/plugins/fusion/scripts/inline-delegation-guard.mjs @@ -851,9 +851,8 @@ function normalizeState(existing, now, waveGapMs = DEFAULT_FLEET_WAVE_GAP_MS) { const writeCount = Number.isFinite(existing.writeCount) && existing.writeCount >= 0 ? Math.floor(existing.writeCount) : 0; const dispatches = normalizeDispatches(existing.dispatches); const dispatchCount = totalDispatches(dispatches); - const hasPeriodCounter = Number.isFinite(existing.writesSinceDispatch) && existing.writesSinceDispatch >= 0; - const writesSinceDispatch = hasPeriodCounter ? Math.floor(existing.writesSinceDispatch) : dispatchCount === 0 ? writeCount : 0; - const advisedMultiples = hasPeriodCounter || dispatchCount === 0 ? normalizeAdvisedMultiples(existing.advisedMultiples) : []; + const writesSinceDispatch = Number.isFinite(existing.writesSinceDispatch) && existing.writesSinceDispatch >= 0 ? Math.floor(existing.writesSinceDispatch) : 0; + const advisedMultiples = normalizeAdvisedMultiples(existing.advisedMultiples); const dispatchLog = pruneExpiredLaunchedDispatches(normalizeDispatchLog(existing.dispatchLog), now); const createdAtMs = Date.parse(existing.createdAt); const lastDispatchAtMs = Date.parse(existing.lastDispatchAt); diff --git a/tests/inline-delegation-guard.test.mjs b/tests/inline-delegation-guard.test.mjs index b792e56..e57d4ae 100644 --- a/tests/inline-delegation-guard.test.mjs +++ b/tests/inline-delegation-guard.test.mjs @@ -785,28 +785,6 @@ test("dispatch ledger initializes when an existing state file has no dispatchLog assert.strictEqual(state.dispatchLog[0].lane, "fusion:fast-worker"); }); -test("legacy state without a period counter starts counting future writes after its recorded dispatches", (t) => { - const sandbox = makeSandbox(t); - fs.mkdirSync(sandbox.stateDir, { recursive: true }); - fs.writeFileSync( - stateFileFor(sandbox, "session-1"), - JSON.stringify({ writeCount: 7, dispatches: { codex: 1 }, advisedMultiples: [1], createdAt: "2026-07-10T00:00:00.000Z", updatedAt: "2026-07-10T00:00:00.000Z" }), - "utf8" - ); - - for (let index = 0; index < 4; index += 1) { - assert.strictEqual(run(sandbox, writePayload(sandbox)).stdout, ""); - } - const fifth = run(sandbox, writePayload(sandbox)); - assert.match(JSON.parse(fifth.stdout).hookSpecificOutput.permissionDecisionReason, /^5 inline writes happened since the most recent dispatch/); - - const state = readState(sandbox, "session-1"); - assert.strictEqual(state.writeCount, 12); - assert.strictEqual(state.writesSinceDispatch, 5); - assert.strictEqual(state.dispatchEpoch, 1); - assert.deepStrictEqual(state.advisedMultiples, [1]); -}); - test("legacy dispatch ledger entries normalize as confirmed launches", (t) => { const sandbox = makeSandbox(t); fs.mkdirSync(sandbox.stateDir, { recursive: true }); From 60ef104cbfae845728485bcb3c034770d9fe9567 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Wed, 22 Jul 2026 15:38:10 +0800 Subject: [PATCH 6/7] chore(fusion): bump verified codex and grok cli pins --- plugins/fusion/verified-versions.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/fusion/verified-versions.json b/plugins/fusion/verified-versions.json index 1f95c3d..5e3e842 100644 --- a/plugins/fusion/verified-versions.json +++ b/plugins/fusion/verified-versions.json @@ -1,5 +1,5 @@ { - "codex-cli": "0.144.4", - "grok": "0.2.101", - "verifiedAt": "2026-07-17" + "codex-cli": "0.145.0", + "grok": "0.2.110", + "verifiedAt": "2026-07-22" } From 3c1a5bc2d9d672093465159c11aaf7f8e9dfa580 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Wed, 22 Jul 2026 15:38:33 +0800 Subject: [PATCH 7/7] chore: release 0.0.35 --- .claude-plugin/marketplace.json | 8 ++++---- CHANGELOG.md | 11 +++++++++++ plugins/codex/.claude-plugin/plugin.json | 2 +- plugins/fusion/.claude-plugin/plugin.json | 2 +- plugins/grok/.claude-plugin/plugin.json | 2 +- 5 files changed, 18 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dc98f61..fb68ad9 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.34", + "version": "0.0.35", "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.34", + "version": "0.0.35", "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.34", + "version": "0.0.35", "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.34", + "version": "0.0.35", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1f482..2e199fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # changelog +## 0.0.35 + +- codex adapts to cli 0.145.0: task runs forward `--output-schema ` end to end (rescue raw args, exec argv, structured output capture, and a `structured: parsed/invalid/unavailable` footer in the render), adversarial review gains a json schema verdict contract at `plugins/codex/schemas/adversarial-review-verdict.schema.json`, native `review` rejects `--output-schema` because tested versions silently ignore it there, and the 0.144.4 models cache schema drift workaround is deleted with the tested interval moving to 0.145.0 to 0.146.0 +- grok adapts to cli 0.2.110: setup probes the new `grok doctor` capability and surfaces it as a setup advisory line, and ordinary managed runs now require `--no-wait-for-background` unconditionally, dropping the `--background-wait-timeout` compatibility fallback and its capability probe +- best-of-n is retired: the tournament skill is deleted and grok's protected roles shrink to four (burst, independence, live-web, large-context) across rules, skills, agents, docs, and tests +- settlement is centralized: engine acceptance writers move to a shared `engine-acceptance.mjs`, `/fusion:stats` records every verdict through the one `--record =` form (`--record-acceptance` and `--record-worker-acceptance` are gone), and a verdict recorded before a worker's record turns terminal queues instead of failing, settling automatically when the record lands +- the worker lifecycle settles queued verdicts on terminal transitions with the engine cascade, backfills peer identity from rescue wrapper stops (the SubagentStop matcher now also covers `codex:codex-rescue`, `grok:grok-rescue`, and `grok:grok-review-runner`), and guards terminal writers against reprocessing already settled records +- legacy codex plugin state support is dropped: the `codex-openai-codex` compatibility root, `FUSION_CODEX_INCLUDE_LEGACY`, legacy `completed`/`failed` terminal statuses, and log tail failure kind recovery are removed from stats, breaker, and monitor +- the inline delegation guard always normalizes persisted dispatch counters instead of branching on a legacy period counter +- verified engine pins move to codex-cli 0.145.0 and grok 0.2.110 + ## 0.0.34 - the settle-only advisory stops repeating itself: when settlement is legitimately deferred (a package's verification waiting on a sibling landing), the pending-verdict block re-emitted identically at every stop boundary with no new information; it now dedupes by a persisted pending-set signature, the same mechanism the in-flight advisory has used since 0.0.31, emitting once per distinct set and again only when a record enters or leaves; blocking collection demands are unaffected and refresh the signature when they emit combined diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index cc478a2..d9d27cd 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.34", + "version": "0.0.35", "description": "First party local Codex CLI delegation for tasks, reviews, resumable threads, and durable background jobs.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/.claude-plugin/plugin.json b/plugins/fusion/.claude-plugin/plugin.json index e859b1e..716e7c2 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.34", + "version": "0.0.35", "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/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index b1a58cf..f553298 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.34", + "version": "0.0.35", "description": "Local Grok CLI delegation: task, review, resumable history, background jobs, stats, and setup health checks.", "author": { "name": "Harry Yep"