diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6931452..03d772c 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.30", + "version": "0.0.31", "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.30", + "version": "0.0.31", "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.30", + "version": "0.0.31", "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.30", + "version": "0.0.31", "author": { "name": "Harry Yep" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a26bf9..9d6598e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # changelog +## 0.0.31 + +- the fleet default gets a per turn signal: a new `fleet-posture.mjs` UserPromptSubmit hook injects one advisory context line each turn naming the three package threshold, `/fusion:ultra`, and the `fleet-decline: ` protocol, because the default previously lived as one sentence deep inside a document loaded once at session start and fired almost never; `FUSION_FLEET_MODE=off` or a `fleet-mode` state file disables it, and the compact recovery echo now restates the fleet default at the moment of maximum context loss +- the orchestration policy stops competing with itself at its own trigger: the fleet default is hoisted into the operating model with an explicit split between the posture machine, which governs what the main loop touches, and execution width, which the fleet owns; the three manual fan out sites now name `/fusion:ultra` as the action for three or more independent packages, declines require a visible `fleet-decline: ` line whose premises reopen the question when they expire, and consolidation no longer lowers the fleet decision's package count +- the ultra skill reads as the default posture it is: `when_to_use` leads with task shape instead of explicit asks, the facet count follows the goal's package count with a floor of three, skipping a fleet shaped goal is a stated decline, and the convening ceremony of checkpoint, worktrees, and sibling declaration lists runs inside the skill instead of being prepaid by the main loop +- the inline guard notices narrow dispatching: completed dispatch waves are bucketed by `FUSION_FLEET_WAVE_GAP_MS` and a second consecutive wave of width two or less appends one advisory line naming `/fusion:ultra` and the decline marker; advisory only, no permission decision changes +- the in flight stop advisory stops repeating: it is deduplicated by the sorted in flight task id signature persisted in a new per session state file, so a dispatch wave produces one armed notice instead of one per turn end, each of which cost an extra model request +- worker records reach terminal from delivered notifications alone: `handleStop` scans the parent transcript's task notification delta with a persisted offset, bounded per pass and fail open, before cancellation classification, flipping owned records to `task_notification` collection or a terminal failure; this removes the deliberately failing `TaskOutput` probe from the normal collection path and stops the gate demanding `TaskStop` for tasks that already finished + ## 0.0.30 - claude worker results are collectable again: a worker's persisted `outputFile` was the transcript symlink, observed at 538KB against the `Read` tool's 256KB cap, so the gate demanded a read that could not be performed and every claude worker record stayed non terminal and therefore unsettleable; `SubagentStop` now writes a bounded final text artifact (head, truncation marker, tail, capped at 192KB) and points `outputFile` at it, while the launch response transcript path is kept only as `transcriptPath` diff --git a/plugins/codex/.claude-plugin/plugin.json b/plugins/codex/.claude-plugin/plugin.json index 7659bcb..4d3c73f 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.30", + "version": "0.0.31", "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 b5ec212..93ad42a 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.30", + "version": "0.0.31", "description": "Multi-model orchestration: tier agents, routing rules, blind panel, ultra fleet, model config, and drift doctor.", "author": { "name": "Harry Yep" diff --git a/plugins/fusion/hooks/hooks.json b/plugins/fusion/hooks/hooks.json index 0a76c0e..9f71364 100644 --- a/plugins/fusion/hooks/hooks.json +++ b/plugins/fusion/hooks/hooks.json @@ -26,12 +26,23 @@ "hooks": [ { "type": "command", - "command": "echo 'Context was compacted. Restate the session execution posture (coordinate, implement, or triage) in your next message. If the main loop has made five or more file edits since the last collected delegate result, posture is implement: package and dispatch before further product edits. Reassess routing for the remaining work: does anything now belong to fusion:deep-reasoner, a peer engine, or the panel?'", + "command": "echo 'Context was compacted. Restate the session execution posture (coordinate, implement, or triage) in your next message. If the main loop has made five or more file edits since the last collected delegate result, posture is implement: package and dispatch before further product edits. Reassess routing for the remaining work: does anything now belong to fusion:deep-reasoner, a peer engine, or the panel? If the remaining work decomposes into three or more independent packages, the fleet default applies: convene /fusion:ultra or state fleet-decline: .'", "timeout": 5 } ] } ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/fleet-posture.mjs\"", + "timeout": 10 + } + ] + } + ], "PreToolUse": [ { "matcher": "*", diff --git a/plugins/fusion/rules-manifest.json b/plugins/fusion/rules-manifest.json index 862d6f2..deadfae 100644 --- a/plugins/fusion/rules-manifest.json +++ b/plugins/fusion/rules-manifest.json @@ -1,7 +1,6 @@ { "format": 2, "hashes": [ - "13a4cb3f590c16e2fdd37f0ca08471460e86a8c8630b6f5961ac774ba0a37428", "16d5cc7a7a1df5908043379d4278bd5e7bd8f66a112de0804857ae3cdcc15a60", "206d5d62bfc699358790244597ba9f8d331c436dfc7af25467d39487a6c0815e", "3106f1dd92b8bc6ded59fe7f6b694eb947b9e6594522442b929474dbc9e6fd5f", @@ -23,6 +22,7 @@ "b3649bf8c8fb1f3ede0ccfc7fb727a8adf1e13628a18f4a84d08efbee75bd934", "b5283c0356c9004385157f3360ca81a3601253e370216b390049d069a29a2250", "c435cc1486eeff00f5b3a1c76017d46d8f05d30ebd248435d575a1461d7da5d4", + "c68785a9d73cc2cb83b740db7a80744ddd293e6da011c6a80a17b045f9219e17", "dd2c1475815da92b62b28ab9cf6599df7ccab7fa88617e367e8343a6ed515434", "e1feb3d17af193a1b8b34b090d4d504c594efb87bbe521ed20f948aba4567e8a", "eef493e5fd41b00653684fba75e80556ff51480dd96a785be560f2e072ad60ff", diff --git a/plugins/fusion/rules/orchestration.md b/plugins/fusion/rules/orchestration.md index ef8e2ed..736217c 100644 --- a/plugins/fusion/rules/orchestration.md +++ b/plugins/fusion/rules/orchestration.md @@ -24,6 +24,7 @@ Run the session like the founder of a well staffed startup: you decide, employee - Bias to fan out once the brief is verifiable: decompose independent pieces, dispatch them together, and keep each available lane drawing only for work you can collect and verify. Observability outranks width; do not add a branch you cannot collect and verify. - Trust, then verify: hand off a clear brief and judge the result, without pre solving the task inside the brief. - Fan out and decomposition stay with the main session. Claude workers do not receive Agent access; a package that needs further decomposition returns that gap to the main session. +- The session posture machine (coordinate, implement, triage) governs what the main loop itself may touch, while execution width has its own default, the fleet. Any goal that decomposes into three or more independent work packages, or one package that shards into parallel slices, convenes the fleet (/fusion:ultra) as soon as bootstrap dependencies are resolved. Solo or small wave execution is the exception and carries a visible `fleet-decline: ` line in the reply. Decline reasons are premises, and when a premise expires (a lane recovers, the goal enters implement, the package count accretes to three), the fleet question reopens instead of the decline persisting silently. Usage volume is never a reason to shrink or skip a fleet, and verification gates are never traded for speed. ## Session execution posture @@ -31,6 +32,8 @@ Classification is per message; execution posture is per session goal and persist There are three postures, and each governs what the main loop is allowed to touch until the goal changes or an exit condition fires. +Coordinate is the default for main loop conduct and does not decide execution width, which the fleet default owns. + - coordinate (default): main loop tools are for coordination only, peeking to phrase a brief, read only checks on reported results, adjudicating disagreement, final diff review. One micro step write is allowed per goal under the micro step gate below. - implement: an approved plan, an explicit multi part change, or an accumulation trigger is active. The main loop stops editing product code and dispatches packages, fanning out independent pieces together. - triage: the message is a runtime failure, stack trace, log excerpt, or UI element reference. Read only tools freely, at most one micro step edit to reproduce or narrow, then either dispatch the fix as a brief with a verification command or declare implement and fan out. @@ -63,7 +66,7 @@ Scores feed the routing priorities above: intelligence proxies correctness and s Treat /fusion:config and live model listings as authoritative over any example model names in this document. -Dispatch gate, checked once before any dispatch: decide whether the message continues the active goal or starts a new one, apply the current posture, and only inside coordinate or a fresh goal's triage does the question versus change classification apply (a question or problem description is answered or diagnosed from the main loop with read only tools; a requested change proceeds). A requested change, a second inline edit for the same goal, a repeated runtime error, or a failed verification enters implement as soon as a verification command can be written. If one cannot be written yet, neither dispatch nor edit; continue triage or reconnaissance instead. Fan out independent packages in a single message rather than queueing them one at a time. +Dispatch gate, checked once before any dispatch: decide whether the message continues the active goal or starts a new one, apply the current posture, and only inside coordinate or a fresh goal's triage does the question versus change classification apply (a question or problem description is answered or diagnosed from the main loop with read only tools; a requested change proceeds). A requested change, a second inline edit for the same goal, a repeated runtime error, or a failed verification enters implement as soon as a verification command can be written. If one cannot be written yet, neither dispatch nor edit; continue triage or reconnaissance instead. Fan out independent packages in a single message rather than queueing them one at a time; at three or more independent packages, convene the fan out through /fusion:ultra rather than hand rolling it. Implementation routing by brief shape: @@ -110,10 +113,10 @@ Codex owns ordinary delegated implementation by default. Grok is a complementary Peers are executors with model aware lanes. Codex is the primary builder for bounded, long horizon, multi step implementation. Grok may receive spec grade packages only when one protected role explains the fit. Live model listings and /fusion:config are authoritative for the model and effort inside a lane, but capability scores never change lane ownership by themselves. A brief names the effort or model when correctness or latency warrants it; /fusion:config is the write path for changing defaults and scores, while changing primary ownership requires an explicit routing policy edit. -- codex rewards spec grade briefs: completion criteria, an output contract, boundaries, a verification command, and an explicit requirement to return the real result in the same turn. Pass the prompt directly to `codex:codex-rescue`; do not create a transport payload with Write. The companion adapter owns any private staging needed by its transport. A current workspace brief may be direct natural language. A worktree implementation brief instead uses `--write --cwd "" -- ` as the complete direct prompt so the companion records that worktree as both its cwd and workspace root. Every Codex rescue and companion call stays foreground by default. Complexity, estimated duration, review size, and model choice never authorize implicit background execution. Split a package that cannot fit the 10 minute foreground cap. If it cannot be split, bypass Codex and route a bounded isolated package to Grok under `burst`, or use the matching Claude fallback. Only an incoming user request that already includes `--background` may detach Codex. Fusion:job-collector makes one same turn collection attempt, bounded to 540000ms so its foreground Bash call finishes inside the tool timeout. If that attempt times out, report the still running job and its result command honestly; do not claim collection or completion. A brief that cannot be explicit and bounded is not ready for Codex. If Codex is unavailable, a bounded, isolated implementation package may move to Grok under `burst`; use `fusion:fast-worker` when the package needs Claude tools or privacy, exceeds Grok's safe write or turn boundaries, or cannot be independently isolated; use `fusion:deep-reasoner` only for read only adversarial analysis. The Codex lane is single flight per canonical workspace and always follows the admission ladder rather than blanket overflow. Three or more quick packages touching the same subsystem with long horizon shape consolidate into one bounded spec grade brief or split only where independent. 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`. +- 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. - 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. Idle capacity alone never manufactures a Grok role. Multi source research fans out one track to a peer by default. +- 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. - A companion `failure: setup` means its CLI installation, version, or required capability preflight is incompatible. Do not retry the same task against that surface. Upgrade or repair the CLI and rerun the companion setup command; route current work to another healthy eligible lane until the surface is repaired. - Worker reuse, thread rotation, and per engine failure handling: see the fusion plugin's troubleshooting rules (shipped with the plugin, consulted on demand). @@ -125,7 +128,7 @@ These fire from plain language, not only from a typed slash command. Match the u - Stuck between two approaches, a design or architecture decision where being wrong is expensive, or a diagnosis that survived one fix: convene the blind panel (/fusion:panel). - A request to go deep, thorough, or exhaustive (a comprehensive audit, an exhaustive bug hunt, mapping a whole subsystem, a large multi part implementation): convene the fleet (/fusion:ultra), which runs on the peer lanes and adds intensity without spending main loop tokens on the workers. - Both self size: the panel is overkill for a short tactical prompt, and the fleet must skip small tasks and return them to ordinary routing or a direct answer. -- The fleet is the default execution posture, not an escalation. Any goal that decomposes into three or more independent work packages, or one package that shards into parallel slices, convenes the fleet (/fusion:ultra) as soon as bootstrap dependencies are resolved; solo or small wave execution is the exception and carries a stated reason. Usage volume is never a reason to shrink or skip a fleet, and verification gates are never traded for speed. +- The fleet default above invokes /fusion:ultra and self sizes to the goal's resolved decomposition. ## High stakes fan out @@ -141,7 +144,7 @@ Every brief is self contained: goal, constraints, relevant paths, and what done - Implementation and change briefs name every verification suite that exercises the touched behavior, including shared harness suites not named after the touched plugin; a verification list covering only the obviously named suites is not complete. - 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. +- 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. 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. - 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 foreground peer call that returns only a receipt is a contract failure. diff --git a/plugins/fusion/scripts/fleet-posture.mjs b/plugins/fusion/scripts/fleet-posture.mjs new file mode 100644 index 0000000..d842d87 --- /dev/null +++ b/plugins/fusion/scripts/fleet-posture.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const FLEET_MODE_ENV = "FUSION_FLEET_MODE"; +const DATA_DIR_ENV = "FUSION_DATA_DIR"; +const FLEET_MODE_FILE = "fleet-mode"; +const ADDITIONAL_CONTEXT = "fleet-default active: a goal that decomposes into three or more independent work packages convenes /fusion:ultra once bootstrap dependencies are resolved; narrower execution states `fleet-decline: ` visibly in the reply."; + +function readHookInput() { + try { + const raw = fs.readFileSync(0, "utf8").trim(); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function resolveFusionDataDir(env = process.env) { + const override = env[DATA_DIR_ENV]; + if (override && String(override).trim()) { + return path.resolve(String(override).trim()); + } + return path.join(os.homedir(), ".claude", "plugins", "data", "fusion-claude-code-fusion"); +} + +function fleetEnabled(env = process.env) { + if (env[FLEET_MODE_ENV] !== undefined) { + return env[FLEET_MODE_ENV] !== "off"; + } + try { + return fs.readFileSync(path.join(resolveFusionDataDir(env), FLEET_MODE_FILE), "utf8").trim() !== "off"; + } catch { + return true; + } +} + +function main() { + if (!readHookInput() || !fleetEnabled()) { + return; + } + process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: ADDITIONAL_CONTEXT } })}\n`); +} + +function isMain() { + return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +} + +if (isMain()) { + try { + main(); + } catch { + void 0; + } +} diff --git a/plugins/fusion/scripts/inline-delegation-guard.mjs b/plugins/fusion/scripts/inline-delegation-guard.mjs index bcceb31..96195d1 100644 --- a/plugins/fusion/scripts/inline-delegation-guard.mjs +++ b/plugins/fusion/scripts/inline-delegation-guard.mjs @@ -14,10 +14,12 @@ const AUDIT_MAX_BYTES_ENV = "FUSION_INLINE_GUARD_AUDIT_MAX_BYTES"; const AUDIT_MAX_FILES_ENV = "FUSION_INLINE_GUARD_AUDIT_MAX_FILES"; const FUSION_DATA_DIR_ENV = "FUSION_DATA_DIR"; const WORKER_STATE_DIR_ENV = "FUSION_WORKER_STATE_DIR"; +const FLEET_WAVE_GAP_ENV = "FUSION_FLEET_WAVE_GAP_MS"; const DEFAULT_BUDGET = 5; const DEFAULT_AUDIT_RETENTION_DAYS = 180; const DEFAULT_AUDIT_MAX_BYTES = 2 * 1024 * 1024; const DEFAULT_AUDIT_MAX_FILES = 256; +const DEFAULT_FLEET_WAVE_GAP_MS = 120000; const AUDIT_SCHEMA_VERSION = 1; const STALE_MS = 48 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000; @@ -39,6 +41,7 @@ const MAIN_LANE = "main"; const WORKER_TERMINAL_STATUSES = new Set(["done", "incomplete", "failed", "cancelled", "owner_ended"]); const NO_OP_BASH_COMMANDS = new Set(["", "true", ":"]); const NO_OP_HEARTBEAT_REASON = "Fusion tasks are in flight for this session, so emit a text-only heartbeat instead of this no-op Bash command."; +const NARROW_WAVE_ADVISORY_LINE = "second consecutive narrow wave; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ."; function resolveStateDir(env = process.env) { const override = env[STATE_ENV]; @@ -145,6 +148,10 @@ function resolveAuditMaxFiles(env = process.env) { return resolvePositiveInteger(env, AUDIT_MAX_FILES_ENV, DEFAULT_AUDIT_MAX_FILES); } +function resolveFleetWaveGapMs(env = process.env) { + return resolvePositiveInteger(env, FLEET_WAVE_GAP_ENV, DEFAULT_FLEET_WAVE_GAP_MS); +} + function readHookInput() { try { const raw = fs.readFileSync(0, "utf8").trim(); @@ -635,6 +642,8 @@ function defaultState(now) { writesSinceDispatch: 0, dispatchEpoch: 0, lastDispatchAt: null, + fleetWaveWidth: 0, + consecutiveNarrowWaves: 0, dispatches: {}, dispatchLog: [], advisedMultiples: [], @@ -687,7 +696,29 @@ function normalizeAdvisedMultiples(value) { return [...new Set(value.filter((multiple) => Number.isInteger(multiple) && multiple > 0))]; } -function normalizeState(existing, now) { +function deriveFleetWaveState(dispatchLog, waveGapMs) { + let fleetWaveWidth = 0; + let consecutiveNarrowWaves = 0; + let previousDispatchAtMs = null; + for (const entry of dispatchLog) { + const dispatchAtMs = Date.parse(entry.at); + if (!Number.isFinite(dispatchAtMs)) { + continue; + } + if (previousDispatchAtMs !== null && Math.abs(dispatchAtMs - previousDispatchAtMs) <= waveGapMs) { + fleetWaveWidth += 1; + } else { + if (fleetWaveWidth > 0) { + consecutiveNarrowWaves = fleetWaveWidth <= 2 ? consecutiveNarrowWaves + 1 : 0; + } + fleetWaveWidth = 1; + } + previousDispatchAtMs = dispatchAtMs; + } + return { fleetWaveWidth, consecutiveNarrowWaves }; +} + +function normalizeState(existing, now, waveGapMs = DEFAULT_FLEET_WAVE_GAP_MS) { if (!existing || typeof existing !== "object") { return defaultState(now); } @@ -700,11 +731,17 @@ function normalizeState(existing, now) { const dispatchLog = normalizeDispatchLog(existing.dispatchLog); const createdAtMs = Date.parse(existing.createdAt); const lastDispatchAtMs = Date.parse(existing.lastDispatchAt); + const derivedFleetWaveState = deriveFleetWaveState(dispatchLog, waveGapMs); + const fleetWaveWidth = Number.isInteger(existing.fleetWaveWidth) && existing.fleetWaveWidth >= 0 ? existing.fleetWaveWidth : derivedFleetWaveState.fleetWaveWidth; + const consecutiveNarrowWaves = + Number.isInteger(existing.consecutiveNarrowWaves) && existing.consecutiveNarrowWaves >= 0 ? existing.consecutiveNarrowWaves : derivedFleetWaveState.consecutiveNarrowWaves; return { writeCount, writesSinceDispatch, dispatchEpoch: Number.isInteger(existing.dispatchEpoch) && existing.dispatchEpoch >= 0 ? existing.dispatchEpoch : dispatchCount, lastDispatchAt: Number.isFinite(lastDispatchAtMs) ? new Date(lastDispatchAtMs).toISOString() : null, + fleetWaveWidth, + consecutiveNarrowWaves, dispatches, dispatchLog, advisedMultiples, @@ -725,6 +762,10 @@ function denyOutput(reason) { return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }; } +function postToolAdvisoryOutput(additionalContext) { + return { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext } }; +} + function buildAdvisoryLine(writeCount, dispatchCount) { const countSummary = dispatchCount === 0 @@ -768,12 +809,26 @@ function runHook(env = process.env, input = readHookInput()) { const safeSubagentType = sanitizeIdentifier(subagentType); const lane = laneForSubagentType(subagentType); const description = extractDispatchDescription(input.tool_input); + const waveGapMs = resolveFleetWaveGapMs(env); recordAuditEvent( { at: now, session: sessionId, event: "dispatch", lane, tool: toolName, ...(description ? { description } : {}) }, env ); - withStateLock(file, () => { - const state = normalizeState(readState(file), now); + const waveAdvisory = withStateLock(file, () => { + const state = normalizeState(readState(file), now, waveGapMs); + const previousDispatchAtMs = Date.parse(state.lastDispatchAt); + const nowMs = Date.parse(now); + const isSameWave = Number.isFinite(previousDispatchAtMs) && Number.isFinite(nowMs) && Math.abs(nowMs - previousDispatchAtMs) <= waveGapMs; + let shouldAdvise = false; + if (isSameWave) { + state.fleetWaveWidth += 1; + } else if (state.fleetWaveWidth > 0) { + state.consecutiveNarrowWaves = state.fleetWaveWidth <= 2 ? state.consecutiveNarrowWaves + 1 : 0; + shouldAdvise = state.consecutiveNarrowWaves === 2; + state.fleetWaveWidth = 1; + } else { + state.fleetWaveWidth = 1; + } state.dispatches[lane] = (state.dispatches[lane] ?? 0) + 1; state.dispatchEpoch += 1; state.lastDispatchAt = now; @@ -784,7 +839,11 @@ function runHook(env = process.env, input = readHookInput()) { state.dispatchLog.splice(0, state.dispatchLog.length - DISPATCH_LOG_LIMIT); } writeState(file, state); + return shouldAdvise; }); + if (waveAdvisory) { + process.stdout.write(`${JSON.stringify(postToolAdvisoryOutput(NARROW_WAVE_ADVISORY_LINE))}\n`); + } return; } @@ -801,7 +860,7 @@ function runHook(env = process.env, input = readHookInput()) { let writeCount = 0; let dispatchCount = 0; try { - const snapshot = withStateLock(file, () => normalizeState(readState(file), now)); + const snapshot = withStateLock(file, () => normalizeState(readState(file), now, resolveFleetWaveGapMs(env))); writeCount = snapshot.writesSinceDispatch; dispatchCount = totalDispatches(snapshot.dispatches); } catch { @@ -840,7 +899,7 @@ function runHook(env = process.env, input = readHookInput()) { const budget = resolveBudget(env); const mode = resolveMode(env); const decision = withStateLock(file, () => { - const state = normalizeState(readState(file), now); + const state = normalizeState(readState(file), now, resolveFleetWaveGapMs(env)); if (mode === "enforce" && state.writesSinceDispatch >= budget) { return { denied: true, writeCount: state.writesSinceDispatch, dispatchCount: totalDispatches(state.dispatches) }; } @@ -881,7 +940,7 @@ function runHook(env = process.env, input = readHookInput()) { recordAuditEvent({ at: now, session: sessionId, event: "write", lane: MAIN_LANE, tool: toolName, ...(auditPath ? { path: auditPath } : {}) }, env); if (decision.candidate) { withStateLock(file, () => { - const latest = normalizeState(readState(file), new Date().toISOString()); + const latest = normalizeState(readState(file), new Date().toISOString(), resolveFleetWaveGapMs(env)); if (latest.dispatchEpoch === decision.candidate.dispatchEpoch && latest.advisedMultiples.includes(decision.candidate.multiple)) { recordAuditEvent( { diff --git a/plugins/fusion/scripts/lib/worker-state.mjs b/plugins/fusion/scripts/lib/worker-state.mjs index 1e2510b..d0ad883 100644 --- a/plugins/fusion/scripts/lib/worker-state.mjs +++ b/plugins/fusion/scripts/lib/worker-state.mjs @@ -134,6 +134,13 @@ export function workerRecordFile(taskId, env = process.env) { return path.join(resolveWorkerStateDir(env), "jobs", `${taskId}.json`); } +export function workerSessionStateFile(sessionId, env = process.env) { + if (typeof sessionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) { + throw new TypeError("Fusion worker session id is invalid."); + } + return path.join(resolveWorkerStateDir(env), "sessions", `${sessionId}.json`); +} + function ensurePrivateDirectory(directory) { fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); try { @@ -182,6 +189,23 @@ export function readWorkerRecord(taskId, env = process.env) { return readWorkerRecordFile(workerRecordFile(taskId, env)); } +export function readWorkerSessionState(sessionId, env = process.env) { + return readWorkerRecordFile(workerSessionStateFile(sessionId, env)); +} + +export function updateWorkerSessionState(sessionId, env, updater) { + const file = workerSessionStateFile(sessionId, env); + return withLock(file, () => { + const current = readWorkerRecordFile(file); + const next = updater(current); + if (!next || typeof next !== "object" || Array.isArray(next)) { + return current; + } + writePrivateJson(file, next); + return next; + }); +} + export function updateWorkerRecord(taskId, env, updater) { const file = workerRecordFile(taskId, env); return withLock(file, () => { diff --git a/plugins/fusion/scripts/worker-lifecycle.mjs b/plugins/fusion/scripts/worker-lifecycle.mjs index 75941f6..8a6c7a2 100644 --- a/plugins/fusion/scripts/worker-lifecycle.mjs +++ b/plugins/fusion/scripts/worker-lifecycle.mjs @@ -13,10 +13,12 @@ import { isFusionWorkerAgent, isTerminalWorkerStatus, markWorkerCollected, + readWorkerSessionState, readWorkerRecords, refreshWorkerTranscript, resolveFusionDataDir, updateWorkerRecord, + updateWorkerSessionState, workerRecordFile, writePrivateJson, writePrivateText @@ -31,6 +33,9 @@ const MAX_UNCACHED_TOKENS_ENV = "FUSION_WORKER_MAX_UNCACHED_TOKENS"; const COLLECTION_RESPONSE_DEBUG_ENV = "FUSION_WORKER_DEBUG_COLLECTION_RESPONSE"; const COLLECTION_RESPONSE_DEBUG_FILE = "worker-collection-response.json"; const DEFAULT_BRIEF_MAX_BYTES = 16 * 1024; +const TASK_NOTIFICATION_SCAN_MAX_BYTES = 4 * 1024 * 1024; +const TASK_NOTIFICATION_SCAN_CHUNK_BYTES = 64 * 1024; +const TASK_NOTIFICATION_MAX_LINE_BYTES = 1024 * 1024; const FINAL_TEXT_MAX_BYTES = 192 * 1024; const FINAL_TEXT_HEAD_BYTES = 64 * 1024; const FINAL_TEXT_TAIL_BYTES = 128 * 1024; @@ -46,6 +51,9 @@ const PEER_WRAPPER_AGENTS = new Set(["codex:codex-rescue", "codex-rescue", "grok const MANAGED_PEER_AGENTS = new Set(["grok:grok-review-runner", "grok-review-runner"]); const PEER_JOB_FOOTER_AGENTS = new Set(["codex:codex-rescue", "grok:grok-rescue", "grok:grok-review-runner"]); const TERMINAL_RUNTIME_TASK_STATUSES = new Set(["completed", "complete", "done", "failed", "error", "cancelled", "canceled", "stopped", "terminated", "timed_out", "timeout"]); +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"]); function readHookInput() { try { @@ -969,6 +977,193 @@ function terminalCollectionPending(record) { || (record.runtimeAsync === true && isTerminalWorkerStatus(record.transportStatus) && !record.collectedAt); } +function taskNotificationTextParts(value) { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.flatMap(taskNotificationTextParts); + } + if (value && typeof value === "object" && value.type === "text" && typeof value.text === "string") { + return [value.text]; + } + return []; +} + +function taskNotificationsFromTranscriptLine(line) { + let entry; + try { + entry = JSON.parse(line); + } catch { + return []; + } + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return []; + } + const texts = taskNotificationTextParts(entry.message?.content).concat(taskNotificationTextParts(entry.content)); + const notifications = []; + for (const text of texts) { + const blocks = text.matchAll(/]*)?\s*>([\s\S]*?)<\/task-notification\s*>/gi); + for (const block of blocks) { + const taskId = /\s*([^<\s]+)\s*<\/task-id\s*>/i.exec(block[1])?.[1] ?? null; + const status = /\s*([^<]+?)\s*<\/status\s*>/i.exec(block[1])?.[1]?.trim().toLowerCase() ?? null; + if (taskId && status && (SUCCESSFUL_TASK_NOTIFICATION_STATUSES.has(status) || CANCELLED_TASK_NOTIFICATION_STATUSES.has(status) || FAILED_TASK_NOTIFICATION_STATUSES.has(status))) { + notifications.push({ taskId, status }); + } + } + } + return notifications; +} + +function taskNotificationScanOffset(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function initialTaskNotificationScanOffset(records) { + const offsets = records.map((record) => taskNotificationScanOffset(record.parentTranscriptBytesAtDispatch)).filter((offset) => offset != null); + return offsets.length > 0 ? Math.min(...offsets) : 0; +} + +function parentTranscriptPathForTaskNotifications(records, input) { + const configured = records.map((record) => record.parentTranscriptPath).find((transcriptPath) => typeof transcriptPath === "string" && path.isAbsolute(transcriptPath)); + if (configured) { + return path.resolve(configured); + } + return typeof input.transcript_path === "string" && path.isAbsolute(input.transcript_path) ? path.resolve(input.transcript_path) : null; +} + +function scanTaskNotifications(transcriptPath, requestedOffset) { + let descriptor; + try { + descriptor = fs.openSync(transcriptPath, "r"); + const size = fs.fstatSync(descriptor).size; + const startOffset = Math.min(taskNotificationScanOffset(requestedOffset) ?? 0, size); + const bytesToScan = Math.min(TASK_NOTIFICATION_SCAN_MAX_BYTES, size - startOffset); + const statuses = new Map(); + let cursor = startOffset; + let safeOffset = startOffset; + let pending = Buffer.alloc(0); + let skippingLongLine = false; + let remaining = bytesToScan; + + while (remaining > 0) { + const chunk = Buffer.allocUnsafe(Math.min(TASK_NOTIFICATION_SCAN_CHUNK_BYTES, remaining)); + const bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, cursor); + if (bytesRead === 0) { + break; + } + const data = chunk.subarray(0, bytesRead); + const chunkStart = cursor; + let segmentStart = 0; + while (segmentStart < data.length) { + const lineEnd = data.indexOf(0x0a, segmentStart); + const segmentEnd = lineEnd === -1 ? data.length : lineEnd; + const segment = data.subarray(segmentStart, segmentEnd); + if (!skippingLongLine) { + if (pending.length + segment.length > TASK_NOTIFICATION_MAX_LINE_BYTES) { + pending = Buffer.alloc(0); + skippingLongLine = true; + } else if (segment.length > 0) { + pending = pending.length === 0 ? Buffer.from(segment) : Buffer.concat([pending, segment]); + } + } + if (lineEnd === -1) { + break; + } + if (!skippingLongLine && pending.length > 0) { + for (const notification of taskNotificationsFromTranscriptLine(pending.toString("utf8").replace(/\r$/, ""))) { + statuses.set(notification.taskId, notification.status); + } + } + pending = Buffer.alloc(0); + skippingLongLine = false; + safeOffset = chunkStart + lineEnd + 1; + segmentStart = lineEnd + 1; + } + cursor += bytesRead; + remaining -= bytesRead; + if (skippingLongLine) { + safeOffset = cursor; + } + } + return { statuses, offset: safeOffset }; + } catch { + return null; + } finally { + if (descriptor != null) { + try { + fs.closeSync(descriptor); + } catch { + void 0; + } + } + } +} + +function taskNotificationTransition(record, status, now) { + if (SUCCESSFUL_TASK_NOTIFICATION_STATUSES.has(status)) { + const harnessAsyncDelivery = collectedHarnessAsyncDelivery(record, "done"); + return { + ...markWorkerCollected({ ...record, acceptance: "unverified" }, "task_notification", now), + transportStatus: "done", + acceptance: "unverified", + failureKind: harnessAsyncDelivery ? null : record.failureKind, + ...(harnessAsyncDelivery ? { deliveryMode: "harness_async" } : {}), + finishedAt: record.finishedAt ?? now, + lastActivityAt: now + }; + } + const cancelled = CANCELLED_TASK_NOTIFICATION_STATUSES.has(status); + const fallbackFailureKind = cancelled ? "cancelled" : "task_failed"; + return { + ...markWorkerCollected({ ...record, acceptance: "unverified" }, "task_notification", now), + transportStatus: cancelled ? "cancelled" : "failed", + acceptance: "unverified", + failureKind: record.failureKind && record.failureKind !== "unexpected_async" ? record.failureKind : fallbackFailureKind, + finishedAt: record.finishedAt ?? now, + lastActivityAt: now + }; +} + +function reconcileTaskNotifications(input, env) { + try { + const candidates = sessionRecords(input.session_id, env).filter((record) => !isTerminalWorkerStatus(record.transportStatus) && [record.backgroundTaskId, record.agentId].some((taskId) => typeof taskId === "string" && taskId)); + if (candidates.length === 0) { + return; + } + const transcriptPath = parentTranscriptPathForTaskNotifications(candidates, input); + if (!transcriptPath) { + return; + } + const sessionState = readWorkerSessionState(input.session_id, env); + const scanOffset = sessionState?.taskNotificationTranscriptPath === transcriptPath + ? taskNotificationScanOffset(sessionState.taskNotificationScanOffset) ?? initialTaskNotificationScanOffset(candidates) + : initialTaskNotificationScanOffset(candidates); + const scanned = scanTaskNotifications(transcriptPath, scanOffset); + if (!scanned) { + return; + } + const now = new Date().toISOString(); + for (const [taskId, status] of scanned.statuses) { + for (const record of candidates.filter((candidate) => [candidate.backgroundTaskId, candidate.agentId].includes(taskId))) { + updateWorkerRecord(record.taskId, env, (current) => { + if (!current || current.sessionId !== input.session_id || isTerminalWorkerStatus(current.transportStatus) || ![current.backgroundTaskId, current.agentId].includes(taskId)) { + return null; + } + return taskNotificationTransition(current, status, now); + }); + } + } + updateWorkerSessionState(input.session_id, env, (current) => ({ + ...(current ?? {}), + taskNotificationTranscriptPath: transcriptPath, + taskNotificationScanOffset: scanned.offset + })); + } catch { + void 0; + } +} + function armInFlightRecords(records, tasks, env) { const inFlight = records.filter((record) => !terminalTransportObserved(record, runtimeTaskForRecord(record, tasks))); const armedAt = new Date().toISOString(); @@ -1059,8 +1254,31 @@ function writeAcceptanceAdvisory(sessionId, env) { 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.`)); } +function inFlightSignature(records) { + return records.map((record) => record.taskId).sort().join(","); +} + +function shouldWriteInFlightAdvisory(sessionId, signature, env) { + return readWorkerSessionState(sessionId, env)?.inFlightAdvisorySignature !== signature; +} + +function recordInFlightAdvisory(sessionId, signature, env) { + updateWorkerSessionState(sessionId, env, (current) => ({ ...(current ?? {}), inFlightAdvisorySignature: signature })); +} + +function clearInFlightAdvisory(sessionId, env) { + updateWorkerSessionState(sessionId, env, (current) => { + if (!current || !("inFlightAdvisorySignature" in current)) { + return null; + } + const { inFlightAdvisorySignature, ...next } = current; + return next; + }); +} + function handleStop(input, env) { const tasks = Array.isArray(input.background_tasks) ? input.background_tasks : []; + reconcileTaskNotifications(input, env); const initialRecords = sessionRecords(input.session_id, env); if (initialRecords.every((record) => !terminalCollectionPending(record)) && collectorStopGate(input, env)) { armInFlightRecords(initialRecords, tasks, env); @@ -1105,6 +1323,9 @@ function handleStop(input, env) { } const currentRecords = sessionRecords(input.session_id, env); const inFlight = armInFlightRecords(currentRecords, tasks, env); + if (inFlight.length === 0) { + clearInFlightAdvisory(input.session_id, env); + } const terminalUncollected = currentRecords.filter(terminalCollectionPending); if (terminalUncollected.length > 0) { const instructions = terminalUncollected.map((record) => { @@ -1120,8 +1341,10 @@ function handleStop(input, env) { return; } if (inFlight.length > 0) { - if (!input.stop_hook_active) { + const signature = inFlightSignature(inFlight); + if (!input.stop_hook_active && shouldWriteInFlightAdvisory(input.session_id, signature, env)) { writeOutput(hookOutput("Stop", `Fusion task${inFlight.length === 1 ? "" : "s"} ${inFlight.map((record) => record.taskId).join(", ")} ${inFlight.length === 1 ? "is" : "are"} still in flight. Collection is armed and will be required after terminal notification.`)); + recordInFlightAdvisory(input.session_id, signature, env); } return; } diff --git a/plugins/fusion/skills/ultra/SKILL.md b/plugins/fusion/skills/ultra/SKILL.md index 0b8e277..0aaf439 100644 --- a/plugins/fusion/skills/ultra/SKILL.md +++ b/plugins/fusion/skills/ultra/SKILL.md @@ -1,7 +1,7 @@ --- name: ultra -description: "Fans the work out as a fleet of parallel Grok and Codex agents billed to their own subscriptions, then synthesizes one result. The peer engine equivalent of ultracode, adding intensity without spending Claude quota on the fleet." -when_to_use: "Proactively use when the user asks to go deep, be thorough, or exhaustive. Deep research on a topic, a comprehensive audit, an exhaustive bug hunt, mapping a whole subsystem, or implementing a large multi part feature." +description: "Fans the work out as a fleet of parallel Grok and Codex agents billed to their own subscriptions, then synthesizes one result. The peer engine equivalent of ultracode, adding intensity without spending Claude quota on the fleet. The default execution posture for any goal that decomposes into three or more independent work packages, not only an intensity booster for explicit asks." +when_to_use: "Proactively convene whenever the goal decomposes into three or more independent work packages once bootstrap dependencies are resolved; this is the default posture, not an escalation. Also convene on any explicit ask to go deep, be thorough, or exhaustive: deep research on a topic, a comprehensive audit, an exhaustive bug hunt, mapping a whole subsystem, or implementing a large multi part feature. Declining a fleet shaped goal requires a visible fleet-decline: line." argument-hint: '[the task to pursue exhaustively]' allowed-tools: Agent, Read, Bash, AskUserQuestion --- @@ -14,7 +14,8 @@ Task: Size gate first (cheap, in the main loop): - If the task is small, a single question, or a one file change, do NOT convene a fleet. Return it to the normal routing policy: questions stay in the main session, and requested changes normally use the Codex primary lane. Say you skipped the fleet because the task did not warrant it. -- Otherwise decompose the task into independent facets. Research decomposes by angle or source (different subsystems, different questions, different documents). Implementation decomposes by work package. Aim for 6 to 8 facets by default; go lower if the task has fewer natural seams, higher (up to about 12) only when the user asked for maximum coverage and the facets are truly independent. +- Skipping a goal that decomposes into three or more independent packages is a decline: state a visible `fleet-decline: ` line in the reply. Decline reasons are premises, and when one expires (a lane recovers, the goal enters implement, packages accrete to three) the fleet question reopens. +- Otherwise decompose the task into independent facets. Research decomposes by angle or source (different subsystems, different questions, different documents). Implementation decomposes by work package. Facet count follows the goal's natural package count with a floor of three: 6 to 8 facets when the seams allow it; up to about 12 only when the user asked for maximum coverage and the facets are truly independent. A three package goal is fleet shaped even though it sits below six. Compose one self contained brief per facet: @@ -24,6 +25,7 @@ Compose one self contained brief per facet: Launch the fleet in one message: +- The convening ceremony runs inside this skill, not prepaid by the main loop before invoking it: record the checkpoint or staged patch baseline when the tree is uncommitted, create the orchestrator owned worktrees for eligible independent Codex facets, and generate each brief's sibling declaration list mechanically from the decomposition so every facet's files appear in every other facet's forbidden list. The main loop's job is the decomposition decision and final judgment, not the staging ritual. - The fleet changes capacity, not lane ownership. Codex remains the primary builder and deep reviewer. Grok supplies breadth under its `burst`, `live-web`, `large-context`, and `independence` protected roles. - Invoke all independent Grok, Claude, and Codex facets together as parallel foreground-delivery Agent calls in one tool message. Do not set `run_in_background`. If the runtime still returns an async launch receipt, retain ownership and collect it before synthesis; Fusion's lifecycle hook blocks a turn that tries to abandon a Claude worker. Additional independent Codex implementation facets may use distinct orchestrator owned worktrees when their files are disjoint and verification needs no heavy setup. For each such facet, use `--write --cwd "" -- ` as the complete direct prompt to `codex:codex-rescue`. A natural language `Working directory:` line does not select the Codex sandbox, and `--cwd` must never be appended to the Agent call, wrapper Bash command, or `--raw-args-token` invocation. Grok carries the remaining burst breadth, research digests, large context reads, and isolated scoped fixes. Packages that overlap files or require ordering are consolidated or sequenced, never fanned out as parallel overflow. - Never set background mode on the Codex Agent or add `--background` to its brief. Complexity, expected duration, and model choice do not authorize detachment inside a fleet. diff --git a/plugins/grok/.claude-plugin/plugin.json b/plugins/grok/.claude-plugin/plugin.json index 9e89e65..29a4752 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.30", + "version": "0.0.31", "description": "Local Grok CLI delegation: task, review, resumable history, best-of-n tournaments, background jobs, stats, and setup health checks.", "author": { "name": "Harry Yep" diff --git a/tests/fleet-posture.test.mjs b/tests/fleet-posture.test.mjs new file mode 100644 index 0000000..351f5bc --- /dev/null +++ b/tests/fleet-posture.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +const repoRoot = path.join(import.meta.dirname, ".."); +const script = path.join(repoRoot, "plugins", "fusion", "scripts", "fleet-posture.mjs"); + +function makeSandbox(t) { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "fleet-posture-test-"))); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return { root, dataDir: path.join(root, "fusion-data") }; +} + +function envFor(sandbox, extra = {}) { + const env = { ...process.env, FUSION_DATA_DIR: sandbox.dataDir, ...extra }; + if (!Object.hasOwn(extra, "FUSION_FLEET_MODE")) { + delete env.FUSION_FLEET_MODE; + } + return env; +} + +function run(sandbox, input = JSON.stringify({ session_id: "session-1", prompt: "do work", cwd: sandbox.root }), extraEnv = {}) { + return spawnSync(process.execPath, [script], { + input, + env: envFor(sandbox, extraEnv), + encoding: "utf8" + }); +} + +test("the default posture emits the UserPromptSubmit fleet reminder", (t) => { + const sandbox = makeSandbox(t); + const result = run(sandbox); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stderr, ""); + const output = JSON.parse(result.stdout); + assert.strictEqual(output.hookSpecificOutput.hookEventName, "UserPromptSubmit"); + assert.match(output.hookSpecificOutput.additionalContext, /fleet-default active/); +}); + +test("FUSION_FLEET_MODE=off disables the reminder", (t) => { + const sandbox = makeSandbox(t); + const result = run(sandbox, undefined, { FUSION_FLEET_MODE: "off" }); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(result.stderr, ""); +}); + +test("the fleet mode state file can disable the reminder", (t) => { + const sandbox = makeSandbox(t); + fs.mkdirSync(sandbox.dataDir, { recursive: true }); + fs.writeFileSync(path.join(sandbox.dataDir, "fleet-mode"), "off\n", "utf8"); + const result = run(sandbox); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(result.stderr, ""); +}); + +test("an enabled environment mode overrides a disabled state file", (t) => { + const sandbox = makeSandbox(t); + fs.mkdirSync(sandbox.dataDir, { recursive: true }); + fs.writeFileSync(path.join(sandbox.dataDir, "fleet-mode"), "off\n", "utf8"); + const result = run(sandbox, undefined, { FUSION_FLEET_MODE: "on" }); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stderr, ""); + assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /fleet-default active/); +}); + +test("malformed hook input is silent", (t) => { + const sandbox = makeSandbox(t); + const result = run(sandbox, "{not json"); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ""); + assert.strictEqual(result.stderr, ""); +}); diff --git a/tests/fusion-worker-lifecycle.test.mjs b/tests/fusion-worker-lifecycle.test.mjs index b416c5e..447e24c 100644 --- a/tests/fusion-worker-lifecycle.test.mjs +++ b/tests/fusion-worker-lifecycle.test.mjs @@ -6,7 +6,7 @@ import path from "node:path"; import { test } from "node:test"; import { recordCodexAcceptance } from "../plugins/fusion/scripts/fusion-stats.mjs"; -import { createWorkerRecord, readWorkerRecords, recordWorkerAcceptance, updateWorkerRecord } from "../plugins/fusion/scripts/lib/worker-state.mjs"; +import { createWorkerRecord, readWorkerSessionState, readWorkerRecords, recordWorkerAcceptance, updateWorkerRecord } from "../plugins/fusion/scripts/lib/worker-state.mjs"; import { validateWorkerBrief, workerBudgetFailure, workerLimits } from "../plugins/fusion/scripts/worker-lifecycle.mjs"; const repoRoot = path.join(import.meta.dirname, ".."); @@ -976,7 +976,7 @@ test("an armed async worker is collected by SubagentStop and remains verdict gat last_assistant_message: "done", background_tasks: [{ id: "a1", type: "subagent", status: "running", agent_type: "fusion:fast-worker" }] }); - assert.strictEqual(JSON.parse(secondStop.stdout).decision, undefined); + assert.strictEqual(secondStop.stdout, ""); assert.strictEqual(record(box).awaitingCollectionArmedAt, armedAt); const finalMessage = "completed result\ndelivery: complete\nverification: passed"; @@ -1260,7 +1260,7 @@ test("Stop advises on collected unverified workers without blocking and stays qu assert.strictEqual(quiet.stdout, ""); }); -test("Stop in-flight advisory emits once then stays silent while stop_hook_active", (t) => { +test("Stop in-flight advisory emits only when the in-flight set changes", (t) => { const box = sandbox(t); run(box, dispatch(box)); run(box, { @@ -1287,6 +1287,17 @@ test("Stop in-flight advisory emits once then stays silent while stop_hook_activ const armedAt = record(box).awaitingCollectionArmedAt; assert.ok(armedAt); + const unchanged = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + last_assistant_message: "done", + background_tasks: backgroundTasks + }); + assert.strictEqual(unchanged.stdout, ""); + const reentered = run(box, { hook_event_name: "Stop", session_id: "session-1", @@ -1299,6 +1310,65 @@ test("Stop in-flight advisory emits once then stays silent while stop_hook_activ assert.strictEqual(reentered.stdout, ""); assert.strictEqual(record(box).awaitingCollection, true); assert.strictEqual(record(box).awaitingCollectionArmedAt, armedAt); + + const secondDispatch = { ...dispatch(box, { description: "fix b" }), tool_use_id: "tool-2" }; + run(box, secondDispatch); + run(box, { + ...secondDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "a2", resolvedModel: "claude-sonnet-5" } + }); + const expandedBackgroundTasks = [...backgroundTasks, { id: "a2", type: "subagent", status: "running", agent_type: "fusion:fast-worker" }]; + const changed = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + last_assistant_message: "done", + background_tasks: expandedBackgroundTasks + }); + assert.match(JSON.parse(changed.stdout).hookSpecificOutput.additionalContext, /Collection is armed/); + + const emptied = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + last_assistant_message: "done", + background_tasks: expandedBackgroundTasks.map((task) => ({ ...task, status: "completed" })) + }); + assert.strictEqual(JSON.parse(emptied.stdout).decision, "block"); + for (const worker of readWorkerRecords(envFor(box))) { + updateWorkerRecord(worker.taskId, envFor(box), (current) => ({ + ...current, + transportStatus: "done", + collectedAt: new Date().toISOString(), + awaitingCollection: false, + awaitingCollectionArmedAt: null, + awaitingVerdict: false, + awaitingVerdictArmedAt: null + })); + } + + const thirdDispatch = { ...dispatch(box, { description: "fix c" }), tool_use_id: "tool-3" }; + run(box, thirdDispatch); + run(box, { + ...thirdDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "a3", resolvedModel: "claude-sonnet-5" } + }); + const newWave = run(box, { + hook_event_name: "Stop", + session_id: "session-1", + cwd: box.cwd, + transcript_path: box.transcript, + stop_hook_active: false, + last_assistant_message: "done", + background_tasks: [{ id: "a3", type: "subagent", status: "running", agent_type: "fusion:fast-worker" }] + }); + assert.match(JSON.parse(newWave.stdout).hookSpecificOutput.additionalContext, /Collection is armed/); }); test("a successfully terminal async worker without a final-text artifact demands TaskOutput", (t) => { @@ -2647,9 +2717,13 @@ test("advisory in-flight stop rounds do not consume the cancellation budget for last_assistant_message: `Fusion task ${taskId} is still in flight.`, background_tasks: [{ id: "harness-async-1", type: "subagent", status: "running", agent_type: "fusion:fast-worker" }] }); - const output = JSON.parse(stop.stdout); - assert.strictEqual(output.decision, undefined); - assert.match(output.hookSpecificOutput.additionalContext, /still in flight/); + if (count === 0) { + const output = JSON.parse(stop.stdout); + assert.strictEqual(output.decision, undefined); + assert.match(output.hookSpecificOutput.additionalContext, /still in flight/); + } else { + assert.strictEqual(stop.stdout, ""); + } } const worker = record(box); assert.strictEqual(worker.stopBlockCount, 0); @@ -2741,8 +2815,12 @@ test("an explicitly authorized running worker is not cancelled by repeated Stop last_assistant_message: `Background task ${taskId} is running as manual-running.`, background_tasks: [{ id: "manual-running", type: "subagent", status: "running", agent_type: "fusion:fast-worker" }] }); - assert.strictEqual(JSON.parse(stop.stdout).decision, undefined); - assert.match(JSON.parse(stop.stdout).hookSpecificOutput.additionalContext, /still in flight/); + if (count === 0) { + assert.strictEqual(JSON.parse(stop.stdout).decision, undefined); + assert.match(JSON.parse(stop.stdout).hookSpecificOutput.additionalContext, /still in flight/); + } else { + assert.strictEqual(stop.stdout, ""); + } } const running = record(box); assert.strictEqual(running.transportStatus, "pending_async"); @@ -2881,6 +2959,113 @@ test("an explicitly authorized completed worker is collected by SubagentStop bef assert.strictEqual(record(box).transportStatus, "done"); }); +test("Stop settles a delivered task notification once without a TaskOutput probe", (t) => { + const box = sandbox(t); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-worker" } + }); + const before = record(box); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.agentId}\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.status, 0); + assert.strictEqual(stopped.stdout, ""); + const collected = record(box); + assert.strictEqual(collected.transportStatus, "done"); + assert.strictEqual(collected.collectionMethod, "task_notification"); + assert.ok(collected.collectedAt); + assert.strictEqual(collected.failureKind, null); + assert.strictEqual(collected.deliveryMode, null); + assert.strictEqual(collected.awaitingVerdict, true); + + const scanState = readWorkerSessionState("session-1", envFor(box)); + assert.strictEqual(scanState.taskNotificationTranscriptPath, box.transcript); + assert.strictEqual(scanState.taskNotificationScanOffset, fs.statSync(box.transcript).size); + + const repeated = 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(repeated.status, 0); + assert.strictEqual(repeated.stdout, ""); + assert.deepStrictEqual(readWorkerSessionState("session-1", envFor(box)), scanState); + + const settled = recordWorkerAcceptance({ taskId: collected.taskId, acceptance: "accepted", env: envFor(box), source: "main-loop" }); + assert.strictEqual(settled.acceptance, "accepted"); +}); + +test("a delivered completion notification prevents Stop from demanding TaskStop for an expired worker", (t) => { + const box = sandbox(t); + const limits = { FUSION_WORKER_WALL_CLOCK_MS: "1", FUSION_WORKER_STALL_MS: "999999" }; + const workerDispatch = dispatch(box); + run(box, workerDispatch, limits); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-expired" } + }, limits); + const before = record(box); + updateWorkerRecord(before.taskId, envFor(box, limits), (current) => ({ ...current, startedAt: new Date(Date.now() - 1_000).toISOString() })); + fs.appendFileSync(box.transcript, `${JSON.stringify({ type: "user", message: { content: `\n${before.agentId}\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: false, + background_tasks: [] + }, limits); + assert.strictEqual(stopped.status, 0); + assert.doesNotMatch(stopped.stdout, /TaskStop/); + assert.strictEqual(record(box).transportStatus, "done"); + assert.notStrictEqual(record(box).transportStatus, "cancel_requested"); +}); + +test("malformed parent transcript content does not transition a worker during Stop", (t) => { + const box = sandbox(t); + const workerDispatch = dispatch(box); + run(box, workerDispatch); + run(box, { + ...workerDispatch, + hook_event_name: "PostToolUse", + tool_response: { isAsync: true, status: "async_launched", agentId: "notification-malformed" } + }); + const before = record(box); + fs.appendFileSync(box.transcript, "{not valid json}\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: [{ id: before.agentId, type: "subagent", status: "running", agent_type: "fusion:fast-worker" }] + }); + assert.strictEqual(stopped.status, 0); + const unchanged = record(box); + assert.strictEqual(unchanged.transportStatus, "pending_async"); + assert.strictEqual(unchanged.collectionMethod, null); + assert.strictEqual(unchanged.collectedAt, null); + assert.strictEqual(unchanged.failureKind, null); + assert.strictEqual(readWorkerSessionState("session-1", envFor(box)).taskNotificationScanOffset, fs.statSync(box.transcript).size); +}); + 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"]) { diff --git a/tests/inline-delegation-guard.test.mjs b/tests/inline-delegation-guard.test.mjs index a8c144b..7a5fef0 100644 --- a/tests/inline-delegation-guard.test.mjs +++ b/tests/inline-delegation-guard.test.mjs @@ -103,6 +103,13 @@ function readState(sandbox, sessionId) { return JSON.parse(fs.readFileSync(stateFileFor(sandbox, sessionId), "utf8")); } +function completeActiveWave(sandbox, gapMs) { + const file = stateFileFor(sandbox, "session-1"); + const state = readState(sandbox, "session-1"); + state.lastDispatchAt = new Date(Date.now() - gapMs - 1000).toISOString(); + fs.writeFileSync(file, JSON.stringify(state), "utf8"); +} + function auditFiles(sandbox) { if (!fs.existsSync(sandbox.auditDir)) { return []; @@ -341,6 +348,50 @@ test("a Task dispatch is bucketed the same way as an Agent dispatch", (t) => { assert.deepStrictEqual(state.dispatches, { codex: 1 }); }); +test("two narrow dispatch waves attach the fleet advisory to the following dispatch", (t) => { + const sandbox = makeSandbox(t); + const gapMs = 10000; + const extraEnv = { FUSION_FLEET_WAVE_GAP_MS: String(gapMs) }; + + assert.strictEqual(run(sandbox, dispatchPayload(sandbox), extraEnv).stdout, ""); + assert.strictEqual(run(sandbox, dispatchPayload(sandbox), extraEnv).stdout, ""); + completeActiveWave(sandbox, gapMs); + assert.strictEqual(run(sandbox, dispatchPayload(sandbox), extraEnv).stdout, ""); + assert.strictEqual(run(sandbox, dispatchPayload(sandbox), extraEnv).stdout, ""); + completeActiveWave(sandbox, gapMs); + + const revealed = run(sandbox, dispatchPayload(sandbox), extraEnv); + const output = JSON.parse(revealed.stdout); + assert.strictEqual(output.hookSpecificOutput.hookEventName, "PostToolUse"); + assert.strictEqual(output.hookSpecificOutput.permissionDecision, undefined); + assert.strictEqual( + output.hookSpecificOutput.additionalContext, + "second consecutive narrow wave; fleet default applies: consider /fusion:ultra for the remaining packages or state fleet-decline: ." + ); + assert.strictEqual(readState(sandbox, "session-1").consecutiveNarrowWaves, 2); +}); + +test("a wide dispatch wave resets the narrow-wave advisory run", (t) => { + const sandbox = makeSandbox(t); + const gapMs = 10000; + const extraEnv = { FUSION_FLEET_WAVE_GAP_MS: String(gapMs) }; + + run(sandbox, dispatchPayload(sandbox), extraEnv); + run(sandbox, dispatchPayload(sandbox), extraEnv); + completeActiveWave(sandbox, gapMs); + assert.strictEqual(run(sandbox, dispatchPayload(sandbox), extraEnv).stdout, ""); + run(sandbox, dispatchPayload(sandbox), extraEnv); + run(sandbox, dispatchPayload(sandbox), extraEnv); + completeActiveWave(sandbox, gapMs); + assert.strictEqual(run(sandbox, dispatchPayload(sandbox), extraEnv).stdout, ""); + run(sandbox, dispatchPayload(sandbox), extraEnv); + completeActiveWave(sandbox, gapMs); + + const afterReset = run(sandbox, dispatchPayload(sandbox), extraEnv); + assert.strictEqual(afterReset.stdout, ""); + assert.strictEqual(readState(sandbox, "session-1").consecutiveNarrowWaves, 1); +}); + test("an Agent PreToolUse attempt does not reset the write window before dispatch succeeds", (t) => { const sandbox = makeSandbox(t); for (let index = 0; index < 5; index += 1) {