diff --git a/CHANGELOG.md b/CHANGELOG.md index e343597b..0c9b1192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to the claude-plugins project will be documented in this fil The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`. +### vibe v1.0.77 + +The last 2026-10-22 lessons and a trim that brings 1.0.76 and 1.0.77 together to fewer instruction words than 1.0.75. + +#### Added +- Questions to the orchestrator carry an id: `board.mjs question`, `answered` and `ack` take `--id`, and `status` prints `UNACKED` for an answer not acknowledged in 15 minutes. Answers to writers in long turns had to be sent three times. +- `hooks/foreground-guard.mjs` refuses a foreground Bash wait over 5 minutes in a vibe session's worktree, so long runs go to the background and messages reach the writer between turns. +- `green-watch.mjs --state` prints `NEW-RED` the first time a lane fails, while the rest of its batch still runs, and `mock-gap.mjs` checks each `vi.mock` block for the exports the code reads, block by block. +- The watcher's PR set is `prs.txt` plus every open PR in `WATCH_NEW_PR_REPOS` that `PR_SEARCH` lists or `WATCH_PR_AUTHOR` opened, on any branch prefix; a merged or closed PR is reported once and dropped, and `STALE-PR` names the writer from an optional third `prs.txt` column. A writer sat silent for 1h40m on two PRs the hand-kept list did not hold. +- A UserPromptSubmit hook (`hooks/away-protocol.mjs`) puts the fleet away checklist in context when the operator says they are stepping away ("brb", "stepping away", "afk") and the return checklist when they say they are back. An orchestrator once answered "brb" with a summary and ran none of the written away protocol. +- `pre-pr-lanes.mjs` refuses to open a pull request until each test lane the repository's lane report names has a passing run recorded at the head (operator ruling); the environment worker, handoff and fleet QA prep run it. + +#### Changed +- Instruction text is trimmed: prose a script now enforces keeps only the command and its reason, and repeated rules are stated once. Markdown under the vibe plugin is about 930 words shorter than in 1.0.75. + ### vibe v1.0.76 The open lessons and operator rulings from the 2026-10-22 fleet run, each with a mechanical check where one fits. diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index 84a40de4..95f97b44 100644 --- a/plugins/vibe/.claude-plugin/plugin.json +++ b/plugins/vibe/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "vibe", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", - "version": "1.0.76", + "version": "1.0.77", "dependencies": ["closedloop-core"], "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"} } diff --git a/plugins/vibe/.codex-plugin/plugin.json b/plugins/vibe/.codex-plugin/plugin.json index 14cc0471..e672e311 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.76", + "version": "1.0.77", "description": "Vibe sessions, handoff, seed refresh, and ClosedLoop ticket workflows", "author": {"name": "ClosedLoop", "email": "support@closedloop.ai"}, "homepage": "https://github.com/closedloop-ai/claude-plugins", diff --git a/plugins/vibe/agents/vibe-environment-worker.md b/plugins/vibe/agents/vibe-environment-worker.md index fe82b433..5fcb416b 100644 --- a/plugins/vibe/agents/vibe-environment-worker.md +++ b/plugins/vibe/agents/vibe-environment-worker.md @@ -392,7 +392,9 @@ changes and gates fields. inline, briefly. `blocking.prBody` refuses a line naming one. 4. Title: `: `, at most 72 characters including the ` (#NNNN)` GitHub appends, with no mention of AI - tools. Create it with + tools. First `node ../skills/vibe/scripts/pre-pr-lanes.mjs check --worktree "" --report ` + must pass (each test lane the report names has a passing run recorded at + HEAD; `BLOCKED` with its `MISSING-LANE` lines otherwise). Create it with `gh pr create --repo closedloop-ai/symphony-alpha --base main --head --title "" --body-file <file>`, the body file in the session's private git directory and named for the live ticket (`pr-body-<live ticket slug>.md`), never a shared name such as diff --git a/plugins/vibe/hooks/away-protocol.mjs b/plugins/vibe/hooks/away-protocol.mjs new file mode 100644 index 00000000..55f9fc19 --- /dev/null +++ b/plugins/vibe/hooks/away-protocol.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// UserPromptSubmit hook: when the operator says they are stepping away or are +// back, put the fleet-orchestrator away or return checklist into context. An +// orchestrator once answered "stepping away. brb." with a friendly summary +// and ran none of the away protocol, though its skill required it. +// +// Usage (hook): reads the UserPromptSubmit JSON on stdin; prints the checklist as +// additional context, or nothing. + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const AWAY = /\b(?:brb|be right back|afk|stepping away|step(?:ping)? out|going to bed|off to bed|heading (?:out|off|to bed|home)|signing off|back in (?:\d+|a (?:few|bit|while)|an hour|a minute|ten|twenty|thirty)\b)/i; +const BACK = /(?:^|[.!?]\s*)(?:i'?m back|i am back|back now|back again|ok(?:ay)?,? back)\b|^\s*back[.!]*\s*$/i; + +export const AWAY_CHECKLIST = [ + "The operator is stepping away. If you orchestrate a fleet (fleet-orchestrator), run the away protocol now, before anything else:", + "1. Write <orchDir>/attention.md: each decision needed with your recommendation, and what is blocked.", + "2. Keep the watcher armed (watcher.mjs prints `watcher ok`) and start a recurring schedule every 20 to 30 minutes (loop or cron) that reads every teammate message, answers or applies defaults, and runs `board.mjs <orchDir> status`.", + "3. Keep reading idle writers' last messages and keep attention.md current until they return.", +].join("\n"); + +export const BACK_CHECKLIST = [ + "The operator is back. If you orchestrate a fleet (fleet-orchestrator):", + "1. Lead your reply with <orchDir>/attention.md: decisions needed, recommendations, what is blocked.", + "2. Delete the away schedule you started (loop or cron), keeping the watcher armed.", +].join("\n"); + +/** The checklist an operator prompt calls for, or null. */ +export function checklistFor(prompt) { + const text = String(prompt ?? ""); + if (BACK.test(text)) return BACK_CHECKLIST; + if (AWAY.test(text)) return AWAY_CHECKLIST; + return null; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const event = JSON.parse(readFileSync(0, "utf8") || "{}"); + const checklist = checklistFor(event?.prompt); + if (checklist) { + process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: checklist } })}\n`); + } + } catch (error) { + process.stderr.write(`away-protocol: ${error instanceof Error ? error.message : String(error)}\n`); + } +} diff --git a/plugins/vibe/hooks/away-protocol.test.mjs b/plugins/vibe/hooks/away-protocol.test.mjs new file mode 100644 index 00000000..05e63468 --- /dev/null +++ b/plugins/vibe/hooks/away-protocol.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { AWAY_CHECKLIST, BACK_CHECKLIST, checklistFor } from "./away-protocol.mjs"; + +const HOOK = path.join(path.dirname(fileURLToPath(import.meta.url)), "away-protocol.mjs"); + +test("away and back phrases bring the checklist; ordinary prompts do not (brb with no away protocol)", () => { + for (const prompt of ["stepping away. brb.", "brb", "afk for 20", "going to bed, see you tomorrow", "heading out for lunch", "back in 30", "back in a few", "ok I need to step out"]) { + assert.equal(checklistFor(prompt), AWAY_CHECKLIST, prompt); + } + for (const prompt of ["I'm back", "im back, what happened?", "back now", "back.", "Okay, back. Status?"]) { + assert.equal(checklistFor(prompt), BACK_CHECKLIST, prompt); + } + for (const prompt of ["go back to the PR and fix the thread", "roll back the migration", "the back button is broken", "write it back to the ticket", "feedback on the board", "bring back the legacy list"]) { + assert.equal(checklistFor(prompt), null, prompt); + } +}); + +test("the hook adds the checklist as context and stays silent otherwise", () => { + const run = (prompt) => spawnSync(process.execPath, [HOOK], { input: JSON.stringify({ hook_event_name: "UserPromptSubmit", prompt }), encoding: "utf8" }).stdout; + const away = JSON.parse(run("stepping away. brb.")).hookSpecificOutput; + assert.equal(away.hookEventName, "UserPromptSubmit"); + assert.match(away.additionalContext, /attention\.md/); + assert.match(away.additionalContext, /every 20 to 30 minutes/); + assert.match(JSON.parse(run("I'm back")).hookSpecificOutput.additionalContext, /Delete the away schedule/); + assert.equal(run("go back to the PR"), ""); +}); diff --git a/plugins/vibe/hooks/foreground-guard.mjs b/plugins/vibe/hooks/foreground-guard.mjs new file mode 100644 index 00000000..4073e2dd --- /dev/null +++ b/plugins/vibe/hooks/foreground-guard.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// PreToolUse guard for Bash in a vibe session's worktree: no foreground command +// may wait longer than 5 minutes. A Claude pane teammate reads messages only +// between turns, so writers blocked in long foreground runs kept re-asking +// questions already answered, and answers had to be sent three times. A long +// command runs in the background (`run_in_background`) and the turn ends while +// it waits. +// +// Usage (hook): reads the PreToolUse JSON on stdin; prints a deny decision or nothing. + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const MAX_FOREGROUND_MS = 5 * 60 * 1000; + +/** Whether `cwd` is inside a vibe session's worktree (its git dir holds the session record). */ +export function inVibeSession(cwd) { + try { + const gitDir = execFileSync("git", ["-C", cwd, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + return existsSync(path.join(gitDir, "vibe-session.json")); + } catch { + return false; + } +} + +/** The deny reason for a Bash call that would wait in the foreground longer than allowed, or null. */ +export function foregroundProblem(input) { + if (input?.run_in_background === true) return null; + const timeout = Number(input?.timeout); + if (!Number.isFinite(timeout) || timeout <= MAX_FOREGROUND_MS) return null; + return `a foreground command may wait at most 5 minutes in a vibe session (timeout ${timeout} ms): run it with run_in_background and end the turn while it runs, so messages reach you between turns`; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const event = JSON.parse(readFileSync(0, "utf8") || "{}"); + const problem = event?.tool_name === "Bash" ? foregroundProblem(event.tool_input) : null; + if (problem && inVibeSession(typeof event.cwd === "string" ? event.cwd : process.cwd())) { + process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem } })}\n`); + } + } catch (error) { + process.stderr.write(`foreground-guard: ${error instanceof Error ? error.message : String(error)}\n`); + } +} diff --git a/plugins/vibe/hooks/foreground-guard.test.mjs b/plugins/vibe/hooks/foreground-guard.test.mjs new file mode 100644 index 00000000..bd875ebf --- /dev/null +++ b/plugins/vibe/hooks/foreground-guard.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { foregroundProblem } from "./foreground-guard.mjs"; + +const GUARD = path.join(path.dirname(fileURLToPath(import.meta.url)), "foreground-guard.mjs"); + +test("a foreground wait over 5 minutes is refused; background runs and short waits pass (answers sent three times)", () => { + assert.match(foregroundProblem({ command: "pnpm test", timeout: 600000 }), /at most 5 minutes/); + assert.equal(foregroundProblem({ command: "pnpm test", timeout: 600000, run_in_background: true }), null); + assert.equal(foregroundProblem({ command: "git status", timeout: 300000 }), null); + assert.equal(foregroundProblem({ command: "git status" }), null); +}); + +test("the hook applies only inside a vibe session's worktree", (t) => { + const dir = mkdtempSync(path.join(tmpdir(), "foreground-guard-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + execFileSync("git", ["init", "--quiet", dir]); + const long = { tool_name: "Bash", cwd: dir, tool_input: { command: "pnpm test", timeout: 900000 } }; + const run = (event) => spawnSync(process.execPath, [GUARD], { input: JSON.stringify(event), encoding: "utf8" }).stdout; + assert.equal(run(long), "", "outside a vibe session the guard stays silent"); + writeFileSync(path.join(dir, ".git", "vibe-session.json"), "{}\n"); + assert.equal(JSON.parse(run(long)).hookSpecificOutput.permissionDecision, "deny"); + assert.equal(run({ ...long, tool_input: { ...long.tool_input, run_in_background: true } }), ""); +}); diff --git a/plugins/vibe/hooks/hooks.json b/plugins/vibe/hooks/hooks.json index 4ac5f46c..c2f8d71c 100644 --- a/plugins/vibe/hooks/hooks.json +++ b/plugins/vibe/hooks/hooks.json @@ -4,7 +4,24 @@ { "matcher": "Bash", "hooks": [ - { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kill-guard.mjs\"" } + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/kill-guard.mjs\"" + }, + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/foreground-guard.mjs\"" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/away-protocol.mjs\"" + } ] } ] diff --git a/plugins/vibe/skills/codex-fleet-monitor/SKILL.md b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md index d8b11dd8..fee3daaf 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/SKILL.md +++ b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md @@ -5,19 +5,17 @@ description: 'Use PROACTIVELY whenever you start or are already running two or m # codex-fleet-monitor -One background command that polls every watched Codex thread and PR, and exits with a diff the moment any line changes. You read the diff, act, then re-arm it. It costs no model tokens while waiting. +One background command polls every watched Codex thread and PR and exits with a diff when any line changes; act on it, then re-arm. Waiting costs no model tokens. -It also recovers sessions on its own: a turn that failed with a transient provider error is resumed after a backoff, without waiting for you (see [Recovery](#recovery)). Between your re-arms, and when no orchestrator is awake, the [supervisor](#supervisor) does the same on the host that owns the sessions. +It also resumes a turn that failed with a transient provider error after a backoff ([Recovery](#recovery)); the [supervisor](#supervisor) does the same on each host when no orchestrator is awake. ## Why liveness is read from the thread, not the turn id -A Codex turn that hits a usage limit stays open and simply stops producing items. Checking only "is a turn active" reports it as running forever. This monitor reads each thread's last turn (status, error, newest item) and flags a turn whose newest item has not changed for `STALL_SECONDS` as `STALLED`. - -It never loads a thread's history. A long-lived thread's full read is tens of MB of JSON and pushed one process past 1 GB of RSS, so every read is `thread/read` without turns (the thread status), `thread/turns/list` for the newest turn without its items (status and error), and, for the watcher, `thread/items/list` for that turn's newest item id. An App Server without `thread/turns/list` falls back to the full read. +A Codex turn that hits a usage limit stays open and stops producing items, so "is a turn active" reports it running forever. The monitor reads each thread's last turn (status, error, newest item) and flags a turn whose newest item has not changed for `STALL_SECONDS` as `STALLED`. It never loads a thread's history (one full read passed 1 GB of RSS): `thread/read` without turns, `thread/turns/list` for the newest turn, and `thread/items/list` for its newest item, with a full read only on an App Server too old for `thread/turns/list`. ## Requirements -- Each Codex session is identified by its thread id. `scripts/liveness.mjs` is self-contained (Node built-ins only): it starts the managed App Server with `codex app-server daemon start`, connects through `codex app-server proxy`, and reads thread status as above. `scripts/ratelimit.mjs` reuses its client to call `account/rateLimits/read`; it is also the plugin's one usage reader and classifier, which the vibe skill's `codex-usage.mjs` gate imports. `scripts/recover.mjs` and `scripts/supervise.mjs` use the same client for recovery. Nothing outside the vibe plugin and the `codex` CLI is needed. +- Each Codex session is identified by its thread id. `scripts/liveness.mjs` (Node built-ins only) starts the managed App Server with `codex app-server daemon start` and connects through `codex app-server proxy`; `scripts/ratelimit.mjs` (the plugin's one usage reader, also imported by vibe's `codex-usage.mjs`), `scripts/recover.mjs` and `scripts/supervise.mjs` reuse its client. - `codex`, `node`, `jq`, `gh` (authenticated) on this machine; `codex` and `node` on the remote host, reachable through `REMOTE_SHELL`. ## Set up a work dir @@ -36,7 +34,8 @@ STALL_SECONDS=720 # no new thread items for this long means STALL # QUESTION_SECONDS=900 # an idle writer's question unanswered this long is QUESTION-UNANSWERED (default 15m) # STALE_PR_SECONDS=2700 # a PR needing its writer with no push this long is STALE-PR (default 45m) # HEARTBEAT_SECONDS=1800 # with no change, watch.sh exits with HEARTBEAT after this long (default 30m) -WATCH_NEW_PR_REPOS="owner/repo" # optional: list open PRs here to spot new ones +WATCH_NEW_PR_REPOS="owner/repo" # open PRs here that PR_SEARCH lists or WATCH_PR_AUTHOR opened join the watch set +# WATCH_PR_AUTHOR=@me # author whose open PRs are watched on any branch prefix (default the gh account) PR_SEARCH="head:feature/" # optional: search filter for that list BOT_LOGIN_REGEX="vercel|codex|github-actions|copilot|bot" # authors not counted as human feedback # USAGE_HIGH_PERCENT=80 # usage below this prints one band (default MOVE_AT_PERCENT, else 80) @@ -61,7 +60,7 @@ REMOTE_REQUESTS_DIR=/path/on/remote/requests # optional: the same on REMOTE_HOS The label is what appears in the output lines. -`prs.txt`: one line per PR to watch, `owner/repo <number>`. Add a PR when it opens, remove it when it merges. +`prs.txt`: the watch set, one line per PR, `owner/repo <number> [<writer>]`. The snapshot adds open PRs from `WATCH_NEW_PR_REPOS` on any branch, and drops a merged or closed PR after reporting it once; add the writer column when you assign a PR so `STALE-PR` names whom to nudge. A Claude pane writer has no turn state to read, so `STALE-PR` on its PR is its liveness signal. ## Run @@ -69,7 +68,7 @@ The label is what appears in the output lines. scripts/watch.sh <workDir> [intervalSeconds] ``` -Start it with `run_in_background`, never with `&`; a second copy for the same work dir exits 3 and says how to re-arm. You are notified when it exits. Read its output, handle every line, then start it again; it only watches while running. An `IDLE` line includes the writer's final message tail, the same once the App Server unloads the thread (`notLoaded`), and says `NEEDS-REPLY` when any of its lines asks a question or asks for permission, authorization or approval; classify that message before re-arming. `watch.sh` records its pid, each tick and its exit in `<workDir>/watcher.json`, and `node scripts/watcher.mjs <workDir>` prints `watcher ok` or `watcher MISSING(<why>)` (exit 1): check it before ending any turn that handled an event, and re-arm on `MISSING`. Editing `sessions.txt` or `prs.txt` takes effect on the next re-arm. `scripts/snapshot.sh <workDir>` prints the current state once. +Start it with `run_in_background`, never with `&` (a second copy for the same work dir exits 3). When it exits, handle every line, then start it again; it only watches while running. An `IDLE` line carries the writer's final message tail, also once the thread is unloaded (`notLoaded`), and says `NEEDS-REPLY` when any line asks a question or for permission, authorization or approval. Before ending any turn that handled an event, `node scripts/watcher.mjs <workDir>` must print `watcher ok`; re-arm on `MISSING`. `sessions.txt` and `prs.txt` edits take effect on the next re-arm; `scripts/snapshot.sh <workDir>` prints the state once. ## Reading the output @@ -90,16 +89,16 @@ Start it with `run_in_background`, never with `&`; a second copy for the same wo | `supervisor ... STALE(>Ns)` / `MISSING` / `UNREADABLE` | no heartbeat for 3 intervals (a hung or dead supervisor), the lock exists with no heartbeat, or the heartbeat cannot be parsed | read `supervisor.log` there; restart the supervisor | | `supervisor ... RSS-HIGH(>NMB)` | the supervisor's RSS is over `SUPERVISOR_RSS_CAP_MB` | read its hourly summary lines; restart it and report the leak | | `session remote <host> UNREACHABLE` | ssh failed | check the host | -| `codex-usage <local\|remote> under80% ok resets=<n>` / `HIGH(<band>%)` | Codex account usage on that machine: one band below `USAGE_HIGH_PERCENT` (80 here) used, so ordinary use is not an event, then the used percent floored to 10; `resets` is the count of available limit resets | none below it; on `HIGH`, warn the user at once with the band and `resets`, since applying a reset is theirs; if they are away, move the busiest writers to Claude writers | +| `codex-usage <local\|remote> under80% ok resets=<n>` / `HIGH(<band>%)` | Codex usage on that machine: one band below `USAGE_HIGH_PERCENT` (80 here), then the used percent floored to 10; `resets` counts available limit resets | on `HIGH`, warn the user at once with the band and `resets` (applying one is theirs); if they are away, move the busiest writers to Claude writers | | `claude-usage local session=<band> week=<band>` | Claude usage from `claude -p "/usage"` on the same bands (read at most every `CLAUDE_USAGE_SECONDS`, 600) | on `HIGH`, warn the user at once with its `resets=` time; if they are away, move the busiest Claude writers to Codex writers | | `codex-usage ... LIMIT` / `LIMIT-STOPPED` | usage limit reached; `LIMIT` means ordinary usage is still allowed, `LIMIT-STOPPED` means sessions cannot run | tell the user right away; a reset or second account is theirs to do | | `codex-usage ... LIMIT-SPENDING-CREDITS` | limit reached and turns are now consuming paid credits | tell the user at once and stop sending turns unless they approve paid credits | | `codex-usage ... unknown` / `unreadable READFAIL` | the App Server answered without rate limits, or it or the remote host could not be read | check `codex` on that machine | | `requests <local\|remote> <count> <newest file>` / `requests ... UNREADABLE` | a session dropped (or you removed) a delegated-work request file | hand the new request to a Claude worker, then steer its result back to the session that asked | | `open-prs <repo> <numbers>` | open PR list changed | add the new PR to `prs.txt` and start its review | -| `pr ... BLOCKED(unresolved threads)` | an open PR has unresolved review threads (`threads=<n>`), which block its merge even when it is approved with auto-merge armed | treat each thread as work: route it to the owning writer now, and never read the block as a missing approval | +| `pr ... BLOCKED(unresolved threads)` | unresolved review threads (`threads=<n>`) block the merge even when approved with auto-merge armed | route each thread to the owning writer now; it is not a missing approval | | `pr ... STALE-PR(...)` | an open PR with unresolved threads, a conflict, a draft flag or a failed check has had no push for `STALE_PR_SECONDS` (45 minutes) | ask its writer for an ETA, unblock it, or reassign it, however busy the writer looks | -| `pr ... <state> <head> <reviewDecision> <mergeable> comments= reviews= inline= threads= checks=` | any field changed | new head: re-review; count up: read the feedback; `CONFLICTING`: tell the owning session to merge the base branch; checks: act on failures. Check counts change only once settled; while any check is pending the last settled counts show with ` (running)` | +| `pr ... <state> <head> <reviewDecision> <mergeable> comments= reviews= inline= threads= checks=` | any field changed | new head: re-review; count up: read the feedback; `CONFLICTING`: tell the owning session to merge the base branch; checks: act on failures (counts change only once settled, with ` (running)` meanwhile) | Surface everything that needs a human to the user immediately: limits, paid-credit spending, stalls, failures, and new human review comments. @@ -107,8 +106,8 @@ Surface everything that needs a human to the user immediately: limits, paid-cred Each watcher tick runs `scripts/recover.mjs` on every machine that has sessions (unless that machine's supervisor is running, which owns recovery there). For a session whose last turn failed with a transient provider error: -1. It waits the next `RECOVERY_BACKOFF_SECONDS` value, then reloads the thread (`thread/resume`) and starts a turn with the continue prompt. The built-in prompt tells the session to check its real state (git status and HEAD, a merge or rebase in progress, its background jobs and slot or lock entries, its last push) and not to redo finished steps. Retries count across consecutive transient failures and reset once a turn completes. -2. If the thread is still in `systemError` after the last retry, the App Server daemon must restart. With `AUTO_RESTART_DAEMON=1` it prints a loud `!!! RESTARTING` line, runs `codex app-server daemon restart`, then resumes with the continue prompt each watched session on that machine whose turn was active (the restart interrupts it) or failed. An idle session stays idle, since a prompt would start work nobody asked for. Each session's state just before the restart is a `before-restart` event. It restarts at most once per `RESTART_COOLDOWN_SECONDS`. With the switch off it prints one `ALERT ... daemon-restart-needed` line and restarts nothing. +1. It waits the next `RECOVERY_BACKOFF_SECONDS` value, reloads the thread (`thread/resume`) and starts a turn with the continue prompt, which tells the session to check its real state (git, merges, background jobs, locks, last push) and not redo finished steps. Retries reset once a turn completes. +2. A thread still in `systemError` after the last retry needs the App Server daemon restarted. With `AUTO_RESTART_DAEMON=1` it prints `!!! RESTARTING`, runs `codex app-server daemon restart` (at most once per `RESTART_COOLDOWN_SECONDS`), records each session's `before-restart` state, and resumes only sessions whose turn was active or failed; idle ones stay idle. With the switch off it prints one `ALERT ... daemon-restart-needed` line. 3. Retries used up outside `systemError` print one `gave-up` line. A usage limit or any other error is never resumed; those stay yours. 4. An idle session whose question ends `If no answer by <ISO time>: <default>` (vibe's `session-questions.mjs`) is told to go ahead on the default once that time passes (`proceeded-on-default`). @@ -128,20 +127,18 @@ Every action is an `EVENT recovery ...` line in the events log, which the `recov ## Supervisor -`scripts/supervise.sh <workDir> [intervalSeconds]` runs on the host that owns the Codex sessions, with no Claude session involved. It loads `<workDir>/config.env` and runs a recovery pass over the `local` lines of `<workDir>/sessions.txt` every interval (default 60 seconds, or `SUPERVISE_INTERVAL_SECONDS`). Recovery state and the events log live in `LOCAL_STATE_DIR` (the work dir when unset). +`scripts/supervise.sh <workDir> [intervalSeconds]` runs on the host that owns the Codex sessions, with no Claude session involved: a recovery pass over the `local` lines of `<workDir>/sessions.txt` every interval (default 60 seconds, or `SUPERVISE_INTERVAL_SECONDS`), with state and events in `LOCAL_STATE_DIR` (the work dir when unset). - One supervisor per host: it holds `FLEET_SUPERVISOR_LOCK` (default `~/.codex/fleet-supervisor.lock`), and a second one exits with status 3. A lock left by a dead process is taken over. - On a remote host, give it a work dir whose `sessions.txt` lists that host's sessions as `local` and whose `LOCAL_STATE_DIR` is the `REMOTE_STATE_DIR` the watcher uses, so the watcher's `recovery remote` line reads the same log. -- Each pass is bounded by `PASS_TIMEOUT_SECONDS` (default 300, enough for a daemon restart and the resumes after it). A pass that runs longer gets a `pass-timeout` event, its App Server connections are closed so it unwinds and releases its lock, and the loop goes on. -- After every pass it writes `<stateDir>/.fleet-recovery/heartbeat.json`: time, pid, interval, pass count, last pass duration, last error and RSS. The watcher's `supervisor` line reads it, so a hung, dead or leaking supervisor is an event. Once an hour it logs a `supervise: hourly` line (passes, errors, longest pass, RSS). A clean stop removes the heartbeat. -- It logs to stdout, which the templates append to `<workDir>/supervisor.log`; at `LOG_MAX_BYTES` (default 1 MB) it copies that file to `supervisor.log.1` and empties it. `SUPERVISOR_LOG` names the file when it is elsewhere. The events log rotates the same way to `events.log.1`. -- Keep it running with `templates/codex-fleet-supervisor.plist` (launchd, macOS) or `templates/codex-fleet-supervisor.service` (systemd user unit, Linux). Each template says where to save it and how to load it; nothing is installed for you. On macOS restart it with `launchctl bootout` then `launchctl bootstrap`, never `launchctl kickstart -k`, which hung the calling shell and left the job waiting its throttle before it respawned. +- A pass longer than `PASS_TIMEOUT_SECONDS` (default 300) gets a `pass-timeout` event, its connections are closed and the loop goes on. +- After every pass it writes `<stateDir>/.fleet-recovery/heartbeat.json` (time, pid, interval, passes, last duration and error, RSS), which the watcher's `supervisor` line reads, and logs a `supervise: hourly` summary each hour. +- Its stdout goes to `<workDir>/supervisor.log` (or `SUPERVISOR_LOG`), rotated to `supervisor.log.1` at `LOG_MAX_BYTES` (1 MB), as `events.log` is. +- Keep it running with `templates/codex-fleet-supervisor.plist` (launchd) or `templates/codex-fleet-supervisor.service` (systemd user unit); each says how to load it. On macOS restart it with `launchctl bootout` then `launchctl bootstrap`, never `launchctl kickstart -k`, which hung the calling shell. ## Notes - `mergeable` keeps its last known value while GitHub reports `UNKNOWN`, so it does not flap. -- Each run compares against `<workDir>/last-snapshot.txt` from the previous run, so a change between a watcher exit and its re-arm is still reported. Delete the file to start from a fresh base. -- Item history lives in `<workDir>/item-history.txt`; delete it to reset stall timers. Settled check counts live in `<workDir>/checks-*.txt`. The `keep-*`, `merge-*` and `checks-*` files of a PR are removed once it leaves `prs.txt`. +- Each run compares against `<workDir>/last-snapshot.txt`, so a change between an exit and the re-arm is still reported; delete it for a fresh base. Delete `<workDir>/item-history.txt` to reset stall timers. A PR's state files go once it leaves `prs.txt`. - On the remote host the scripts are copied to `<REMOTE_STATE_DIR>/.fleet-monitor/` on every snapshot. - A failed or empty `gh` read repeats that field's last value, so an API blip is not an event. -- `watch.sh` holds `<workDir>/watch.lock`; a second copy exits 3, and a lock whose pid died is taken over. diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh index 28abbf58..1cda8969 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh @@ -1,7 +1,7 @@ #!/bin/zsh # Usage: snapshot.sh <workDir>. One line per watched item; any changed line is an event. # With RECOVER=1 (watch.sh sets it) it first runs a recover.mjs pass on each machine that has sessions. -# State files of PRs no longer in prs.txt are removed. +# State files of PRs no longer in prs.txt are removed. prs.txt lines are `<owner/repo> <number> [<writer>]`. K=${0:A:h}; W=${1:?workDir}; [ -f $W/config.env ] && source $W/config.env : ${STALL_SECONDS:=720} ${BOT_LOGIN_REGEX:=vercel|codex|github-actions|copilot|bot} ${REMOTE_SHELL:=zsh -lic} : > $W/live.txt @@ -52,14 +52,22 @@ if [ -n "${REMOTE_HOST:-}" ]; then out=$(remote_node ratelimit.mjs remote); echo if [ -n "${REQUESTS_DIR:-}" ]; then requests local ls -t $REQUESTS_DIR; fi if [ -n "${REMOTE_REQUESTS_DIR:-}" ]; then requests remote ssh -o ConnectTimeout=10 ${REMOTE_HOST:?set REMOTE_HOST} "ls -t $REMOTE_REQUESTS_DIR"; fi zsh $K/classify.sh $W/item-history.txt $STALL_SECONDS ${QUESTION_SECONDS:-900} < $W/live.txt +# The watch set is prs.txt plus every open PR in WATCH_NEW_PR_REPOS that PR_SEARCH lists or that +# WATCH_PR_AUTHOR (default the gh account) opened, whatever its branch prefix: a hand-kept prs.txt once held +# only merged PRs while a writer sat silent for 1h40m on two live ones, so STALE-PR never ran on them. +touch $W/prs.txt for r in ${=WATCH_NEW_PR_REPOS}; do op=$(gh pr list -R $r --state open --search "${PR_SEARCH:-}" --json number --jq '.[].number' 2>/dev/null | sort -n | tr '\n' ' ') echo "open-prs $r $(keep open-$r "$op")" + mine=$(gh pr list -R $r --state open --author "${WATCH_PR_AUTHOR:-@me}" --json number --jq '.[].number' 2>/dev/null) + for n in ${=op} ${(f)mine}; do + grep -Eq "^$r $n( |\$)" $W/prs.txt || echo "$r $n" >> $W/prs.txt + done done # keep-<field>-<repo>-<n>, merge-<repo>-<n> and checks-<repo>-<n> files of a PR that left prs.txt are removed. prune_pr_state() { local -A live; local r n f rest - [ -f $W/prs.txt ] && while read r n; do [ -n "$n" ] && live[${r//[^A-Za-z0-9_-]/_}-$n]=1 && live[${r//\//_}-$n]=1; done < $W/prs.txt + [ -f $W/prs.txt ] && while read r n _; do [ -n "$n" ] && live[${r//[^A-Za-z0-9_-]/_}-$n]=1 && live[${r//\//_}-$n]=1; done < $W/prs.txt for f in $W/keep-(st|c|v|i|t|ck|pu)-*.txt(N) $W/merge-*.txt(N) $W/checks-*.txt(N); do rest=${${f:t:r}#keep-}; rest=${rest#*-} [ -n "${live[$rest]:-}" ] || rm -f $f @@ -70,7 +78,8 @@ prune_pr_state ME=${MY_GH_LOGIN:-$(gh api user --jq .login 2>/dev/null)} skip="(.user.login==\"$ME\" or (.user.login|test(\"$BOT_LOGIN_REGEX\";\"i\")))" THREADS='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullRequest(number:$p){reviewThreads(first:100){nodes{isResolved}}}}}' -while read r n; do +done_prs=() +while read r n owner; do [ -z "$n" ] && continue st=$(gh pr view $n -R $r --json state,headRefOid,reviewDecision,mergeable --jq '"\(.state) \(.headRefOid[0:8]) \(.reviewDecision) \(.mergeable)"' 2>/dev/null); st=$(keep st-$r-$n "$st") m=${st##* }; k=$W/merge-${r//\//_}-$n.txt @@ -98,7 +107,10 @@ while read r n; do [[ -n ${pu#* } ]] && TZ=UTC strftime -r -s pushed '%Y-%m-%dT%H:%M:%SZ' "${pu#* }" 2>/dev/null if [[ ${st%% *} == OPEN && $pushed -gt 0 && $(( EPOCHSECONDS - pushed )) -ge ${STALE_PR_SECONDS:-2700} ]] && [[ ${t:-0} -gt 0 || $m == CONFLICTING || ${pu%% *} == true || $ck == *fail* ]]; then - stale=" STALE-PR(no push for ${STALE_PR_SECONDS:-2700}s+)" + stale=" STALE-PR(no push for ${STALE_PR_SECONDS:-2700}s+${owner:+ owner=$owner})" fi + # A merged or closed PR is reported this once, then leaves the watch set. + [[ ${st%% *} == (MERGED|CLOSED) ]] && done_prs+=("$r $n") echo "pr $r#$n $st comments=$c reviews=$v inline=$i threads=$t checks=$ck$blocked$stale" done < $W/prs.txt +for d in $done_prs; do grep -Ev "^$d( |\$)" $W/prs.txt > $W/prs.txt.tmp; mv $W/prs.txt.tmp $W/prs.txt; done diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs index 2bbdfe8c..6116ca8f 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -15,9 +15,10 @@ const FAKE_GH = `#!/bin/sh case "$*" in "api user"*) echo me ;; "api graphql"*) cat "$FAKE_ROOT/threads" 2>/dev/null || echo 0 ;; + "pr list"*--author*) cat "$FAKE_ROOT/mine" 2>/dev/null ;; "pr list"*) printf '5\\n7\\n' ;; - "pr view"*isDraft*) cat "$FAKE_ROOT/push" 2>/dev/null || echo "false $(date -u +%Y-%m-%dT%H:%M:%SZ)" ;; - "pr view"*) cat "$FAKE_ROOT/view" 2>/dev/null || echo "OPEN abcdef12 APPROVED MERGEABLE" ;; + "pr view"*isDraft*) cat "$FAKE_ROOT/push-$3" 2>/dev/null || cat "$FAKE_ROOT/push" 2>/dev/null || echo "false $(date -u +%Y-%m-%dT%H:%M:%SZ)" ;; + "pr view"*) cat "$FAKE_ROOT/view-$3" 2>/dev/null || cat "$FAKE_ROOT/view" 2>/dev/null || echo "OPEN abcdef12 APPROVED MERGEABLE" ;; "pr checks"*) printf 'build\\tpass\\t1m\\nlint\\tpass\\t1m\\n' ;; *"/issues/"*) echo 2 ;; *"/reviews"*) echo 1 ;; @@ -107,7 +108,8 @@ test('state files of a PR that left prs.txt are removed; those of a watched PR s const f = fixture(); writeFileSync(join(f.work, 'prs.txt'), 'acme/app 42\n'); f.run('ok'); - const gone = ['keep-st-acme_app-7.txt', 'keep-c-acme_app-7.txt', 'keep-ck-acme_app-7.txt', 'merge-acme_app-7.txt', 'checks-acme_app-7.txt', 'keep-st-other_repo-42.txt']; + // PR 8 is neither in prs.txt nor open in a watched repo (the fake lists 5 and 7, which are now watched). + const gone = ['keep-st-acme_app-8.txt', 'keep-c-acme_app-8.txt', 'keep-ck-acme_app-8.txt', 'merge-acme_app-8.txt', 'checks-acme_app-8.txt', 'keep-st-other_repo-42.txt']; for (const name of gone) writeFileSync(join(f.work, name), 'x\n'); f.run('ok'); for (const name of gone) assert.equal(existsSync(join(f.work, name)), false, `${name} is pruned`); @@ -153,3 +155,24 @@ test('the Claude usage line reads claude -p /usage on the Codex bands, at most o writeFileSync(join(f.root, 'claude-usage'), 'Current session: 91% used \u00b7 resets Oct 22 at 12:39pm (America/Chicago)\nCurrent week (all models): 69% used\n'); assert.match(f.run('ok'), /^claude-usage local session=under80%/m, 'the cached line holds within the interval'); }); + +test('the watch set adds open PRs on any branch prefix and drops a merged PR after reporting it once (silent writer on fix/ PRs)', TIMEOUT, () => { + const f = fixture(); + const ago = (minutes) => new Date(Date.now() - minutes * 60_000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + writeFileSync(join(f.work, 'prs.txt'), 'acme/app 42\nacme/app 9116 writer-9098\n'); + writeFileSync(join(f.root, 'view-42'), 'MERGED abcdef12 APPROVED UNKNOWN\n'); + writeFileSync(join(f.root, 'view-9116'), 'OPEN abcdef12 REVIEW_REQUIRED CONFLICTING\n'); + writeFileSync(join(f.root, 'mine'), '9149\n'); + writeFileSync(join(f.root, 'threads'), '1\n'); + writeFileSync(join(f.root, 'push-9149'), `false ${ago(100)}\n`); + writeFileSync(join(f.root, 'push-9116'), `false ${ago(100)}\n`); + const first = f.run('ok'); + assert.match(first, /^pr acme\/app#9149 OPEN .*STALE-PR\(no push for 2700s\+\)$/m, 'a fix/ PR nobody listed is watched and stale'); + assert.match(first, /^pr acme\/app#9116 .*STALE-PR\(no push for 2700s\+ owner=writer-9098\)$/m, 'the stale line names its writer'); + assert.match(first, /^pr acme\/app#42 MERGED /m, 'a merged PR is reported once'); + const watched = readFileSync(join(f.work, 'prs.txt'), 'utf8'); + assert.doesNotMatch(watched, /^acme\/app 42$/m); + assert.match(watched, /^acme\/app 9149$/m); + assert.match(watched, /^acme\/app 9116 writer-9098$/m); + assert.doesNotMatch(f.run('ok'), /#42 /, 'and then it leaves the watch set'); +}); diff --git a/plugins/vibe/skills/fleet-orchestrator/SKILL.md b/plugins/vibe/skills/fleet-orchestrator/SKILL.md index 14baaee9..ca399185 100644 --- a/plugins/vibe/skills/fleet-orchestrator/SKILL.md +++ b/plugins/vibe/skills/fleet-orchestrator/SKILL.md @@ -5,38 +5,38 @@ description: 'Use when you orchestrate several workers at once (Codex or vibe se # fleet-orchestrator -You run a program of several PRs at once: you plan, route, review, push back and report; workers write; the operator does QA, merges and makes product calls. Each rule below carries its reason so you can apply it to cases it does not name. +You run a program of several PRs at once: you plan, route, review, push back and report; workers write; the operator does QA, merges and makes product calls. Each script named below carries the incident behind its rule in its header. ## Principles 1. **The operator's attention is the scarcest resource.** Decide everything you can yourself (precedent, sibling consistency, repo rules, measurements), act on it, and tell the operator the ruling in one line so they can override. Escalate only what needs them (product trade-offs, irreversible actions, approvals the repo reserves for humans), one question at a time with a recommendation. Never forward a worker's question or result without first trying to settle or verify it. Before any ruling on behavior, follow the single ordered product-question - research list in [vibe's quality loop](../vibe/references/quality-loop.md#communication-and-product-questions); do not copy or reorder it here. Under fleet orchestration, a writer's `NEEDS_PERSON` comes to you, and you finish that list before asking the operator. + research list in [vibe's quality loop](../vibe/references/quality-loop.md#communication-and-product-questions); do not copy or reorder it here. A writer's `NEEDS_PERSON` comes to you, and you finish that list before asking the operator. A scope exclusion, deferral or "obsolete" call counts only with a human source that names the ticket or its PRD (operator ruling; `rulings-check.mjs --ruling <words> --scope <ticket>,<PRD>`): a ticket AC, a PRD line, a recorded operator ruling, or a plan with a named human approver. A self-approved plan or a handoff note never counts, and writers ask before dropping or narrowing anything. - Every question to the operator names where you checked the code first (`checked in code: <file>:<line>`); drop one the code already answers. `scripts/review-page-check.mjs` refuses a decision row without it. + Every question to the operator names where you checked the code first (`checked in code: <file>:<line>`); drop one the code already answers. A human-sourced line that conflicts with the ruling goes to the operator as a question with a recommendation. Record every ruling with its source in the resume log as `- Ruling (<who>): <decision>`, and have the writer add it to the ticket with the criteria it amends (`../handoff/scripts/rulings-check.mjs` fails until it is there) and cite it on the relevant thread. 2. **Sanity-check every worker output; never just relay it.** When a change looks absurd, oversized or irrational (several flags for one screen, a docs file nobody asked for, a plugin PR that only adds, a stale head, a weakened test, a claim the evidence does not support), push back to the worker with a simpler alternative before the operator sees it, then tell the operator what you caught. -Repo specifics (launch paths, auth, bootstrap, test commands, e2e harness) come from the repo's own docs (its `AGENTS.md` files or contributor guide) or the team knowledge base; look them up, never assume them. Machine specifics (hosts, paths, model names, slot counts) come from `<orchDir>/config.env`. +Repo specifics (launch paths, auth, bootstrap, test commands, e2e harness) come from the repo's own docs or the team knowledge base; never assume them. Machine specifics (hosts, paths, model names, slot counts) come from `<orchDir>/config.env`. ## Setup -Works on one machine with no remote config. More machines need ssh key auth between them, plus `codex`, `node`, `gh`, this plugin and the closedloop-graph MCP server on each. Prerequisites, the `config.env` keys with placeholders, and the setup checklist: [references/setup.md](./references/setup.md). +Works on one machine with no remote config. More machines need ssh key auth between them, plus `codex`, `node`, `gh`, this plugin and the closedloop-graph MCP server on each. Prerequisites, `config.env` keys and the checklist: [references/setup.md](./references/setup.md). -- First run: when `<orchDir>/config.env` is missing, or lacks a key this run needs, ask the operator once before launching anything, as one short structured question set (the harness's ask-question tool where available) with the [first-run questions](./references/setup.md#first-run-interview) and their defaults. Write `config.env` from the answers, run the checklist's verification, and report what passed. +- First run: when `<orchDir>/config.env` is missing, or lacks a key this run needs, ask the operator once before launching anything, as one structured question set with the [first-run questions](./references/setup.md#first-run-interview) and their defaults. Write `config.env`, run the checklist's verification, and report what passed. - Later runs read `config.env` and do not ask again unless a verification fails. ## Roles -- Every writer is a vibe session, and no un-vibed writer ever edits a PR branch: Codex writers start or resume with a `$vibe` prompt naming the ticket or PRD (`fleet.sh` refuses a new session whose prompt does not start with `$vibe`), and Claude writers invoke `vibe:vibe` and follow its resume path. Vibe's scope, guardrails and ticket records only bind work done inside it. -- Reviews, handoff, ticket, environment and seed work go through vibe's own skills, workers and scripts (`vibe:handoff`, its reviewer and verify workers, `vibe:vibe-seed-refresh`), never ad hoc equivalents. One process keeps one set of records and gates. -- You never edit a product repo; every change goes through a worker in its own worktree. Keeps one owner per change and your context free for judgment. -- One writer per branch at a time, Codex or Claude. Two writers on one branch overwrite each other. -- Reviewers, verifiers, QA prep and visual reviewers are read-only Claude subagents. A reviewer that fixes cannot be the independent check. -- The operator gets QA, merges and enqueues, anything irreversible, and product decisions; everything else you settle (principle 1). -- Prefer the repo's or an installed plugin's specialist over a general-purpose agent whenever one exists. Repo-specific names below are examples. +- Every writer is a vibe session: Codex writers start or resume with a `$vibe` prompt naming the ticket or PRD (`fleet.sh` refuses a new session without it), and Claude writers invoke `vibe:vibe` and follow its resume path. Vibe's scope, guardrails and ticket records only bind work done inside it. +- Reviews, handoff, ticket, environment and seed work go through vibe's own skills, workers and scripts (`vibe:handoff`, its reviewer and verify workers, `vibe:vibe-seed-refresh`), never ad hoc equivalents, so one process keeps one set of records and gates. +- You never edit a product repo; every change goes through a worker in its own worktree. +- One writer per branch at a time, Codex or Claude. +- Reviewers, verifiers, QA prep and visual reviewers are read-only Claude subagents; a reviewer that fixes cannot be the independent check. +- The operator gets QA, merges and enqueues, anything irreversible, and product decisions; you settle everything else (principle 1). +- Prefer the repo's or an installed plugin's specialist over a general-purpose agent. Repo-specific names below are examples. | Role | Agent or skill | Plugin | Fallback when not installed | |---|---|---|---| @@ -52,38 +52,37 @@ Works on one machine with no remote config. More machines need ssh key auth betw The closedloop-graph MCP server is required; decide from it, not from guesses. Tool guide: [vibe's closedloop-graph reference](../vibe/references/closedloop-graph.md). -- Before assigning work or ruling between PRs, check open-PR and ticket overlap with `query_collisions` and `blast_radius_tickets`; read a ticket's PRD lineage and children with `ticket_detail`. -- Before dispatching a ticket, and in every QA-prep brief, run vibe's newest-ruling search ([requirements worker](../../agents/vibe-requirements-worker.md), Newest ruling). On a hit, stop and ask the operator. -- In the same step, check every plan linked to the ticket (`list-artifact-links`, PRODUCES): save its `get-document` JSON and latest text and run `node ../handoff/scripts/rulings-check.mjs --plan <json> --plan-body <text>`. A plan with no human approver is not a ruling, so each exclusion it reports is scope the brief tells the writer to build or you escalate, never one to skip. A writer once skipped two tickets on an uncited Out-of-scope line of a plan vibe itself had marked approved. +- Before assigning work or ruling between PRs, check overlap with `query_collisions` and `blast_radius_tickets`; read a ticket's PRD lineage and children with `ticket_detail`. +- Before dispatching a ticket, and in every QA-prep brief, run vibe's newest-ruling search ([requirements worker](../../agents/vibe-requirements-worker.md), Newest ruling); on a hit, stop and ask the operator. +- In the same step, check every plan linked to the ticket (`list-artifact-links`, PRODUCES) with `node ../handoff/scripts/rulings-check.mjs --plan <json> --plan-body <text>`. Each exclusion it reports is scope the brief tells the writer to build, or you escalate; never one to skip. - Before a shared change, read its callers and importers (`code_callers`, `code_importers`). -- Which tests to run, or rerun, after any change, fix or merge (a conflict resolution included) is decided by `code_tests_for`, the only route to tests, over the files that change touched: run exactly those and nothing broader by default. Files that map to no tests (changelog, README, version text) run none, and CI confirms. +- After any change, fix or merge, run exactly the tests `code_tests_for` maps to the files it touched, nothing broader by default; files that map to no tests run none, and CI confirms. - Trace a surprising difference in a before and after comparison to the commit and ticket that introduced it before blaming the PR. -- Attribute a file's origin or a first failure from local history only through `scripts/history-origin.mjs --repo <repo> --path <file>`, which refuses a shallow clone, where the oldest visible commit looks like the origin. -- Every worker prompt requires the same graph-first research (the preamble and resume templates carry it). +- Attribute a file's origin or a first failure from local history only through `scripts/history-origin.mjs --repo <repo> --path <file>`. ## State and persistence -- Keep all orchestration state in one persistent directory, never `/tmp` or a session scratchpad. A forced reboot wiped `/tmp` once and orphaned every Codex thread. Layout: [references/state.md](./references/state.md). -- Write every prompt to `prompts/` before sending it. A restarted orchestrator can then see what each worker was told; `fleet.sh` only accepts prompt files. -- Keep a dated resume log (rulings, merge order, who owns what, pending items) and read it first after any restart or compaction. Your memory of the program does not survive either. +- Keep all orchestration state in one persistent directory, never `/tmp` or a session scratchpad, which a reboot wipes. Layout: [references/state.md](./references/state.md). +- Write every prompt to `prompts/` before sending it; `fleet.sh` only accepts prompt files. +- Keep a dated resume log (rulings, merge order, owners, pending items) and read it first after any restart or compaction. - After your own restart, subagents are gone: re-arm the watcher, relaunch Claude workers from their saved prompts, and tell the operator what you restarted. -- If a remote machine reboots or loses its session state, its Codex threads cannot be reattached: start fresh threads in the same worktrees with [resume prompts](./references/templates.md#resume-prompt) built from `prompts/` and the resume log. +- A remote machine that rebooted or lost session state cannot reattach its Codex threads: start fresh threads in the same worktrees with [resume prompts](./references/templates.md#resume-prompt). ## Monitoring -Run [codex-fleet-monitor](../codex-fleet-monitor/SKILL.md) (`vibe:codex-fleet-monitor`) on the same `<orchDir>` in the background for the whole program and act on its lines as its table says, including usage bands, paid-credit spending and request files. It reads the session state files `fleet.sh` writes, listed as `local|remote <name>` in `sessions.txt`. +Run [codex-fleet-monitor](../codex-fleet-monitor/SKILL.md) (`vibe:codex-fleet-monitor`) on the same `<orchDir>` in the background for the whole program and act on its lines as its table says. It reads the session state files `fleet.sh` writes, listed as `local|remote <name>` in `sessions.txt`. -- The watcher resumes a session after a transient provider failure on its own; read its `recovery` lines when you wake. Run its supervisor on every machine that hosts Codex writers, so recovery also happens while you are not running. Set `AUTO_RESTART_DAEMON=1` in `config.env` only when the operator agrees that a stuck daemon may be restarted unattended. -- Re-arm the watcher in the same step you read each event. It only watches while running, and it costs nothing while waiting. Before ending any turn that handled an event, `node ../codex-fleet-monitor/scripts/watcher.mjs <orchDir>` must print `watcher ok`; on `MISSING` (`board.mjs status` prints `WATCHER-MISSING`), re-arm it. A lost re-arm once left writers' questions unread for seven hours overnight. Start it with the harness's background option (`run_in_background`), never with `&`, which left a stray copy twice; `watch.sh` refuses a second copy for the same work dir. +- The watcher resumes a session after a transient provider failure on its own; read its `recovery` lines when you wake. Run its supervisor on every machine that hosts Codex writers. Set `AUTO_RESTART_DAEMON=1` only when the operator agrees. +- Re-arm the watcher in the same step you read each event. Before ending any turn that handled an event, `node ../codex-fleet-monitor/scripts/watcher.mjs <orchDir>` must print `watcher ok`; on `MISSING` (`board.mjs status` prints `WATCHER-MISSING`), re-arm it. Start `watch.sh` with the harness's background option (`run_in_background`), never with `&`. - The armed watcher is your heartbeat (`HEARTBEAT` every 30 quiet minutes); on every wake read every teammate message first. While the operator is away, also keep a recurring schedule (loop or cron, 20 to 30 minutes) that does the same and checks `watcher.mjs`. Recovery resumes an idle Codex writer on its default once its question's deadline passes. -- Answer a writer's question within 15 minutes: the watcher's `QUESTION-UNANSWERED` (Codex) and `board.mjs status`'s (questions Claude writers record with `board.mjs question`) mark the late ones; answer, or tell the writer to proceed on its default. -- On `STALE-PR` (a PR needing its writer with no push for 45 minutes), ask the owning writer for an ETA, unblock it, or reassign it, whatever the writer reports it is busy with; one PR sat six hours on five threads while its writer looked busy. -- Push for a writer only the head it declared push-ready (`board.mjs <orchDir> writer <name> --ready <sha>`): `board.mjs <orchDir> push-check <writer> --head <sha>` must print `READY` first. Otherwise ask the writer for its ready head and wait. A writer's committed head once went to auto-merge while the writer was still fixing two regressions in it. -- Before you or a reviewer inspect a head, check it equals the pushed PR head (`gh pr view <n> --json headRefOid`). Sessions sometimes report local, unpushed commits. +- Answer a writer's question within 15 minutes, echoing its id (`board.mjs <orchDir> answered <writer> --id <id>`); the watcher (Codex) and `board.mjs status` (Claude writers) print `QUESTION-UNANSWERED` for late questions and `UNACKED` for answers not acknowledged within 15 minutes. +- On `STALE-PR` (a PR needing its writer with no push for 45 minutes), ask the owning writer for an ETA, unblock it, or reassign it, whatever it reports being busy with. +- Push for a writer only the head it declared push-ready (`--ready <sha>` on its board row): `board.mjs <orchDir> push-check <writer> --head <sha>` must print `READY` first; otherwise ask the writer and wait. +- Before you or a reviewer inspect a head, check it equals the pushed PR head (`gh pr view <n> --json headRefOid`). ## Codex sessions -`scripts/fleet.sh` drives a session on this machine (`local`) or on `REMOTE_HOST` (`remote`, which copies the scripts and the prompt file there first) through the managed Codex App Server, using the monitor's client. +`scripts/fleet.sh` drives a session on this machine (`local`) or on `REMOTE_HOST` (`remote`, which copies the scripts and the prompt file there first) through the managed Codex App Server. ```sh scripts/fleet.sh <orchDir> local|remote session turn <name> --prompt-file <file> [--cwd <worktree>] [--model <m>] [--effort <e>] [--sandbox <mode>] @@ -91,137 +90,120 @@ scripts/fleet.sh <orchDir> local|remote session steer <name> --prompt-file <file scripts/fleet.sh <orchDir> local|remote session last <name> [--count <n>] ``` -- `turn` starts a thread bound to `--cwd` the first time (refusing a different cwd later) and resumes it afterwards, then starts a turn. - - `--model` and `--effort` default to `CODEX_MODEL` and `CODEX_EFFORT`. - - A new session refuses to start without a model or an effort, or with a prompt that does not start with `$vibe`. - - Start every Codex writer this way, never through a bare thread start without both: one came up on a model that ran about 17 tokens per second and another on the default effort. The watcher marks a thread on another model or effort `MODEL(...)`; restart it on the configured one. - - Model, effort, sandbox and each prompt file sent persist in the session's state file. -- `steer` adds to the active turn and fails when none is running. -- `last` prints the monitor's status line and the last agent messages. - -- Run Codex at the high effort level, not the maximum: the maximum burns the limit far faster for little gain on PR work. The thread runs on `CODEX_MODEL`; vibe's own worker-model policy picks each sub-agent's model, so never name one in a prompt. -- No computer use in Codex: no browser, in-app browser, desktop app UI or screenshots. It is the fastest drain. Code host work goes through its CLI. -- Visual work is a request file a Codex session writes only through `scripts/request-visual.mjs` ([template](./references/templates.md#visual-request)), which refuses a head that is not the PR's pushed head; a Claude worker does it and you steer the findings back as text. +- `turn` starts a thread bound to `--cwd` the first time (refusing a different cwd later) and resumes it afterwards, then starts a turn. `--model` and `--effort` default to `CODEX_MODEL` and `CODEX_EFFORT`; a new session refuses to start without a model or an effort, or with a prompt that does not start with `$vibe`. Start every Codex writer this way; the watcher marks a thread on another model or effort `MODEL(...)`, so restart it on the configured one. +- `steer` adds to the active turn and fails when none is running; `last` prints the monitor's status line and the last agent messages. +- Run Codex at the high effort level, not the maximum, which burns the limit far faster for little gain. Never name a sub-agent model in a prompt; vibe's worker-model policy picks them. +- No computer use in Codex (browser, in-app browser, desktop app UI, screenshots): it is the fastest drain. Code host work goes through its CLI. +- Visual work is a request file a Codex session writes only through `scripts/request-visual.mjs` ([template](./references/templates.md#visual-request)); a Claude worker does it and you steer the findings back as text. ## Claude workers -- Launch each Claude writer with the [worker preamble](./references/templates.md#worker-preamble) saved to `prompts/`. It starts with invoking vibe and carries the worktree, PR, reading, test, git, thread-reply and report rules so no writer improvises them. -- A message to a busy worker arrives only when it goes idle; if it idles without acting on one, resend it once, then interrupt after the second unanswered delivery instead of sending a third. -- A pane teammate sees your messages only when its current turn ends. For an urgent redirect, ask the operator to press Esc in the named pane, which ends the turn and delivers them; otherwise expect delivery at the next turn boundary. Keep long briefs in a file and send its path. -- A standing duty (main's post-merge health, a PR shepherd, the watcher) always has a live owner: `board.mjs <orchDir> duty <duty> --owner <writer or helper>`. Name the replacement in the same step you stop or move its owner; `helpers.mjs stopped` refuses an owner, and `board.mjs status` prints `UNOWNED-DUTY`. Main's health once went unwatched for an hour after its watcher was stopped to save budget. -- Stop a helper (reviewer, verifier, QA prep, poller) as soon as you accept its result; its brief and findings are on disk, so the next trigger launches a fresh one. Idle helper panes hide the active ones from the operator. Record each with `scripts/helpers.mjs <orchDir> start|accepted|stopped <name>`, and run `scripts/helpers.mjs <orchDir> status` with every monitor event: each `STOP` line is a helper to stop now, each `ORPHAN` line a stopped helper whose processes still run. A helper records every process it starts (dev servers, app stacks) with `helpers.mjs <orchDir> pid <name> <pid...>`, adding `--group` only for one it spawned detached; `stopped` ends them and refuses while any lives. Stopped QA agents once left dev servers running for 13 to 16 hours. +- Launch each Claude writer with the [worker preamble](./references/templates.md#worker-preamble) saved to `prompts/`. +- A pane teammate sees messages only when its turn ends, so writers keep turns short (vibe's foreground guard refuses a foreground wait over 5 minutes in a vibe worktree). On `UNACKED`, or an idle worker that ignored a message, resend once, then interrupt after the second unanswered delivery: ask the operator to press Esc in the named pane. Keep long briefs in a file and send its path. +- A standing duty (main's post-merge health, a PR shepherd, the watcher) always has a live owner: `board.mjs <orchDir> duty <duty> --owner <writer or helper>`. Name the replacement in the same step you stop or move its owner; `helpers.mjs stopped` refuses an owner, and `board.mjs status` prints `UNOWNED-DUTY`. +- Stop a helper (reviewer, verifier, QA prep, poller) as soon as you accept its result; idle helper panes hide the active ones. Record each with `scripts/helpers.mjs <orchDir> start|accepted|stopped <name>` and run `helpers.mjs <orchDir> status` with every monitor event (`STOP`: stop it now; `ORPHAN`: its processes still run). A helper records every process it starts with `helpers.mjs <orchDir> pid <name> <pid...>`, adding `--group` only for one it spawned detached; `stopped` ends them and refuses while any lives. ## Fleet board and direct messages -Writers that could not see each other's work collided, a QA guide never heard its checkpoint was ready, and a UX ruling reached one writer while another PR rendered the same behavior. `scripts/board.mjs` keeps `<orchDir>/board.md`, the page every writer reads: +`scripts/board.mjs` keeps `<orchDir>/board.md`, the page every writer reads: -- The status sweep, a standing `status-sweep` duty. At every writer assignment and every merge, and at every status report, gather each in-scope PRD's children (status, assigned writer, linked pull requests and their state, the latest body of each merged or DONE one, its plans, and the umbrella that absorbed it) into a snapshot and run `node scripts/status-audit.mjs --snapshot <file>`. It fails until each `MISMATCH`, `UNOWNED`, `UNREAD`, `UNCHECKED`, `PLAN-OPEN` and `ABSORBED-OPEN` child is fixed or listed under `outOfScope` with who ruled it out. Closing an umbrella ticket closes the children it absorbed in the same step, with its merge as evidence. A merge alone never makes a ticket DONE: its criteria are checked against the merged code first. -- When you launch a writer, add its row (`board.mjs <orchDir> writer <name> --runtime codex|claude --pr <n> --repo <owner/repo> --head <sha> --status <text> --deps <a,b>`) and give it the board path and the messaging recipe ([worker preamble](./references/templates.md#worker-preamble)). Update the row on every push report. +- The status sweep, a standing `status-sweep` duty. At every writer assignment and every merge, and at every status report, gather each in-scope PRD's children (status, writer, pull requests and their state, the latest body of each merged or DONE one, its plans, and the umbrella that absorbed it) into a snapshot and run `node scripts/status-audit.mjs --snapshot <file>`. It fails until each `MISMATCH`, `UNOWNED`, `UNREAD`, `UNCHECKED`, `PLAN-OPEN` and `ABSORBED-OPEN` child is fixed or listed under `outOfScope` with who ruled it out. Closing an umbrella ticket closes the children it absorbed in the same step, with its merge as evidence. A merge alone never makes a ticket DONE: its criteria are checked against the merged code first. +- When you launch a writer, add its row (`board.mjs <orchDir> writer <name> --runtime codex|claude --pr <n> --repo <owner/repo> --head <sha> --status <text> --deps <a,b>`) and its PR's `<owner/repo> <n> <writer>` line to `prs.txt`, and give it the board path and the [worker preamble](./references/templates.md#worker-preamble). Update the row on every push report. - Writers message each other directly: Codex writers through the `app-server-orchestrator` skill's send, Claude workers and QA guides through SendMessage. -- A settled UX ruling is saved once as a reference file with its screenshots in a shared evidence location. Find every renderer of the behavior with `code_grep` across each open PR's diff and main, then `board.mjs <orchDir> deliver <ruling> --kind ruling --reference <file> --to <every owning writer and QA guide>`. Every QA plan for that behavior cites the reference, and one shared component beats each PR building its own. +- A settled UX ruling is saved once as a reference file with its screenshots in a shared evidence location. Find every renderer of the behavior with `code_grep` across each open PR's diff and main, then `board.mjs <orchDir> deliver <ruling> --kind ruling --reference <file> --to <every owning writer and QA guide>`. Every QA plan for that behavior cites it; one shared component beats each PR building its own. - When QA prep marks a human checkpoint ready, or rebinds results, `deliver <checkpoint> --kind checkpoint --head <sha> --to <guide>` and message the guide yourself with the checkpoint, its launch steps and the head. -- After each message, `board.mjs <orchDir> told <item> <recipient>`. Run `board.mjs <orchDir> status` with every monitor event: each `STALE` row is a writer whose pushed head the board does not show, each `UNTOLD` line someone still to tell, each `UNOWNED-DUTY` a duty to give an owner now. A `SLOW` row is a Codex writer 30 minutes without a push on a PR with unresolved human threads: have it push and hand the branch to a Claude writer (Codex to Claude, below), as for a dead writer. +- After each message, `board.mjs <orchDir> told <item> <recipient>`. Run `board.mjs <orchDir> status` with every monitor event and act on each line: `STALE` (the board misses a pushed head), `UNTOLD`, `UNOWNED-DUTY`. A `SLOW` row is a Codex writer 30 minutes without a push on a PR with unresolved human threads: have it push and hand the branch to a Claude writer. ## Moving work between runtimes and accounts -- When a machine's `codex-usage` band reaches `MOVE_AT_PERCENT`, or its trend says it will before the work is done, tell the operator at once with the band and `resets=<n>`: applying a limit reset is theirs. When they are away, move its busiest writers to Claude writers now instead of waiting for an answer. Move them on any `LIMIT` or `LIMIT-SPENDING-CREDITS` line too. Claude usage is the watcher's `claude-usage` line (from `claude -p "/usage"`, session and weekly bands on the same threshold): on a `HIGH` band tell the operator at once with its reset time, and when they are away move the busiest Claude writers to Codex writers; move one at once when it hits its limit. +- When a machine's `codex-usage` band reaches `MOVE_AT_PERCENT`, or will before the work is done, tell the operator at once with the band and `resets=<n>`: applying a reset is theirs. When they are away, move its busiest writers to Claude writers now; move them on any `LIMIT` or `LIMIT-SPENDING-CREDITS` line too. Claude usage is the watcher's `claude-usage` line (from `claude -p "/usage"`, same thresholds): on a `HIGH` band tell the operator with its reset time, and when they are away move the busiest Claude writers to Codex; move one at once when it hits its limit. - When the operator reports high Claude usage, stop idle and polling Claude workers first, ask active ones to push a checkpoint and end, move their remaining work to Codex writers, and widen the Claude polling interval. -- Before any move the old writer stops, or is confirmed dead, after committing and pushing or reporting its uncommitted state. One writer per branch holds through every move. +- Before any move the old writer stops, or is confirmed dead, after committing and pushing or reporting its uncommitted state. - Codex to another Codex machine or account: a fresh thread through `fleet.sh` in the branch's worktree there, with a [resume prompt](./references/templates.md#resume-prompt). -- Codex to Claude: a Claude writer in the same worktree with the [takeover preamble](./references/templates.md#worker-preamble). It resumes the same vibe session and moves its writer with vibe's harness switch before any other step; a Claude writer beside a ledger that still names the stopped Codex writer has every environment request refused. +- Codex to Claude: a Claude writer in the same worktree with the [takeover preamble](./references/templates.md#worker-preamble), which moves the vibe session's writer with vibe's harness switch before any other step. - Claude to Codex: a Codex vibe session through `fleet.sh` in the same worktree, with a resume prompt. -- Fresh and resumed threads may stop to ask about a plugin version update, so every resume prompt says to keep the current version. +- Every resume prompt says to keep the current plugin version, since fresh and resumed threads may ask. ## Machines and CPU -- Run heavy checks (full test suites, typecheck, repo-wide lint and other gates, component story runs) on the least loaded machine, in the worker's own worktree there ([checklist step 4](./references/setup.md#checklist) gets the branch there). Keep the operator's daily machine for edits and git. -- Balance heavy work by measured load, not a fixed machine: before each heavy run, compare load per core (`uptime` against the core count) on each machine and send the run to the lower one. A fixed choice once drove one machine to more than twice its core count while the other sat idle. -- Cap heavy runs per machine at its `HEAVY_SLOTS` or `REMOTE_HEAVY_SLOTS`: `scripts/fleet.sh <orchDir> local|remote slot heavy <owner> <worktree> <command...>`. -- A shared e2e stack with fixed ports runs only under vibe's machine-wide e2e lock, the one every vibe session's e2e command already takes: `node ../vibe/scripts/e2e-lock.mjs run <command...>` on the machine that runs the stack. A second lock for the same stack would let two runs collide. The lock gives each run its own compose project, so a teardown removes only its own stack; name a stack another tool started on the same ports with `--foreign-compose <project>`. Only runs that start that stack take it; tests against a stand-in server, component story runs, unit tests and typecheck run outside it in parallel. -- `scripts/slot-lock.sh` underneath frees the slot when the command exits or is interrupted and reclaims one whose holder pid died; `scripts/slot-lock.sh --status <lockName>` on that machine lists holders. -- Kill only processes you started, by recorded pid, never by pattern: vibe's `hooks/kill-guard.mjs` refuses `pkill`, `killall` and `kill` of an unrecorded pid in every Claude session with the plugin. Other runs on that machine belong to someone else. Never signal a pid of 1 or lower or a process group you did not spawn: `kill(-1)` from a plugin test once ended every app on the operator's machine. Tests that send signals run on another machine, never the operator's daily one. +- Run heavy checks (full suites, typecheck, repo-wide lint and other gates, component story runs) through `scripts/fleet.sh <orchDir> local|remote slot heavy <owner> <worktree> <command...>`, in the worker's own worktree on that machine ([checklist step 4](./references/setup.md#checklist) gets the branch there). It caps runs at `HEAVY_SLOTS` or `REMOTE_HEAVY_SLOTS` and refuses a machine over `MAX_LOAD_PER_CORE` (exit 4): send the run to the other machine. Keep the operator's daily machine for edits and git; e2e and desktop app runs never go there. +- A shared e2e stack with fixed ports runs only under vibe's machine-wide e2e lock: `node ../vibe/scripts/e2e-lock.mjs run <command...>` on the machine that runs it, never a second lock. Each run gets its own compose project, so a teardown removes only its own stack; name a stack another tool started with `--foreign-compose <project>`. Unit tests, typecheck, story runs and stand-in server tests run outside it. +- `scripts/slot-lock.sh --status <lockName>` on a machine lists slot holders. +- Kill only processes you started, by recorded pid, never by pattern: vibe's `hooks/kill-guard.mjs` refuses `pkill`, `killall` and `kill` of an unrecorded pid in every Claude session with the plugin. Never signal a pid of 1 or lower or a process group you did not spawn. Tests that send signals run on another machine, never the operator's daily one. - Plugin marketplaces and env files on a remote machine live in persistent paths too. ## Quality gates and QA - QA happens before merge; never call a PR ready to enqueue before its QA is done. - Each PR moves through, in order: independent review → writer fixes → verification of the fixes → design or visual review where UI changed → full check at the final head → QA plan (agent-verified plus human-only checkpoints) → operator QA → one main merge → operator enqueues. A head change sends it back to the earliest step the change affects. -- QA runs on the feature-complete head, even when it conflicts with main; on a fast-moving main, a head kept current before QA goes stale during QA. vibe's publication leaves main unmerged until then; after QA, tell the writer to set final-merge mode (`vibe-sessions.mjs touch --main-merge final`), so its next publication merges main once, resolving version-file conflicts, reruns only the tests `code_tests_for` selects for the files the merge touched, and the operator enqueues at once; the merge queue retests against main. Before telling the operator to enqueue, `node ../handoff/scripts/qa-merge-order.mjs --worktree <wt> --qa-head <QA'd sha>` must pass, and so must vibe's criteria audit and rulings check (`../handoff/SKILL.md`). From then on, shepherd the pull request with `../vibe/scripts/shepherd.mjs`, polling every two minutes and re-merging main at once on a conflict. Never ask a writer to merge main mid-work unless a conflict blocks the build itself. -- Criteria are fixed before the build, because nobody is in the loop to ask for them while it runs. - - A writer starts only once its plan holds a numbered acceptance-criteria list. - - Each criterion is sourced to a ticket AC, a PRD line or an operator ruling. - - A mid-flight ruling joins that list with its date. - - An unrequested write path, a missed operator ruling and a mislabeled defect each traced back to having no such list. -- Before an audit, reconcile operator chat and AskUserQuestion answers plus guide-session and QA records into `<orchDir>/rulings.md`, then run `node ../handoff/scripts/rulings-check.mjs --program <orchDir>/rulings.md --record <transcript-or-record> ...`; audit the acceptance criteria together with those rulings. The applicable per-ticket entries remain enforced by handoff's `rulings-check.mjs`. -- Before showing `<orchDir>/review.md`, run `node scripts/review-page-check.mjs <orchDir>/review.md`; duplicate or untyped finding IDs block the report. -- The QA plan is a two-way trace between that list and the diff: each checkpoint cites one criterion ID, a diff change mapping to no criterion is a finding, never a checkpoint, and a criterion with no change and no coverage is a gap. Regression checks on neighboring surfaces the diff touches are labeled separately; a regression failure is a bug, never a new requirement. -- Agent-first QA, zero human checkpoints by default: an agent verifies everything it can (e2e on the current head, DOM and accessibility reads, API second views, headless screenshots it compares with the saved reference itself) and records it. The operator gets only checkpoints an agent cannot cover: real-device feel, assistive tech, flows an agent cannot drive, a product call no reference settles; measure the rest (contrast ratios, box geometry, focus targets). `node scripts/qa-plan-check.mjs --plan <QA record> --criteria <ticket body> --prd <PRD body>` must pass before the plan reaches the operator: every rollout, flag and rollback requirement is a checkpoint, and it refuses a human checkpoint without such a reason, a visual one included. A defect an agent already observed goes to the writer with its measurement, not to the human checklist. -- A confirmed visual-QA finding on a surface the change touches is a required fix even when it predates the branch, is already on main or was called an owner's decision. User-facing copy still needs a human source: reuse approved copy or escalate the wording. -- Before handoff, `node scripts/finding-brief.mjs <orchDir> --findings <findings.json> --require-disposition` must pass. Every touched-surface finding records `fixed: <sha>` or, only when a fix cannot be made in this pull request, `ticket: <id>`. -- A QA rerun that toggles flags or moves data never uses the stack handed to the operator: `helpers.mjs <orchDir> hand <stack> --to <guide>` when the guide gets it, `unhand` when QA ends, and start the rerun helper with `--stack <its own stack>` (refused on a handed one). -- Writers push a tested checkpoint at least every 45 minutes of active work and never hold one for optional evidence (vibe's commit reports `pushDue`); review findings after that go out as follow-up commits reviewed per diff, and never add new scope to an unpushed checkpoint. A critical-path PR once sat unpushed for 5 hours while each review pass added scope. +- QA runs on the feature-complete head, even when it conflicts with main. After QA, tell the writer to set final-merge mode (`vibe-sessions.mjs touch --main-merge final`): its next publication merges main once and reruns only the tests `code_tests_for` selects for the merge, and the operator enqueues at once. Before that, `node ../handoff/scripts/qa-merge-order.mjs --worktree <wt> --qa-head <QA'd sha>`, vibe's criteria audit and rulings check (`../handoff/SKILL.md`) must pass. Then shepherd the pull request with `../vibe/scripts/shepherd.mjs`. Never ask a writer to merge main mid-work unless a conflict blocks the build. +- A writer starts only once its plan holds a numbered acceptance-criteria list, each criterion sourced to a ticket AC, a PRD line or an operator ruling; a mid-flight ruling joins it with its date. Nobody is in the loop to ask while the build runs. +- Before an audit, reconcile operator chat and AskUserQuestion answers plus guide-session and QA records into `<orchDir>/rulings.md`, then run `node ../handoff/scripts/rulings-check.mjs --program <orchDir>/rulings.md --record <transcript-or-record> ...`; audit the acceptance criteria together with those rulings. +- Before showing `<orchDir>/review.md`, run `node scripts/review-page-check.mjs <orchDir>/review.md`. +- The QA plan is a two-way trace between that list and the diff: each checkpoint cites one criterion ID, a diff change mapping to no criterion is a finding, and a criterion with no change and no coverage is a gap. Regression checks on neighboring surfaces are labeled separately; a regression failure is a bug, never a new requirement. +- Agent-first QA, zero human checkpoints by default: an agent verifies everything it can (e2e on the current head, DOM and accessibility reads, API second views, headless screenshots compared with the saved reference) and records it, measuring what it can (contrast ratios, box geometry, focus targets). The operator gets only real-device feel, assistive tech, flows an agent cannot drive, or a product call no reference settles. `node ../vibe/scripts/pre-pr-lanes.mjs check --worktree <wt> --report <lane report>` (each lane at the head) and `node scripts/qa-plan-check.mjs --plan <QA record> --criteria <ticket body> --prd <PRD body>` must pass before the plan reaches the operator. A defect an agent already observed goes to the writer with its measurement. +- A confirmed visual-QA finding on a surface the change touches is a required fix even when it predates the branch, is already on main or was called an owner's decision. User-facing copy still needs a human source. +- Before handoff, `node scripts/finding-brief.mjs <orchDir> --findings <findings.json> --require-disposition` must pass. +- A QA rerun that toggles flags or moves data never uses the stack handed to the operator: `helpers.mjs <orchDir> hand <stack> --to <guide>` when the guide gets it, `unhand` when QA ends, and start the rerun helper with `--stack <its own stack>`. +- Writers push a tested checkpoint at least every 45 minutes of active work and never hold one for optional evidence (vibe's commit reports `pushDue`); later review findings go out as follow-up commits, and no new scope joins an unpushed checkpoint. - Human QA runs in a dedicated Claude QA-guide session per PR that the operator talks to directly. When the PR's QA plan is ready: 1. Save the [QA guide prompt](./references/templates.md#qa-guide-prompt) to `prompts/`. 2. Launch it headless from the repo root (`claude -p --output-format json` with the tools it needs allowed). 3. Take `session_id` from the JSON and give the operator `cd <repo> && claude --resume <session_id>`. 4. Keep a background wait on its findings file, re-armed like the monitor, and route each failure to the writer. - Send QA findings to a writer only through `node scripts/finding-brief.mjs <orchDir> --findings <findings.json> --prompt <prompt> [--where remote]`: it refuses a finding with no story id or route, viewport, selector or screenshot, or explicit `touchedSurface`, and puts the screenshots where the writer's machine can open them (`fleet.sh` ships them with the prompt). - You alone file a ticket for a QA finding (the QA guide never does), and only after searching open tickets created in the last day for the same subject. Two actors once filed the same follow-up a minute apart. - Group audit and bug fixes into as few tickets and PRs as speed allows (operator ruling): one umbrella ticket, with PRs split by code area so writers work in parallel, never one ticket and PR per finding or per PRD. - Before filing a ticket or moving one to in review, run `node ../vibe/scripts/ticket-write-check.mjs` with its project, parents and status; it refuses a past sprint, a parent that is executed, obsolete, done or canceled, and in review without the delivering pull request. Set the project explicitly rather than taking the default. -- Check the test selection in each push report (the PR evidence file's selected tests and their runs) and have QA compare it with the specs the PR edits; selection covers the whole branch diff, not the latest push. + Send QA findings to a writer only through `node scripts/finding-brief.mjs <orchDir> --findings <findings.json> --prompt <prompt> [--where remote]`. + You alone file a ticket for a QA finding (the QA guide never does), and only after searching open tickets created in the last day for the same subject. + Group audit and bug fixes into as few tickets and PRs as speed allows (operator ruling): one umbrella ticket, with PRs split by code area so writers work in parallel. + Before filing a ticket or moving one to in review, run `node ../vibe/scripts/ticket-write-check.mjs` with its project, parents and status, and set the project explicitly. +- Check the test selection in each push report against the specs the PR edits; selection covers the whole branch diff, not the latest push. - Every PR gets an independent review, then a verification pass of the fixes, before it is QA-ready. Resolve a thread only after the fix is verified in code, with a reply naming the fixing SHA. - One review round per PR (operator ruling): threads raised after the round-one fixes are pushed never block or delay the merge. A valid one becomes a criterion on the PR's one follow-up ticket (created once in the current sprint project, assigned to the operator, `TODO`, related to the PR's ticket, titled `<PR ticket>: follow-ups from #<PR> review`; later rounds append to it), and the thread gets that ticket's link or why it is invalid, then is resolved. `shepherd.mjs --round-one-fixed-at` and `review-followup.mjs` enforce both. -- Before enqueue, run the lanes the diff can affect wherever PR CI skips them (the repo's lane selector, never full suites by default), and resolve shared version-file conflicts right before each enqueue, in merge order. The queue rebuilds on current main, and a skipped suite is not a pass. -- A release-version bump every UI PR touches (a component catalog version and its history entry) is made only at enqueue time, and the operator arms auto-merge in the same minute; made earlier, it re-conflicts with every other open PR. -- A fix merged to main reaches a preview only once the ref the preview's backing environment deploys from contains it: check ancestry against that ref (`git merge-base --is-ancestor <fix> <ref>`) before calling a preview fix live. +- Before enqueue, run the lanes the diff can affect wherever PR CI skips them (the repo's lane selector, never full suites by default), and resolve shared version-file conflicts right before each enqueue, in merge order. A skipped suite is not a pass. +- A release-version bump every UI PR touches (a component catalog version and its history entry) is made only at enqueue time, with auto-merge armed the same minute, or it re-conflicts every other open PR. +- A fix reaches a preview only once the ref that preview deploys from contains it: check `git merge-base --is-ancestor <fix> <ref>` before calling it live. - Track merge order for stacked or overlapping PRs and tell each writer which one merges first. - A before and after comparison uses the candidate's merge-base or current main as the baseline, never an unrelated branch. ## Pushback -Pushback applies principle 2; more judgment patterns from past programs: [references/judgment.md](./references/judgment.md). +Pushback applies principle 2; more patterns: [references/judgment.md](./references/judgment.md). -- Debt to push back on also includes parallel code paths, shims and a second owner for the same behavior. Forwarding it for approval as is makes the operator do your review. +- Debt to push back on includes parallel code paths, shims and a second owner for the same behavior; forwarding it for approval as is makes the operator do your review. - No agent adds a key to the team's feature-flag service without the operator approving that key ([approval template](./references/templates.md#flag-approval)). Prefer one key per program or surface; corrections and removals are never gated. -- An instruction to match a prototype or design never removes what main ships: diff main's shipped controls and items against the target before you brief a writer, and keep any the target leaves out unless a human source retires it. +- An instruction to match a prototype or design never removes what main ships: diff main's shipped controls against the target before briefing a writer, and keep any it leaves out unless a human source retires it. - Never invent product behavior or user-facing copy; cite the human source. Never write a row the repo reserves for human attestation; read the owning check's header and ask the operator. - Respect operator boundaries on other teams' work, such as "don't fix main" while another owner is fixing it. -- Work outside the program goes to its owner (operator ruling): a security or infrastructure finding outside its scope, such as a runtime advisory, becomes a ticket assigned to that owner, and the worker investigating it stops. The program does not take the fix. +- Work outside the program goes to its owner (operator ruling): a security or infrastructure finding outside its scope becomes a ticket assigned to that owner, and the worker investigating it stops. ## Program practices -- Green watch: `board.mjs` requires a `green-watch` owner while PRs are watched (`MISSING-DUTY`). After program PRs merge, that owner follows each post-merge batch until `GREEN_REF` advances, with `GREEN_WATCH_AGENT` where the repo has one, and runs `node scripts/green-watch.mjs --repo <owner/repo> --worktree <clone> --program <merge shas>` before every report: it re-lists main's runs each time and covers every lane of every run containing a program PR, so a batch that starts later is not missed. It attributes every red to its PR and author, fixes ours in one PR, and files one ticket per other author in the current sprint, assigned to them with evidence and a fix. It never cuts a release. -- Placement: `fleet.sh slot heavy` refuses a machine whose load per core is over `MAX_LOAD_PER_CORE` (exit 4); send the run to the other machine. E2e and desktop app runs never go on the operator's daily machine. +- Green watch: `board.mjs` requires a `green-watch` owner while PRs are watched (`MISSING-DUTY`). After program PRs merge, that owner follows each post-merge batch until `GREEN_REF` advances, with `GREEN_WATCH_AGENT` where the repo has one, and runs `node scripts/green-watch.mjs --repo <owner/repo> --worktree <clone> --program <merge shas> --state <file>` before every report and triages each `NEW-RED` lane at once; a missing-mock red is checked per mock block with `scripts/mock-gap.mjs`. It attributes every red to its PR and author, fixes ours in one PR, and files one ticket per other author in the current sprint, assigned to them with evidence and a fix. It never cuts a release. - Repo knowledge: a repo fact a worker learns goes into the team's repo memory (`REPO_MEMORY_CMD`), and into the nearest `AGENTS.md` through that worker's PR when it binds code. -- Close-out: `executed-check.mjs --kind prd` before a PRD is executed (its flags section first) and `status-audit.mjs` at every merge and writer assignment. - Deadline mode, only when the operator rules it (record it in `rulings.md` with its end): no pre-merge e2e, follow-up pushes run focused tests plus the affected typecheck and leave full lanes to CI, and extras ship in follow-up PRs. - Runtime choice: judgment-heavy work and takeovers go to Claude writers, which are faster; bulk mechanical work goes to Codex on `CODEX_MODEL`, which is cheaper. Move work on usage and speed. ## Lessons loop -- Log every finding you route to a writer in the lessons ledger at the same moment, tagged new or repeat, with owner and fix. Batched later, they get lost. Format: [references/state.md](./references/state.md#lessons-ledger). +- Log every finding you route to a writer in the lessons ledger at the same moment, tagged new or repeat, with owner and fix. Format: [references/state.md](./references/state.md#lessons-ledger). - A repeat after a shipped fix means the earlier encoding did not bite: make it a gate, not more prose. - A plugin PR that encodes lessons replaces the prose its gate enforces and reports the net change in instruction length. The plugin must not get heavier. -- Batch lesson and tooling fixes into one open PR per repo and add to it until it merges, never one PR per lesson: each PR costs a full CI run. Before opening one, `node scripts/lessons-pr.mjs --repo <owner/repo>` must print `no open lessons PR`; otherwise push the batch onto the branch it names. Four stacked lessons PRs once had to be restacked onto main. +- Batch lesson and tooling fixes into one open PR per repo until it merges. Before opening one, `node scripts/lessons-pr.mjs --repo <owner/repo>` must print `no open lessons PR`; otherwise push onto the branch it names. - A lesson's primary fix lives in vibe itself, its writer instructions plus a mechanical check, because many people run vibe without an orchestrator; a rule here is only a second layer. -- A lessons PR encodes general rules only. One repo's product rules, paths and component names go in that repo's own instruction files, gates or knowledge base, never in this plugin. +- A lessons PR encodes general rules only. One repo's product rules, paths and component names go in that repo's own instruction files, gates or knowledge base. ## Artifacts people read -Hard rule. A ticket, PR body, PR comment, review reply or any other artifact written for people never references a path on the operator's machine (a home path such as `~/`, a temp, scratchpad or worktree path) or says something is on someone's laptop. +Hard rule. A ticket, PR body, PR comment, review reply or any other artifact written for people never references a path on the operator's machine (a home, temp, scratchpad or worktree path) or says something is on someone's laptop. -- Evidence is uploaded and embedded: a screenshot goes into the ticket as an inline image through the ticket tool (for example a new document version with inline images); text evidence is quoted inline, briefly. -- A plan the ticket references is attached, never pointed at: an implementation plan is a plan document linked to the ticket, and a QA plan or other working file is uploaded as an attachment on the ticket and named by its attachment name. +- Evidence is uploaded and embedded: a screenshot goes into the ticket as an inline image; text evidence is quoted inline, briefly. +- A plan the ticket references is attached, never pointed at: an implementation plan is a plan document linked to the ticket; a QA plan or other working file is a ticket attachment named by its attachment name. - Check every worker draft for this before it is posted; vibe's ticket and PR body checks refuse a local path. ## Reporting to the operator -- Surface every problem the moment you see it: limits, stalls, failures, new human review comments, blockers. -- Keep status updates short. -- When the operator steps away, keep `<orchDir>/attention.md` current (decision needed, recommendation, what is blocked), keep reading idle writers' last messages, and lead with that list on return. +- Surface every problem the moment you see it: limits, stalls, failures, new human review comments, blockers. Keep status updates short. +- When the operator steps away (vibe's `away-protocol` hook puts this checklist in context on "brb", "stepping away" and the like, and the return checklist on "I'm back"), keep `<orchDir>/attention.md` current (decision needed, recommendation, what is blocked), keep reading idle writers' last messages, and lead with that list on return. - Put every finding the operator reviews on one page using the stable `D`, `X`, `N`, `S` and `R` IDs in the [operator review template](./references/templates.md#operator-review-page); never restart numbering across audits. - The final report lists, per PRD, which flags must be on to see each change. -- Before a PRD is marked executed, `node ../handoff/scripts/executed-check.mjs --kind prd --file <PRD text> --last-merge <time> --children <its PRODUCES children read now>` must pass: its first section lists each flag its shipped changes rely on, where to see it in production and its state checked after the last merge, and every child is DONE or CANCELED; report open ones instead. +- Before a PRD is marked executed, `node ../handoff/scripts/executed-check.mjs --kind prd --file <PRD text> --last-merge <time> --children <its PRODUCES children read now>` must pass; report open children instead. diff --git a/plugins/vibe/skills/fleet-orchestrator/references/setup.md b/plugins/vibe/skills/fleet-orchestrator/references/setup.md index 7d558d53..f26bbe08 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/setup.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/setup.md @@ -7,7 +7,7 @@ ## config.env -`<orchDir>/config.env` holds every machine-specific value. codex-fleet-monitor reads the same file; its own keys are documented in that skill. Use absolute paths: a `~` would expand to this machine's home, not the other machine's. +`<orchDir>/config.env` holds every machine-specific value; codex-fleet-monitor reads the same file and documents its own keys. Use absolute paths, since `~` expands to this machine's home. ```sh WRITERS=<claude, codex or both> @@ -40,7 +40,7 @@ REMOTE_HEAVY_SLOTS=<concurrent heavy checks that machine can take> REMOTE_MAX_LOAD_PER_CORE=1.5 ``` -`fleet.sh` uses `LOCAL_STATE_DIR` (default `<orchDir>/sessions`), `CODEX_MODEL` and `CODEX_EFFORT` as `turn` defaults, the slot counts, and the `REMOTE_*` keys. Sub-agent models are not configured here: vibe's worker-model policy owns them. `MOVE_AT_PERCENT` is for you: compare it with each `codex-usage` line. +`fleet.sh` uses `LOCAL_STATE_DIR` (default `<orchDir>/sessions`), `CODEX_MODEL` and `CODEX_EFFORT` as `turn` defaults, the slot and load keys, and the `REMOTE_*` keys. Vibe's worker-model policy owns sub-agent models. `MOVE_AT_PERCENT` is yours to compare with each `codex-usage` line. ## First-run interview diff --git a/plugins/vibe/skills/fleet-orchestrator/references/state.md b/plugins/vibe/skills/fleet-orchestrator/references/state.md index 381fa003..0c8f4920 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/state.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/state.md @@ -2,7 +2,7 @@ ## Layout -One directory per program on each machine, in a path that survives a reboot (under your home, never `/tmp` or a session scratchpad). The orchestrating machine's dir is also the codex-fleet-monitor work dir. +One directory per program on each machine, in a path that survives a reboot. The orchestrating machine's dir is also the codex-fleet-monitor work dir. ``` <orchDir>/ @@ -43,18 +43,8 @@ resume log. Each entry records the ruling, date and source: a quoted operator message, an AskUserQuestion answer or a ticket, as `- Ruling YYYY-MM-DD (operator message|AskUserQuestion|ticket: <source>): <decision>`. Mirror it to every worker host after each edit and point every worker and QA-prep prompt at it. Per-ticket -rulings still go through handoff's `rulings-check.mjs`. - -Before an audit, reconcile operator chat and AskUserQuestion results plus guide -and QA records into this file and prove the reconciliation with handoff's -`rulings-check.mjs --program`; audit the acceptance criteria and these rulings together. - -## Operator attention and review - -While the operator is away, keep `attention.md` current with each decision -needed, the recommendation and what it blocks. `review.md` is the one page for -findings the operator reviews; use the stable IDs from the operator review -template and never reuse an ID. +rulings still go through handoff's `rulings-check.mjs`. The pre-audit +reconciliation is in SKILL.md, Quality gates and QA. ## Lessons ledger diff --git a/plugins/vibe/skills/fleet-orchestrator/references/templates.md b/plugins/vibe/skills/fleet-orchestrator/references/templates.md index f1375884..18ee53fd 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/templates.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/templates.md @@ -4,7 +4,7 @@ Fill every `<field>`; delete a line only when it does not apply. ## Visual request -A Codex session drafts this and files it only with `node scripts/request-visual.mjs <requestsDir> <draft> [--worktree <dir>]`, which names it `<session>-<yyyymmdd-hhmm>-<slug>.md`. The script refuses, naming both SHAs, unless `Pushed head` equals the PR's current head (read with `gh`) and the worktree's HEAD, and refuses when `gh` cannot answer or a field is unfilled. On a remote machine `fleet.sh` ships it to `<REMOTE_STATE_DIR>/.fleet-orchestrator/fleet-orchestrator/scripts/`. A Claude worker does the work and writes its result to `findings/` under the same name; you steer the result back to the session as text. +A Codex session drafts this and files it only with `node scripts/request-visual.mjs <requestsDir> <draft> [--worktree <dir>]`, which refuses an unpushed or stale head and an unfilled field (on a remote machine `fleet.sh` ships it to `<REMOTE_STATE_DIR>/.fleet-orchestrator/fleet-orchestrator/scripts/`). A Claude worker writes its result to `findings/` under the same name; you steer it back to the session as text. ``` # Visual request: <short title> @@ -21,7 +21,7 @@ Requirement: <ticket, PRD or acceptance criterion this proves> ## Worker preamble -Prepend to every Claude writer prompt; keep the `Takeover` line only when replacing a dead Codex writer, and the `Inherited scope` line whenever the writer continues another writer's branch. A Codex writer's prompt starts with `$vibe <ticket or PRD>` instead of the first line. +Prepend to every Claude writer prompt; keep `Takeover` only when replacing a dead Codex writer and `Inherited scope` whenever the writer continues another writer's branch. A Codex writer's prompt starts with `$vibe <ticket or PRD>` instead of the first line. ``` Invoke vibe:vibe first: resume the vibe session for <ticket or PRD>, or start one for it, and follow its rules for all of the work below. @@ -32,8 +32,8 @@ Before editing, read the repo docs that own the paths you touch (AGENTS.md files Research graph-first with closedloop-graph before searching files by hand, as vibe requires: ticket_detail for the ticket and its PRD, query_collisions and blast_radius_tickets for overlapping work, code_callers and code_importers before a shared change. Tests: after any change, fix or merge (a conflict resolution included), run exactly the tests code_tests_for maps to the files it touched, nothing broader by default; files that map to no tests (changelog, README, version text) run none and CI confirms. Heavy checks go through <fleet.sh slot command for the chosen machine>. Never run e2e that opens windows on the operator's daily machine. -Commands expected to take more than a few minutes run in the background. Check the worker inbox between steps so orchestration rulings are applied before more work. -Questions to the orchestrator end with `If no answer by <time, 30 minutes on>: <your default>` and are recorded with `board.mjs <orchDir> question <you> --text <question>`; keep working meanwhile and past it proceed on the default (never for a flag, copy, destructive, credential or scope question). A question never holds your PR: ship what is done, arm auto-merge, put the answer-dependent part in a follow-up. +Commands expected to take more than a few minutes run in the background (never a foreground wait over 5 minutes), and you end the turn while they run, so messages reach you. Check the worker inbox between steps so orchestration rulings are applied before more work. +Questions to the orchestrator end with `If no answer by <time, 30 minutes on>: <your default>` and an id, recorded with `board.mjs <orchDir> question <you> --id <id> --text <question>`; acknowledge each answer by its id (`board.mjs <orchDir> ack <you> --id <id>`) in your next status; keep working meanwhile and past it proceed on the default (never for a flag, copy, destructive, credential or scope question). A question never holds your PR: ship what is done, arm auto-merge, put the answer-dependent part in a follow-up. Git: commit messages per the repo's conventions; regular push only, never force or amend after push. Review threads: reply on the thread naming the fixing SHA and resolve it only after the fix is verified in code. Artifacts for people (tickets, PR bodies, PR comments, review replies): never name a path on the operator's machine (home, temp, scratchpad or worktree paths) or say something is on someone's laptop. Embed screenshots as uploaded images and quote text evidence inline, briefly. A plan is attached, never pointed at: link the implementation plan as a plan document, and upload a QA plan or other working file as a ticket attachment named by its attachment name. @@ -48,7 +48,7 @@ Task: ## Resume prompt -Starts a Codex writer on work another writer began, or a fresh thread after a move or reboot. Fill it from the resume log and the last writer's report; for a Claude writer use the worker preamble with its `Takeover` line instead. `fleet.sh` refuses a prompt with a `Done:` line and no `Re-verify:` line. +Starts a Codex writer on work another writer began, or a fresh thread after a move or reboot, filled from the resume log and the last writer's report; a Claude writer gets the worker preamble with its `Takeover` line instead. `fleet.sh` refuses a `Done:` line without `Re-verify:`. ``` $vibe resume the vibe session for <ticket or PRD>. @@ -68,7 +68,7 @@ Artifacts for people (tickets, PR bodies, PR comments, review replies): never na ## QA guide prompt -Primes a headless Claude session the operator resumes for one PR's human QA. Allow it to read files, run `gh pr view`, use the skill, and write only the plan and the findings file. +Primes the headless Claude session the operator resumes for one PR's human QA. Allow it to read files, run `gh pr view`, use the skill, and write only the plan and the findings file. ``` You are the QA guide for <owner/repo>#<number>. You never edit code. @@ -82,11 +82,11 @@ Record each result in the plan. Append each failure (checkpoint, what the operat ## Operator review page -Keep all findings the operator reviews on one page. Assign each a stable, -never-reused ID: `D###` decision needed, `X###` fixing, `N###` stands, -`S###` superseded or `R###` ruling. Preserve the ID when its status changes. -Run `node scripts/review-page-check.mjs <orchDir>/review.md` before showing it; -a `D###` row also says `checked in code: <file>:<line>`. +One page for every finding the operator reviews, each with a stable, +never-reused ID kept through status changes: `D###` decision needed, +`X###` fixing, `N###` stands, `S###` superseded or `R###` ruling. A `D###` +row says `checked in code: <file>:<line>`; run +`node scripts/review-page-check.mjs <orchDir>/review.md` before showing it. ``` <ID> | <status> | <PR or surface> | <finding and evidence; for a decision, checked in code: <file>:<line>> | <recommendation or resolution> @@ -94,7 +94,7 @@ a `D###` row also says `checked in code: <file>:<line>`. ## Flag approval -Send to the operator before any worker adds a feature flag key; nothing is gated until they approve that key. +Send before any worker adds a feature flag key; nothing is gated until the operator approves that key. ``` Flag approval: <key> diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs index c7b23082..a56626ca 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs @@ -9,7 +9,7 @@ // board.mjs <orchDir> deliver <item> --to <a,b> [--kind ruling|checkpoint|note] [--reference <file>] [--head <sha>] // board.mjs <orchDir> told <item> <recipient> // board.mjs <orchDir> duty <duty> --owner <writer or helper> -// board.mjs <orchDir> question <writer> --text <the question and its default> | answered <writer> +// board.mjs <orchDir> question|answered|ack <writer> --id <question id> [--text <the question and its default>] // board.mjs <orchDir> status [--pushed <name>=<sha>]... [--threads <name>=<n>]... // // `writer` adds or updates a writer's row (owner, PR, head, status, @@ -42,9 +42,14 @@ // <sha>`); `push-check` exits 1 unless the head you are about to push for it // is that one. The orchestrator once pushed a writer's committed head while the // writer was still fixing two regressions in it, and auto-merge took it. -// A writer records each question it sends you (`question`); `status` prints -// `QUESTION-UNANSWERED <writer> <m>m: <text>` once one waited 15 minutes, until -// `answered`. A writer once held its PR six hours on an unanswered question. +// A writer records each question it sends you under an id (`question`), your +// answer echoes that id (`answered`), and the writer acknowledges it in its next +// status (`ack`). `status` prints `QUESTION-UNANSWERED <writer> <id> <m>m: +// <text>` once a question waited 15 minutes, and `UNACKED <writer> <id> <m>m` +// once an answer waited 15 minutes unacknowledged: escalate, for example by +// asking the operator to press Esc in that pane. A writer once held its PR six +// hours on an unanswered question, and answers to writers in long turns had to +// be sent three times. // All of these count toward the exit status, as does `WATCHER-MISSING(<why>)` when the // work dir watches sessions or PRs (sessions.txt or prs.txt) and its watcher is // not running: a watcher left unarmed overnight let writers' questions sit @@ -91,11 +96,16 @@ export function unownedDuties(board, helpers) { }); } -/** Writers whose recorded question has waited QUESTION_MINUTES or more. */ -export function unansweredQuestions(board, now) { - return Object.entries(board.questions) - .map(([writer, { text, at }]) => ({ writer, text, minutes: Math.floor((now - Date.parse(at)) / 60_000) })) - .filter((entry) => entry.minutes >= QUESTION_MINUTES); +/** Questions unanswered, and answers unacknowledged, for QUESTION_MINUTES or more. */ +export function lateQuestions(board, now) { + const minutesSince = (at) => Math.floor((now - Date.parse(at)) / 60_000); + const entries = Object.entries(board.questions).map(([key, entry]) => ({ writer: entry.writer ?? key, id: entry.id ?? "-", ...entry })); + return { + unanswered: entries.filter((entry) => !entry.answeredAt && minutesSince(entry.at) >= QUESTION_MINUTES) + .map((entry) => ({ ...entry, minutes: minutesSince(entry.at) })), + unacked: entries.filter((entry) => entry.answeredAt && minutesSince(entry.answeredAt) >= QUESTION_MINUTES) + .map((entry) => ({ ...entry, minutes: minutesSince(entry.answeredAt) })), + }; } /** Required duties with no owner recorded. */ @@ -226,11 +236,11 @@ function main(argv) { pr: { type: "string" }, repo: { type: "string" }, head: { type: "string" }, status: { type: "string" }, deps: { type: "string" }, to: { type: "string" }, kind: { type: "string" }, reference: { type: "string" }, pushed: { type: "string", multiple: true }, threads: { type: "string", multiple: true }, now: { type: "string" }, - runtime: { type: "string" }, owner: { type: "string" }, text: { type: "string" }, ready: { type: "string" }, + runtime: { type: "string" }, owner: { type: "string" }, text: { type: "string" }, ready: { type: "string" }, id: { type: "string" }, }, }); - if (!orchDir || !["writer", "deliver", "told", "duty", "question", "answered", "push-check", "status"].includes(action)) { - throw new Error("Usage: board.mjs <orchDir> writer|deliver|told|duty|question|answered|push-check|status ..."); + if (!orchDir || !["writer", "deliver", "told", "duty", "question", "answered", "ack", "push-check", "status"].includes(action)) { + throw new Error("Usage: board.mjs <orchDir> writer|deliver|told|duty|question|answered|ack|push-check|status ..."); } const board = read(orchDir); const now = values.now ? Date.parse(values.now) : Date.now(); @@ -248,12 +258,14 @@ function main(argv) { for (const entry of duties) process.stdout.write(`UNOWNED-DUTY ${entry.duty} owner ${entry.owner} ${entry.why}\n`); const missing = missingDuties(board, requiredDuties(orchDir)); for (const duty of missing) process.stdout.write(`MISSING-DUTY ${duty}: give it an owner with board.mjs <orchDir> duty ${duty} --owner <writer or helper>\n`); - const asked = unansweredQuestions(board, now); - for (const entry of asked) process.stdout.write(`QUESTION-UNANSWERED ${entry.writer} ${entry.minutes}m: ${entry.text.slice(0, 160)}\n`); + const { unanswered, unacked } = lateQuestions(board, now); + for (const entry of unanswered) process.stdout.write(`QUESTION-UNANSWERED ${entry.writer} ${entry.id} ${entry.minutes}m: ${entry.text.slice(0, 160)}\n`); + for (const entry of unacked) process.stdout.write(`UNACKED ${entry.writer} ${entry.id} ${entry.minutes}m: the writer has not acknowledged your answer; it is likely in a long turn, so escalate\n`); + const asked = [...unanswered, ...unacked]; const watched = ["sessions.txt", "prs.txt"].some((file) => existsSync(path.join(orchDir, file))); const watcher = watched ? watcherState(orchDir, now) : { ok: true }; if (!watcher.ok) process.stdout.write(`WATCHER-MISSING ${watcher.line.replace("watcher MISSING", "")}: re-arm watch.sh now (run_in_background)\n`); - process.stdout.write(`board: ${Object.keys(board.writers).length} writers, ${stale.length} stale, ${open.length} untold, ${slow.length} slow, ${duties.length + missing.length} unowned duties, ${asked.length} unanswered${watcher.ok ? "" : ", watcher missing"}\n`); + process.stdout.write(`board: ${Object.keys(board.writers).length} writers, ${stale.length} stale, ${open.length} untold, ${slow.length} slow, ${duties.length + missing.length} unowned duties, ${unanswered.length} unanswered, ${unacked.length} unacked${watcher.ok ? "" : ", watcher missing"}\n`); return stale.length + open.length + slow.length + duties.length + missing.length + asked.length + (watcher.ok ? 0 : 1) === 0 ? 0 : 1; } const [name, recipient] = positionals; @@ -272,17 +284,22 @@ function main(argv) { process.stdout.write(`READY ${name} ${values.head}\n`); return 0; } - if (action === "question") { - if (!values.text?.trim()) { - throw new Error("question needs --text <the question and the default you proceed on>."); + if (["question", "answered", "ack"].includes(action)) { + if (!/^[\w.:-]{1,40}$/.test(values.id ?? "")) { + throw new Error(`${action} needs --id <question id>; the answer echoes it and the writer acknowledges it.`); } - board.questions[name] = { text: values.text.trim(), at }; - write(orchDir, board, `${name} asked: ${values.text.trim().slice(0, 80)}`, at); - return 0; - } - if (action === "answered") { - delete board.questions[name]; - write(orchDir, board, `${name} answered`, at); + const key = `${name}/${values.id}`; + if (action === "question") { + if (!values.text?.trim()) throw new Error("question needs --text <the question and the default you proceed on>."); + board.questions[key] = { writer: name, id: values.id, text: values.text.trim(), at }; + } else if (!board.questions[key]) { + throw new Error(`no question ${values.id} from ${name} is recorded.`); + } else if (action === "answered") { + board.questions[key].answeredAt = at; + } else { + delete board.questions[key]; + } + write(orchDir, board, `${name} ${action} ${values.id}`, at); return 0; } if (action === "duty") { diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs index 2cab7b40..355d4608 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs @@ -142,14 +142,29 @@ test('a work dir that watches sessions or PRs fails status while its watcher is test('a writer question waiting 15 minutes is QUESTION-UNANSWERED until answered (#9118)', (t) => { const dir = tempDir(t); - assert.equal(run([dir, 'question', 'w1', '--text', 'Sort by rank or updated? If no answer by 09:04Z: rank', '--now', '2026-10-22T08:34:00Z']).status, 0); + assert.equal(run([dir, 'question', 'w1', '--id', 'q1', '--text', 'Sort by rank or updated? If no answer by 09:04Z: rank', '--now', '2026-10-22T08:34:00Z']).status, 0); assert.equal(run([dir, 'status', '--now', '2026-10-22T08:48:00Z']).status, 0, 'fourteen minutes is not yet late'); const late = run([dir, 'status', '--now', '2026-10-22T08:49:00Z']); assert.equal(late.status, 1); - assert.match(late.stdout, /^QUESTION-UNANSWERED w1 15m: Sort by rank or updated\?/m); - assert.equal(run([dir, 'answered', 'w1', '--now', '2026-10-22T08:50:00Z']).status, 0); + assert.match(late.stdout, /^QUESTION-UNANSWERED w1 q1 15m: Sort by rank or updated\?/m); + assert.equal(run([dir, 'answered', 'w1', '--id', 'q1', '--now', '2026-10-22T08:50:00Z']).status, 0); + assert.equal(run([dir, 'ack', 'w1', '--id', 'q1', '--now', '2026-10-22T08:52:00Z']).status, 0); assert.equal(run([dir, 'status', '--now', '2026-10-22T13:00:00Z']).status, 0); - assert.equal(run([dir, 'question', 'w1']).status, 2); + assert.equal(run([dir, 'question', 'w1', '--text', 'x']).status, 2, 'a question carries an id'); +}); + +test('an answer the writer has not acknowledged in 15 minutes is UNACKED (answers sent three times)', (t) => { + const dir = tempDir(t); + assert.equal(run([dir, 'question', 'w2', '--id', 'url-cap', '--text', 'Cap URLs at 2000? Default: yes', '--now', '2026-10-22T10:00:00Z']).status, 0); + assert.equal(run([dir, 'answered', 'w2', '--id', 'url-cap', '--now', '2026-10-22T10:05:00Z']).status, 0); + assert.equal(run([dir, 'status', '--now', '2026-10-22T10:19:00Z']).status, 0, 'an answer has 15 minutes to be acknowledged'); + const unacked = run([dir, 'status', '--now', '2026-10-22T10:20:00Z']); + assert.equal(unacked.status, 1); + assert.match(unacked.stdout, /^UNACKED w2 url-cap 15m: the writer has not acknowledged your answer/m); + assert.doesNotMatch(unacked.stdout, /QUESTION-UNANSWERED/, 'an answered question is no longer unanswered'); + assert.equal(run([dir, 'ack', 'w2', '--id', 'other']).status, 2, 'an ack names a recorded question'); + assert.equal(run([dir, 'ack', 'w2', '--id', 'url-cap', '--now', '2026-10-22T10:21:00Z']).status, 0); + assert.equal(run([dir, 'status', '--now', '2026-10-22T11:00:00Z']).status, 0); }); test('a watched program needs a green-watch owner; REQUIRED_DUTIES names others', (t) => { diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.mjs index 0b5ce9a4..d83105bd 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.mjs @@ -5,7 +5,12 @@ // the runs for main from the code host, and every lane (job) of every run whose // commit contains a program PR's merge commit is reported. // -// Usage: green-watch.mjs --repo <owner/repo> --worktree <a clone with main fetched> --program <merge sha>[,<merge sha>...] [--branch main] +// Each lane is triaged as it finishes, not when its batch does: a long lane once +// left two finished reds untriaged. With `--state <file>`, a red lane seen for +// the first time prints `NEW-RED` instead of `RED`, so every finished red is +// an event while the rest of its batch still runs. +// +// Usage: green-watch.mjs --repo <owner/repo> --worktree <a clone with main fetched> --program <merge sha>[,<merge sha>...] [--branch main] [--state <file>] // // Prints `RED <workflow> #<run> <sha> <job>` for each failed lane, // `PENDING <workflow> #<run> <sha> <job>` for each unfinished one, `NO-RUN <sha>` @@ -14,13 +19,14 @@ // git cannot answer. `GREEN_WATCH_GH` overrides the gh binary. import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; const FAILED = new Set(["failure", "cancelled", "timed_out", "action_required", "startup_failure"]); /** The report lines for runs whose commit contains a program PR, read fresh each time. */ -export function greenReport({ runs, jobsOf, head, includesProgram }) { +export function greenReport({ runs, jobsOf, head, includesProgram, seen = new Set() }) { const watched = runs.filter((run) => includesProgram(run.headSha)); const lines = []; let red = 0; @@ -33,7 +39,8 @@ export function greenReport({ runs, jobsOf, head, includesProgram }) { lines.push(`PENDING ${where}`); } else if (FAILED.has(job.conclusion)) { red += 1; - lines.push(`RED ${where}`); + lines.push(`${seen.has(where) ? "RED" : "NEW-RED"} ${where}`); + seen.add(where); } } } @@ -56,7 +63,7 @@ function isAncestor(worktree, ancestor, sha) { } function main(argv) { - const { values } = parseArgs({ args: argv, options: { repo: { type: "string" }, worktree: { type: "string" }, program: { type: "string" }, branch: { type: "string" } } }); + const { values } = parseArgs({ args: argv, options: { repo: { type: "string" }, worktree: { type: "string" }, program: { type: "string" }, branch: { type: "string" }, state: { type: "string" } } }); const program = (values.program ?? "").split(",").map((sha) => sha.trim()).filter(Boolean); if (!values.repo || !values.worktree || program.length === 0) { throw new Error("Usage: green-watch.mjs --repo <owner/repo> --worktree <clone> --program <merge sha>[,...] [--branch main]"); @@ -64,12 +71,15 @@ function main(argv) { const branch = values.branch ?? "main"; const runs = gh(["run", "list", "-R", values.repo, "--branch", branch, "--limit", "100", "--json", "databaseId,headSha,status,conclusion,name,workflowName,event,createdAt"]); const head = gh(["api", `repos/${values.repo}/commits/${branch}`]).sha; + const seen = new Set(values.state && existsSync(values.state) ? JSON.parse(readFileSync(values.state, "utf8")) : []); const { lines, ok } = greenReport({ + seen, runs, head, jobsOf: (id) => gh(["run", "view", String(id), "-R", values.repo, "--json", "jobs"]).jobs ?? [], includesProgram: (sha) => program.some((merge) => isAncestor(values.worktree, merge, sha)), }); + if (values.state) writeFileSync(values.state, `${JSON.stringify([...seen])}\n`); process.stdout.write(`${lines.join("\n")}\n`); return ok ? 0 : 1; } diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.test.mjs index 0598dd54..f8aa72e8 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.test.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/green-watch.test.mjs @@ -18,7 +18,7 @@ test('every call reports every lane of every run containing a program PR, includ assert.deepEqual(first, { lines: ['green-watch: 1 runs, 0 red, 0 pending'], ok: true }); const later = [run(1, 'merge1'), run(2, 'head2', 'ci-push', '2026-10-22T11:00:00Z'), run(3, 'before')]; const second = greenReport({ runs: later, head: 'head2', jobsOf: (id) => jobs[id], includesProgram }); - assert.deepEqual(second.lines, ['RED ci-push #2 head2 a11y', 'green-watch: 2 runs, 1 red, 0 pending']); + assert.deepEqual(second.lines, ['NEW-RED ci-push #2 head2 a11y', 'green-watch: 2 runs, 1 red, 0 pending']); assert.equal(second.ok, false, 'a red lane in a later push-triggered batch is reported'); const noRun = greenReport({ runs: later, head: 'head3', jobsOf: (id) => jobs[id], includesProgram }); assert.match(noRun.lines.join('\n'), /^NO-RUN head3$/m); @@ -49,5 +49,15 @@ test('the command re-lists runs through gh on every call and reads ancestry from writeFileSync(path.join(dir, 'runs.json'), JSON.stringify([run(1, before), run(2, merge), run(3, head, 'ci-push')])); const late = call(); assert.equal(late.status, 1); - assert.match(late.stdout, /^RED ci-push #3 \S+ a11y$/m); + assert.match(late.stdout, /^NEW-RED ci-push #3 \S+ a11y$/m); +}); + +test('a finished red lane is an event while another lane of its batch still runs, once (green watcher sat on reds)', () => { + const seen = new Set(); + const jobs = [job('desktop-e2e', null, 'in_progress'), job('vitest-shard-2', 'failure'), job('lint', 'success')]; + const report = () => greenReport({ runs: [run(7, 'h7')], head: 'h7', jobsOf: () => jobs, includesProgram: () => true, seen }); + const first = report(); + assert.deepEqual(first.lines, ['PENDING ci-batch #7 h7 desktop-e2e', 'NEW-RED ci-batch #7 h7 vitest-shard-2', 'green-watch: 1 runs, 1 red, 1 pending']); + assert.equal(first.ok, false, 'the red is reported before the batch finishes'); + assert.deepEqual(report().lines.filter((line) => line.includes('RED')), ['RED ci-batch #7 h7 vitest-shard-2'], 'the same red is not a new event'); }); diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/mock-gap.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/mock-gap.mjs new file mode 100644 index 00000000..13759986 --- /dev/null +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/mock-gap.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +// Finds module mocks that leave out an export the code under test reads. Each +// `vi.mock("<module>", ...)` block is judged on its own: a scan that passed a +// whole file because some block spread `importOriginal()` once missed a hook +// mock in the same file that lacked a newly read field, and main broke. +// +// Usage: mock-gap.mjs --module <specifier> --exports <name>[,<name>...] <test file>... +// +// A block passes when it spreads the real module (`...(await importOriginal())`, +// or a spread of a variable assigned from importOriginal inside the block) or +// names every export. Prints `MOCK-GAP <file>:<line> <module> lacks <names>` per +// failing block and exits 1; exits 0 when every block passes. + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +const MOCK_CALL = /\bvi\.mock\(\s*(["'`])([^"'`]+)\1/g; + +/** The text of each `vi.mock` call for `specifier`, with its line, by paren matching. */ +export function mockBlocks(text, specifier) { + const blocks = []; + for (const match of text.matchAll(MOCK_CALL)) { + if (match[2] !== specifier) continue; + const open = text.indexOf("(", match.index); + let depth = 0; + let end = open; + for (; end < text.length; end += 1) { + if (text[end] === "(") depth += 1; + else if (text[end] === ")" && --depth === 0) break; + } + blocks.push({ line: text.slice(0, match.index).split("\n").length, body: text.slice(open, end + 1) }); + } + return blocks; +} + +/** Exports a mock block leaves out; none when it spreads the real module. */ +export function blockGaps(body, exports) { + const original = /(\w+)\s*=\s*(?:await\s+)?importOriginal\b/.exec(body)?.[1]; + if (/\.\.\.\s*\(?\s*(?:await\s+)?importOriginal\b/.test(body) || (original && new RegExp(`\\.\\.\\.\\s*${original}\\b`).test(body))) { + return []; + } + return exports.filter((name) => !new RegExp(`\\b${name}\\b`).test(body)); +} + +function main(argv) { + const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { module: { type: "string" }, exports: { type: "string" } } }); + const exports = (values.exports ?? "").split(",").map((name) => name.trim()).filter(Boolean); + if (!values.module || exports.length === 0 || positionals.length === 0) { + throw new Error("Usage: mock-gap.mjs --module <specifier> --exports <a,b> <test file>..."); + } + let gaps = 0; + for (const file of positionals) { + for (const block of mockBlocks(readFileSync(file, "utf8"), values.module)) { + const missing = blockGaps(block.body, exports); + if (missing.length > 0) { + gaps += 1; + process.stdout.write(`MOCK-GAP ${file}:${block.line} ${values.module} lacks ${missing.join(", ")}\n`); + } + } + } + return gaps === 0 ? 0 : 1; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + process.exit(main(process.argv.slice(2))); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(2); + } +} diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/mock-gap.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/mock-gap.test.mjs new file mode 100644 index 00000000..b86e7097 --- /dev/null +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/mock-gap.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { blockGaps, mockBlocks } from './mock-gap.mjs'; + +const SCRIPT = fileURLToPath(new URL('./mock-gap.mjs', import.meta.url)); +// A mixed file: one block spreads the real module, the use-users hook mock does not and lacks userKeys. +const MIXED = [ + "vi.mock('@app/api-client', async (importOriginal) => ({ ...(await importOriginal()), fetchUsers: vi.fn() }));", + "", + "vi.mock('@app/use-users', () => ({", + " useUsers: () => ({ users: [] }),", + "}));", + "", +].join('\n'); + +test("each mock block is judged on its own, so another block's importOriginal does not cover a gap", () => { + const blocks = mockBlocks(MIXED, '@app/use-users'); + assert.equal(blocks.length, 1); + assert.equal(blocks[0].line, 3); + assert.deepEqual(blockGaps(blocks[0].body, ['useUsers', 'userKeys']), ['userKeys']); + assert.deepEqual(blockGaps(mockBlocks(MIXED, '@app/api-client')[0].body, ['fetchUsers', 'other']), [], 'a spread of the real module covers every export'); + const named = "vi.mock('@app/use-users', async (importOriginal) => {\n const actual = await importOriginal();\n return { ...actual, useUsers: vi.fn() };\n});"; + assert.deepEqual(blockGaps(mockBlocks(named, '@app/use-users')[0].body, ['userKeys']), []); +}); + +test('the command reports the failing block with its line', (t) => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'mock-gap-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const file = path.join(dir, 'list.test.tsx'); + writeFileSync(file, MIXED); + const result = spawnSync(process.execPath, [SCRIPT, '--module', '@app/use-users', '--exports', 'useUsers,userKeys', file], { encoding: 'utf8' }); + assert.equal(result.status, 1); + assert.equal(result.stdout, `MOCK-GAP ${file}:3 @app/use-users lacks userKeys\n`); + writeFileSync(file, MIXED.replace('useUsers: () => ({ users: [] }),', 'useUsers: () => ({ users: [] }),\n userKeys: { all: [] },')); + assert.equal(spawnSync(process.execPath, [SCRIPT, '--module', '@app/use-users', '--exports', 'useUsers,userKeys', file], { encoding: 'utf8' }).status, 0); +}); diff --git a/plugins/vibe/skills/handoff/SKILL.md b/plugins/vibe/skills/handoff/SKILL.md index ebec24ea..7893cf2d 100644 --- a/plugins/vibe/skills/handoff/SKILL.md +++ b/plugins/vibe/skills/handoff/SKILL.md @@ -41,12 +41,8 @@ below says. Every word the person sees follows asked once" says, so nothing settled in the session is asked again. Updates mention only completed features and next work; planning, reviews and the checklist stay internal. Every worker brief says closedloop-graph is required -(`../vibe/references/closedloop-graph.md`): each worker that locates, -changes, or reviews code makes its required calls and ends its result with a -Graph block. A result without one, or with one that lists no calls, is -incomplete; dispatch the worker again saying so. A Graph block that says -`unreachable` keeps that step moving; dispatch `vibe-setup-worker` to restore -the connection before the next step. Every worker that edits the live ticket +(`../vibe/references/closedloop-graph.md`): a worker that locates, changes or +reviews code ends with a Graph block listing its calls; otherwise dispatch the worker again saying so. On `unreachable` the step goes on; dispatch `vibe-setup-worker` to restore the connection before the next one. Every worker that edits the live ticket follows `../vibe/references/ticket-template.md`. You and every worker leave plugins, plugin marketplaces and plugin caches alone, and never clone the plugin's repository to run other tooling (`../vibe/references/guardrails.md`, @@ -56,23 +52,11 @@ fix and never stops the handoff: it is recorded for engineering and the handoff goes on (`../vibe/references/quality-loop.md`, "Failures already on main"). Only a failure the session caused blocks. -Handoff authors no tests (`../vibe/references/guardrails.md`, "Tests"). It runs the existing affected tests, and when one -fails the SAME persistent `vibe-change-worker` fixes the session's own code, -never what the test asserts; it only repairs an existing test or fixture the -session's change broke ("Repair, don't author"). The tests the session still -needs go on the live ticket under Missing tests as engineering's scope of -work. Verification/review helpers are read-only. Preserve test integrity and -fix implementation defects rather than weakening a check. Never ask the -person about tests or any other engineering choice: those rules decide it, or -it goes to engineering. +Handoff authors no tests (`../vibe/references/guardrails.md`, "Tests"). It runs the existing affected tests; on a failure the SAME persistent `vibe-change-worker` fixes the session's own code, never what the test asserts, and repairs only an existing test or fixture the change broke ("Repair, don't author"). Tests still needed go on the live ticket under Missing tests. Verification/review helpers are read-only, and no check is weakened. Never ask the person about tests or any other engineering choice. The build loop is lean (`../vibe/references/quality-loop.md`, "Lean build -loop"), so handoff is where the session's reviews and heavy validation run: -the guardrails and adversarial reviews, browser E2E, visual QA, logical QA, -product-contract QA, the existing tests, Storybook footprint and the running app -checks. Handoff opens a pull request only when the person asks for one ("A -requested pull request" below), which is also the one exception to the -no-tests rule. +loop"), so the guardrails and adversarial reviews, browser E2E, visual QA, logical QA and product-contract QA run here. Handoff opens a pull request only when the person asks for one ("A +requested pull request" below), the one exception to the no-tests rule. Harness notes match the `vibe` skill: in Codex invoke as `$handoff` and spawn plugin agents from `../../agents/<name>.md` with the file's body as the @@ -118,9 +102,8 @@ which step 8 asks and routes itself. A behavior answer resumes the SAME `vibe-change-worker` in fix mode first, with the question and the person's answer verbatim. It checks the code against the -answer on every screen it touches and either reports the code already meets -it (with the evidence, no file changed) or builds it under its usual rules -(backend guidance may be read-only; this writer builds the backend too). If +answer on every screen it touches and reports it already met (with evidence) +or builds it under its usual rules. If it changed any file, re-run before the ticket is finished: step 3 (inventory and guardrails), step 4 when a component or story changed, step 5 (checks), step 6 on the result (lighter: both reviewers; backend: one @@ -134,11 +117,10 @@ earlier frozen targets. Handoff reruns use the whole table, not just this answer A wording answer goes straight to `vibe-ticket-worker`. -Every `vibe-ticket-worker` handoff dispatch carries all the answers so far, -each with the question, the person's exact words, and how it was handled: -`built` (with the change worker's one-line summary), `already met` (with its -evidence), or `wording`. Never send the ticket worker a behavior answer the -change worker has not handled; it refuses one with `NEEDS_CHANGE`. +Every `vibe-ticket-worker` handoff dispatch carries all the answers so far: +the question, the person's exact words, and `built` (with the change worker's +summary), `already met` (with evidence) or `wording`. A behavior answer the +change worker has not handled is refused with `NEEDS_CHANGE`. ## Workers @@ -162,13 +144,11 @@ change worker has not handled; it refuses one with `NEEDS_CHANGE`. ## A requested pull request Without the person's explicit request for a pull request, nothing in vibe or -handoff writes a test. The person asks in plain words, and plain-language -intent to take the work through review and merge themselves IS that request -(`../vibe/references/guardrails.md`, "Tests"): +handoff writes a test. The person's plain-language intent to take the work through review +and merge themselves IS that request (`../vibe/references/guardrails.md`, "Tests"): "raise a PR", "open a pull request", "I'll get the PR merged", "I'll get it merged", "I'll ship it myself", "I want to take it through review", or -choosing the pull request in step 0's question. Words like these take this -path wherever they come, during the session or at any step here, and are +choosing the pull request in step 0's question, whenever it comes; it is never answered with a branch to merge. When the person explicitly asks for a pull request to @@ -184,11 +164,8 @@ on those tests, then step 7's commit and its push and publication through `vibe-environment-worker` in redeploy mode. The pull request does not open until those tests pass, their independent review is clean and step 9's gate passes. -From the request on, every brief in this handoff (the writer, the reviewers, -the verify and environment workers, the ticket worker) carries the -`pull-request` phase. No brief may say both that the person takes the work to -a pull request and that no tests are authored: a requested pull request is the -one case where the SAME writer authors them. +From the request on, every brief in this handoff carries the `pull-request` +phase. No brief may say both that the person takes the work to a pull request and that no tests are authored. On this path the ticket stays with the person: step 8 skips the next-owner question and lookup and gives the ticket worker the session's operator as the @@ -295,12 +272,8 @@ The checklist stops immediately when `blocking.forbiddenPaths` is false false: each `diffRules` finding goes to the SAME writer, which fixes it or records the change log line it names. -Keep the remaining checklist internal: inventory and shared-owner checks, -Storybook coverage and footprint, existing tests and the missing tests list, -validation, integrated review and corrections, browser E2E and the -repository's QA passes, publication, running app checks -with control, next-owner lookup, ticket completion and assignment. Do not show -the person a technical task list, upfront summary or plan to approve. Preserve +Keep the remaining checklist internal; never show the person a technical +task list, upfront summary or plan to approve. Preserve the lighter versus backend validation and review weights below. ## 2. Summarize internally @@ -310,14 +283,11 @@ not committed yet. Dispatch `vibe-handoff-summarizer` with the worktree and the inventory path. The inventory's `localFixes` are managed local workarounds for repository -bugs, each with its owning ticket. Historical entries may be setup-authored; -new entries are authored only by the SAME persistent implementation writer. They -are not the person's work: the inventory already leaves them out of -`changedFiles` and its guardrail checks, and every worker brief from here on -(summarizer, guardrails reviewer, change and backend workers, decomposer, -verify worker, reviewers, environment worker, ticket worker) lists their paths -as out of scope, not to be described, reviewed, edited, or committed. Keep the -summary internal for the ticket; it is not a technical approval request. +bugs, each with its owning ticket; new entries come only from the SAME +persistent writer. They are not the person's work: the inventory leaves them +out of `changedFiles` and its checks, and every worker brief from here on lists +their paths as out of scope (never described, reviewed, edited or committed). +Keep the summary internal for the ticket. Route any correction the person volunteers as "Answers from the person" says: a correction that changes what the product does goes to the change worker now; the rest go to the ticket worker in step 8. @@ -809,7 +779,8 @@ worktree, the live ticket slug, the confirmed summary, the final verified published commit from step 7, the tests the `pull-request` turn wrote and step 5's check results, and the ticket's Production impact and Flag changes and gates fields. It opens the pull request on the already pushed branch, -blocked until the inventory's `blocking.prBody` and `blocking.prEvidence` pass +blocked until the inventory's `blocking.prBody` and `blocking.prEvidence` and +`../vibe/scripts/pre-pr-lanes.mjs check` pass (`NEEDS_CHANGE`: runs to the verify worker, the rest to the SAME writer), and returns its URL; it never enqueues or merges it. On `BLOCKED`, tell the person in one plain line that the pull request could not be opened and diff --git a/plugins/vibe/skills/vibe/SKILL.md b/plugins/vibe/skills/vibe/SKILL.md index 5f2b0034..c3ad3435 100644 --- a/plugins/vibe/skills/vibe/SKILL.md +++ b/plugins/vibe/skills/vibe/SKILL.md @@ -125,12 +125,11 @@ This skill only works in a `closedloop-ai/symphony-alpha` checkout. forwarding a request to the writer, and again when a worker turn stalls with no progress. It reads the account's rate limits from the Codex App Server through codex-fleet-monitor's shared reader. On `"status":"stop"` - (exit 3) a limit is reached and ordinary usage is no longer allowed, so - continuing would draw on paid credits or cannot run at all: spawn and forward - nothing, leave the writer's request queued, and tell the person in one plain - sentence that the Codex usage limit is reached and when it resets - (`resetsAt`), and that the session picks up from there then, or, when - `state` is `LIMIT-SPENDING-CREDITS`, now if they say to use paid credits. + (exit 3) a limit is reached: spawn and forward nothing, leave the writer's + request queued, and tell the person in one plain sentence that the Codex + usage limit is reached and when it resets (`resetsAt`), so the session picks + up then (or, on `LIMIT-SPENDING-CREDITS`, now if they say to use paid + credits). Pass `--allow-credits` only after the person explicitly said to use credits in this session; `LIMIT-STOPPED` (no credits left) stops even then. `unknown` is not a stop. @@ -435,10 +434,8 @@ Then follow "Resuming" below. opening instruction to proceed reports the version and continues, so it does not delay this line or the work. Use the work the person already described. Only when they have not supplied it, ask what they want to work on: a ClosedLoop ticket (ISS-, PRD-, or a pasted - URL) or a plain description. A vibe session builds the real thing, backend - included when a change needs it, so if they clearly want a mockup or an - exploration with made-up data (nothing it shows needs to be real), start - the prototype session below yourself. The person only types `$vibe` and + URL) or a plain description. If they clearly want a mockup or an + exploration with made-up data, start the prototype session below yourself. The person only types `$vibe` and `$handoff`; do not ask them to invoke `$prototype`. For a ticket, dispatch `vibe-requirements-worker`. For a description, dispatch the same worker with the description: it checks for an existing ticket @@ -451,10 +448,8 @@ Then follow "Resuming" below. scope units: the requirements brief classifies every child before the session ticket exists, and the ticket worker claims or reports them (`vibe-ticket-worker.md`, create step 7). - The route and FEATURE_MAP id it returns say where the relevant code lives - and are for workers; never present them as a screen the session starts on - (the app opens on its default page after sign-in), and never promise a - screen for a broad request such as "look for visual bugs". + Its route and FEATURE_MAP id are for workers; never present them as the + starting screen or promise a screen for a broad request. 2. Choose the app's starting data. Use what the person already said. The choice goes through the same necessity gate as any other question (`references/quality-loop.md`, "Communication and product questions"): it @@ -569,13 +564,12 @@ work by its summary. Never open or give the person a URL after `BLOCKED`: any preview address without its own deployment shows the stage production app. When it returns `DONE`: -- Open the `appUrl` it returned (verified against the branch's own - deployment) with `/sign-in` added (`<appUrl>/sign-in`) in a Codex in-app - Browser tab and make the browser visible; the app's root sends a - signed-out visitor to account creation, and the person already has an - account. After this first sign-in, use the plain `appUrl`; if a tab ever - lands on account creation instead, open `<appUrl>/sign-in`. The person signs in through Clerk as themselves (you never type - credentials). Seeded: they land in the example company as an admin. Blank: they create +- Open `<appUrl>/sign-in` (the `appUrl` it returned, verified against the + branch's own deployment) in a Codex in-app Browser tab and make the browser + visible, since the root sends a signed-out visitor to account creation. + Later, use the plain `appUrl`, or `<appUrl>/sign-in` if a tab lands on + account creation. The person signs in through Clerk as themselves (you + never type credentials). Seeded: they land in the example company as an admin. Blank: they create their own org. - Confirm the tab shows the app (with seeded data when seeded), not an error page or an empty shell, before saying it is ready. @@ -593,11 +587,9 @@ When it returns `DONE`: `desktop-tab` again for that URL. 3. Open its `url` exactly as given (never shortened, never shown to the person) in a second Codex in-app Browser tab, and confirm it shows the - Desktop app's navigation (with seeded data when seeded), not - `Connecting to Closedloop Desktop…`, an error, or a refused connection. - If `desktop-tab` reports `running` with no `url`, the session's worktree - predates the Desktop tab: Desktop is open as its own window, as before, - and there is no tab to open. + Desktop app's navigation, not `Connecting to Closedloop Desktop…`, an + error, or a refused connection. `running` with no `url` means the + worktree predates the tab: Desktop is its own window, with no tab to open. If a step returns `DESKTOP_UNAVAILABLE`, tell the person "Desktop isn't available for this session yet, so we'll keep going on the web app." and continue web-only. @@ -610,19 +602,15 @@ Setup only retries the launch after that verified correction, never patches code ### Bringing a tab back -Whenever the person asks to bring back or reopen the app or Desktop (they -closed the tab, or it stopped answering), open a new tab for the one they -named: +When the person asks to reopen the app or Desktop, open a new tab for it: - The web app: the plain `appUrl` (`<appUrl>/sign-in` if it lands on account creation). - Desktop: run `node scripts/vibe-sessions.mjs desktop-tab --worktree "<wt>"`. If it reports `running`, open its `url` exactly as given. Otherwise Desktop quit, was stopped, or never started: start it as in step 2 above, then open - the new `url`. On `DESKTOP_UNAVAILABLE`, use the same sentence as above. - With `running` and no `url` (a worktree that predates the tab), tell the - person Desktop is open in its own window on their Mac for this session. - Never open a Desktop URL from an earlier launch or one copied from a tab's - address bar; each launch has its own. + the new `url`. `DESKTOP_UNAVAILABLE` and `running` with no `url` are + handled as in step 3 above. Never open a Desktop URL from an earlier launch + or a tab's address bar; each launch has its own. ## 4. Set expectations @@ -1003,31 +991,27 @@ Whenever the person asks to throw a session away (at any point, for any `--confirm` it changes nothing). It refuses a `handed-off` session: tell the person it is already finished (a pull request was opened, or it went to design and engineering) and stop. -2. Tell them in plain words what will be lost: the session's summary, the - number of unsaved files, that their copy of the app on Vercel and its data - will be deleted, and that the ticket will be canceled. Ask them to confirm. +2. Tell them in plain words what will be lost (the session's summary, its + unsaved files, their Vercel copy of the app and its data, and the ticket, + which is canceled) and ask them to confirm. 3. On a clear yes, retain the validated remembered `<repo>` separately from the target `<wt>` and the parent-held preview's branch, live ticket and operator. Dispatch `vibe-setup-worker` from that stable `<repo>` with `sessionless: {kind: "startup"}`, mode `record`, `recordAction: "discard"`, `exclusiveRecordTurn: true`, graph/live reads only, and `discardTarget`: `{worktree: "<wt>", confirmed: true, branch, operatorId, operatorEmail}` - plus `liveTicket` only when present. The launcher independently checks the - target's private record and same Git repository and locks both contexts; - its definition, trace and stable record lock remain until completion. - The worker re-verifies target ownership and the person's confirmation, never - deletes its execution checkout, and discards only the target worktree. It - stops the session's local processes, requests the drop of the session's - app data (nothing removes it automatically), deletes the - worktree and the local and remote branch, and returns the script's receipt. + plus `liveTicket` only when present (`references/quality-loop.md`, + "Exactly three helper paths"). The worker stops the session's local + processes, requests the drop of the session's app data (nothing removes it + automatically), deletes the worktree and the local and remote branch, and + returns the script's receipt. 4. Only after that successful result has `discarded: true`, retain its `cancelEvidence` in the parent. If there was a live ticket, dispatch `vibe-ticket-worker` from the retained `<repo>` in mode `record`, action `cancel`, with `exclusiveRecordTurn: true` and - `sessionless: {kind: "discarded", evidence: <cancelEvidence>}`. Grant only - parent-discovered graph/live reads and the declared cancel writes. It verifies - the live ticket and exact operator before canceling. A failed/partial discard, - absent receipt, missing ownership or deleted execution root blocks cancellation. + `sessionless: {kind: "discarded", evidence: <cancelEvidence>}`, granting + only parent-discovered graph/live reads and the declared cancel writes. A + failed or partial discard blocks cancellation. 5. Tell them in one line that it is gone. ## References diff --git a/plugins/vibe/skills/vibe/references/guardrails.md b/plugins/vibe/skills/vibe/references/guardrails.md index 6828b4e3..84a4aa5c 100644 --- a/plugins/vibe/skills/vibe/references/guardrails.md +++ b/plugins/vibe/skills/vibe/references/guardrails.md @@ -1,10 +1,9 @@ # Guardrails -Vibe work is reviewed by design and engineering, whether the person takes it -to a pull request themselves or passes it on for them to open one. It must already follow the team's patterns so they extend it -rather than rewrite it. The repo's `AGENTS.md` files are the contract; read -the nearest one before editing a directory. This file adds the rules specific -to vibe sessions and points at the repo rules that matter most here. +Vibe work is reviewed by design and engineering, so it follows the team's +patterns and they extend it rather than rewrite it. The repo's `AGENTS.md` +files are the contract; read the nearest one before editing a directory. This +file adds the rules specific to vibe sessions. ## Plugins and tooling @@ -101,10 +100,6 @@ still reports it under `outsideAllowed`, and a request that needs it takes the forbidden-path route below (`Engineering needed (forbidden path)`), never an edit in the session. -Nothing agent-written goes into public docs. No worker edits -`apps/web/content/docs/` or the internal `docs/` folder, in any phase; -"Feature docs at handoff" below says what handoff records instead. - Backend, built by that same writer using canonical backend guidance, each under its owning `AGENTS.md`: @@ -149,19 +144,11 @@ The build loop stays lean (`quality-loop.md`, "Lean build loop"): accepts it or asks for polish (`quality-loop.md`, "Visual QA during the session"). -One exception under `scripts/`: the source-gate allowlist -(`scripts/lint/source-gate-allowlist.json`) is shrink-only, so when the -session's change removes the last allowlisted occurrence of a rule (or some -of them), delete that entry or lower its count. That is the only `scripts/` -edit a session may make; the handoff inventory checks mechanically that the -file only lost entries or counts, and reports anything else under `scripts/`. -It applies wherever the session's work is checked or pushed (a change -worker's self-check or fix mode, a redeploy, the first push, handoff): when -`pnpm check:source-gates` or the pre-push hook reports a stale entry (for -example "pins count 1, but only 0 remain") for a file the session changed, -delete the entry (0 remain) or lower its count to what remains, and run the -gate again. Never add an entry or raise a count; a new violation is fixed in -the code. +One exception under `scripts/`: the shrink-only source-gate allowlist +(`scripts/lint/source-gate-allowlist.json`). When a gate reports a stale entry +for a file the session changed ("pins count 1, but only 0 remain"), delete +the entry or lower its count, and rerun the gate. Never add an entry or raise +a count. The handoff inventory checks the file only shrank. When a request genuinely needs a change in a Never path beyond that one exception (a source gate under `scripts/**`, a workflow under `.github/**`, a @@ -186,9 +173,8 @@ keys it may add ("Feature flags"). If the person chose Labs, the Labs pattern is that flag. Tell them in one line that the new screen stays hidden on their environment until that flag is turned on for them. -The prototype-first rule in the root `AGENTS.md` (net-new screens start in -`apps/prototypes`) does not apply to vibe sessions. Build in Storybook and -app code directly. +Vibe sessions skip the root `AGENTS.md` prototype-first rule and build in +Storybook and app code directly. ## Where code goes @@ -220,9 +206,8 @@ team's design standard. When nothing in the catalog fits: -1. Keep the missing building block internal and add it to the reviewed local - plan. Do not narrate this technical dependency to the person; the only - message about it is the new-components notice in step 5. +1. Add the missing building block to the local plan; the person hears of it + only through the new-components notice in step 5. 2. Run the repo agent `design-system-steward` (`.claude/agents/design-system-steward.md`) with the need. Follow its answer, which is one of three: - Reuse: an existing component covers it. Use it and continue. @@ -232,12 +217,10 @@ When nothing in the catalog fits: domain-free goes in `packages/design-system/components/ui/`; domain UI goes in its feature slice under `packages/app/<feature>/components/`. - For Extend and Create, the spec must cover the whole component, not only - the case this screen needs: every variant and size, responsive behavior at - phone and desktop widths, accessibility (accessible name, keyboard - behavior, focus handling, ARIA state), and every state that applies - (default, hover, focus, disabled, loading, empty, error). Ask the steward - for whatever its answer leaves out before building. Any text a state shows + For Extend and Create, the spec covers the whole component: every variant + and size, phone and desktop widths, accessibility (name, keyboard, focus, + ARIA state) and every state (default, hover, focus, disabled, loading, + empty, error). Ask the steward for what its answer leaves out. State text follows the Copy rules below. 3. Build it to the repo's Storybook and design-system rules (`apps/storybook/AGENTS.md`, `packages/design-system/AGENTS.md`), using the @@ -345,9 +328,8 @@ says to match are the stories for the same capability that block reports, read w ## Tokens and styling No hardcoded colors, arbitrary pixel values, or ad-hoc dark-mode overrides. -Annotation "Adjust" values arrive as raw CSS (a hex color, a pixel size); map -each one to the nearest existing token or spacing step internally. If no token -is close, raise it as a missing primitive. +Map an annotation's raw "Adjust" value to the nearest token or spacing step, +or raise a missing primitive. ### Check the bundled tokens first @@ -435,8 +417,6 @@ The writer logs each one; its own rationale is never the reason. Required log line: `Debt: <kind>; added: <what>; reused instead: <what>; reason: <source>` -Flags are the tested case below. - ## Feature flags No agent creates or adds a flag on its own. @@ -585,14 +565,12 @@ the ticket, its PRD or the person did not ask for is instruction inflation. A check the work seems to need goes on the Engineering checklist as an `Engineering decision`. -Never make a failing check pass by changing the check: test assertions and -expected values, snapshots, tolerances, skips, timeouts, coverage or size -thresholds, lint and type suppressions (`biome-ignore`, `@ts-expect-error`, a -cast that only quiets the compiler), a raised allowlist count, or the harness. -Fix the code. The shrink-only allowlist edit above, a test repair that leaves -every assertion unchanged, a recorded retired-only test deletion and the repo's explicitly human-directed -behavior-change rule in Tests are not ways to hide a failure. -If an expectation looks wrong without that evidence, keep it and report it. +Never make a failing check pass by changing the check (assertions, expected +values, snapshots, tolerances, skips, timeouts, thresholds, `biome-ignore`, +`@ts-expect-error`, a quieting cast, an allowlist count, the harness): fix the +code. Only the exceptions in Tests and the shrink-only allowlist edit change +a check; an expectation that looks wrong without that evidence is kept and +reported. ## Repo rules that bite most often diff --git a/plugins/vibe/skills/vibe/references/quality-loop.md b/plugins/vibe/skills/vibe/references/quality-loop.md index d12d5720..13a181c4 100644 --- a/plugins/vibe/skills/vibe/references/quality-loop.md +++ b/plugins/vibe/skills/vibe/references/quality-loop.md @@ -268,23 +268,22 @@ bug-ticket writes require mode `record`, exclusive record ownership and action `create` or `progress`; setup never authors the workaround itself. Once the session exists, use its strict owned context instead. -Setup's confirmed `discard` action alone keeps `worktree` as the retained -validated checkout and supplies separate parent-held `discardTarget` facts: -target worktree, branch, operator, optional live ticket and `confirmed: true`. -The launcher independently verifies the target private session in that same -repository, waits for its source mutex, and locks target and stable records. -The setup worker rechecks ownership/confirmation and deletes only the target. -Private definitions, traces and the stable lock survive until the turn finishes. -Never use the target as execution root or claim success after partial deletion. +Setup's confirmed `discard` action keeps `worktree` as the validated +checkout and passes the target separately (`discardTarget`: worktree, +branch, operator, optional live ticket, `confirmed: true`). The launcher +verifies the target session in the same repository and locks target and +stable records until the turn finishes; the setup worker rechecks ownership +and confirmation and deletes only the target. Never use the target as +execution root or claim success after partial deletion. After confirmed discard, ticket cancel alone uses a stable existing checkout, mode `record`, action `cancel`, exclusive record ownership and `sessionless: {kind: "discarded", evidence: {discarded: true, branch, -liveTicket, operatorId, operatorEmail}}`. Retain those facts from the confirmed -discard result before its worktree is removed; never invent them or cancel -without live ticket/operator verification. This does not perform a discard or -authorize one. Neither path fabricates a session or registers a source writer. -Writers, prototypes and environment publication have no sessionless fallback. +liveTicket, operatorId, operatorEmail}}`, facts retained from the confirmed +discard result, never invented; it verifies the live ticket and operator +before canceling. Neither path fabricates a session or registers a source +writer; writers, prototypes and environment publication have no sessionless +fallback. Main-sync readiness (`main-sync-inputs`, once, after the Root-owned merge/fix commit) runs in the SAME registered actor's exclusive progress turn; its @@ -553,9 +552,8 @@ never wait") stay as they are. ## Acceptance criteria before ready Nothing is called ready until every acceptance criterion the change touches -has been checked against the code. For example, a criterion that holds on -one list view and was never built on a sibling view is not ready. Heavy QA -still waits for handoff. This section is the rule's one canonical statement; the skills and +has been checked against the code, on every view it names. Heavy QA still +waits for handoff. This section is the rule's one canonical statement; the skills and workers point at it. The list lives in the session decision table, not a new artifact: one @@ -578,8 +576,7 @@ names: each view (a list and a board are two), web and Desktop where their adapters differ, and the mechanism its words require (a URL, a saved view, a reload, a shared link, Back). A criterion is met only when every surface on that line has its own `met` check; a check on one surface never stands in for -another. For example, a list view that keeps its collapse state in the URL -does not meet that criterion for a board view that does not. +another. After each build or fix and its quick checks, before returning `DONE`, the writer checks every unsuperseded criterion the change touches, including @@ -634,11 +631,8 @@ never counts as met. ## Visual references A reference the person gives reaches the writer as an image it reads, and -every visual and UX property it shows becomes an acceptance criterion. -Without this, a session misses properties such as a drop shadow, a larger -text size or bold type: the writer builds from the orchestrator's text -description without reading the image, and reference research scoped to -behaviors never turns those properties into criteria. This +every visual and UX property it shows becomes an acceptance criterion (a +drop shadow, a larger text size, bold type). This section is the rule's one canonical statement; the skills and workers point at it. The build loop stays lean: the reference check below is the one exception to its no-automated-visual-QA rule, and it runs only when there is @@ -808,16 +802,14 @@ engineering note under the existing rules. The orchestrator never tells the person the result matches the reference, or that a visible property is done, when it has seen a gap, in a screenshot -or anywhere else. A gap it sees goes back to the SAME writer in the same -request; the ready message names it plainly as not done yet until the writer -has fixed it. +or anywhere else: the gap goes back to the SAME writer in the same request +and the ready message names it as not done yet. ## Visual QA during the session This section adds an opt-in visual QA the orchestrator offers during the -session, in Codex and Claude Code alike. It takes precedence over the lean -build loop's rule that leaves visual QA to handoff alone; the build loop above -is otherwise unchanged. This section +session, in Codex and Claude Code alike; it takes precedence over the lean build loop's rule that leaves +visual QA to handoff alone. This section is the rule's one canonical statement, and handoff's visual QA compares against the same basis. @@ -921,9 +913,9 @@ status write so the session's own e2e test passes). ## Failures already on main -This applies to handoff in Codex and Claude Code alike. A handoff check can fail because it is already red on main, not because of -anything the session did. `main-sync-validate` settles which, so neither the -orchestrator nor the writer judges it: +This applies to handoff in Codex and Claude Code alike. A handoff check can +fail because it is already red on main. `main-sync-validate` settles which, so neither the orchestrator nor the +writer judges it: - When a handoff check fails, the executor runs that same check on the main the session captured (the transaction's `mainSha`) in a disposable checkout diff --git a/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.mjs b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.mjs new file mode 100644 index 00000000..219198cc --- /dev/null +++ b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.mjs @@ -0,0 +1,85 @@ +#!/usr/bin/env node +// Before a pull request opens, every test lane the repository's lane report +// names for the diff has a passing run recorded at the current head (operator +// ruling, 2026-10-22). The repository's own rule said so and was skipped: +// main broke four times in one day in suites PR CI does not run. +// +// Usage: +// pre-pr-lanes.mjs record --worktree <path> --command "<the lane's command, as the report prints it>" --result pass|fail +// pre-pr-lanes.mjs check --worktree <path> --report <the lane report's output, e.g. `pnpm test:lanes`> [--skip <regex, default e2e>] +// +// The report's lane commands are its 4-space-indented lines that start with a +// package runner (pnpm, npm, npx, yarn, node); a trailing `(<detail>)` is not +// part of the command. A report that says nothing changed needs no lane. `check` +// prints `MISSING-LANE <command>` for each lane with no passing record at HEAD +// and exits 1; a report it cannot read exits 2. Records live in the session's +// git dir (`vibe-lane-results.json`). Changes nothing else. + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { git } from "./session-record.mjs"; + +const LANE_LINE = /^ {4}((?:pnpm|npm|npx|yarn|node)\s.*?)(?:\s{2,}\(.*\))?\s*$/; +const NOTHING = /nothing to cover|No paths changed/i; + +/** The lane commands a lane report names, or null when it names none and does not say nothing changed. */ +export function reportLanes(report, skip = /e2e/i) { + const all = [...new Set(report.split("\n").map((line) => LANE_LINE.exec(line)?.[1]).filter(Boolean))]; + if (all.length === 0) return NOTHING.test(report) ? [] : null; + return all.filter((lane) => !skip.test(lane)); +} + +/** Lanes with no passing record at `head`. */ +export function missingLanes(lanes, records, head) { + return lanes.filter((lane) => !records.some((record) => record.command === lane && record.head === head && record.result === "pass")); +} + +function recordsFile(worktree) { + return path.join(git(worktree, ["rev-parse", "--absolute-git-dir"]), "vibe-lane-results.json"); +} + +function readRecords(worktree) { + const file = recordsFile(worktree); + return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : []; +} + +function main(argv) { + const [command, ...rest] = argv; + const { values } = parseArgs({ args: rest, options: { + worktree: { type: "string" }, command: { type: "string" }, result: { type: "string" }, report: { type: "string" }, skip: { type: "string" }, + } }); + if (!values.worktree) throw new Error("--worktree is required"); + const head = git(values.worktree, ["rev-parse", "HEAD"]); + if (command === "record") { + if (!values.command?.trim() || !["pass", "fail"].includes(values.result)) throw new Error("record needs --command and --result pass|fail"); + const records = readRecords(values.worktree).filter((record) => !(record.command === values.command.trim() && record.head === head)); + records.push({ command: values.command.trim(), head, result: values.result, at: new Date().toISOString() }); + writeFileSync(recordsFile(values.worktree), `${JSON.stringify(records, null, 2)}\n`); + process.stdout.write(`recorded ${values.result} for ${values.command.trim()} at ${head}\n`); + return 0; + } + if (command === "check") { + if (!values.report) throw new Error("check needs --report <lane report output>"); + const lanes = reportLanes(readFileSync(values.report, "utf8"), new RegExp(values.skip ?? "e2e", "i")); + if (lanes === null) { + process.stderr.write("the lane report names no lane and does not say nothing changed; rerun the repository's lane report and save its output\n"); + return 2; + } + const missing = missingLanes(lanes, readRecords(values.worktree), head); + for (const lane of missing) process.stdout.write(`MISSING-LANE ${lane}: run it, then record it with pre-pr-lanes.mjs record --command "${lane}" --result pass\n`); + process.stdout.write(`pre-pr lanes: ${lanes.length} lanes, ${missing.length} missing at ${head.slice(0, 10)}\n`); + return missing.length === 0 ? 0 : 1; + } + throw new Error("Usage: pre-pr-lanes.mjs record|check ..."); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + process.exit(main(process.argv.slice(2))); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exit(2); + } +} diff --git a/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.test.mjs b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.test.mjs new file mode 100644 index 00000000..3faf931e --- /dev/null +++ b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { missingLanes, reportLanes } from "./pre-pr-lanes.mjs"; +import { git, makeHome } from "./test-fixtures.mjs"; +import { spawnSync } from "node:child_process"; + +const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "pre-pr-lanes.mjs"); +const REPORT = [ + "IN THE TURBO GRAPH: 1 packages, dependents included:", + " pnpm --filter @repo/app test", + "", + "OUTSIDE THE TURBO GRAPH: 2 lane(s).", + " pnpm test:desktop-renderer (3 changed path(s) under apps/desktop/src/renderer)", + " pnpm test:desktop-e2e (1 changed path(s) in e2e-test.yml's desktop_e2e filter)", + "", +].join("\n"); + +test("the lanes come from the report's commands; e2e lanes are skipped by default", () => { + assert.deepEqual(reportLanes(REPORT), ["pnpm --filter @repo/app test", "pnpm test:desktop-renderer"]); + assert.deepEqual(reportLanes("No paths changed against origin/main; there is nothing to cover.\n"), []); + assert.equal(reportLanes("something went wrong\n"), null, "an unreadable report is never read as no lanes"); +}); + +test("a lane needs a passing record at the current head", () => { + const lanes = ["pnpm test:desktop-renderer"]; + assert.deepEqual(missingLanes(lanes, [], "h1"), lanes); + assert.deepEqual(missingLanes(lanes, [{ command: lanes[0], head: "h0", result: "pass" }], "h1"), lanes, "a run at an older head"); + assert.deepEqual(missingLanes(lanes, [{ command: lanes[0], head: "h1", result: "fail" }], "h1"), lanes, "a failing run"); + assert.deepEqual(missingLanes(lanes, [{ command: lanes[0], head: "h1", result: "pass" }], "h1"), []); +}); + +test("the PR-open check refuses a diff naming desktop-renderer with no recorded run (pre-PR Vitest lanes ruling)", (t) => { + const { root, home, cleanup } = makeHome("vibe-pre-pr-lanes-"); + t.after(cleanup); + const worktree = path.join(root, "wt"); + git(root, ["init", "--quiet", "--initial-branch=main", worktree], home); + git(worktree, ["commit", "--quiet", "--allow-empty", "-m", "base"], home); + const report = path.join(root, "lanes.txt"); + writeFileSync(report, REPORT); + const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8", env: { ...process.env, HOME: home } }); + const refused = run("check", "--worktree", worktree, "--report", report); + assert.equal(refused.status, 1); + assert.match(refused.stdout, /^MISSING-LANE pnpm test:desktop-renderer:/m); + for (const lane of reportLanes(REPORT)) assert.equal(run("record", "--worktree", worktree, "--command", lane, "--result", "pass").status, 0); + assert.equal(run("check", "--worktree", worktree, "--report", report).status, 0); + git(worktree, ["commit", "--quiet", "--allow-empty", "-m", "more"], home); + assert.equal(run("check", "--worktree", worktree, "--report", report).status, 1, "a new head needs the lanes run again"); +}); diff --git a/plugins/vibe/tools/python/test_vibe_1077_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1077_lessons_contract.py new file mode 100644 index 00000000..99ef96ee --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_1077_lessons_contract.py @@ -0,0 +1,50 @@ +"""Contract coverage for the vibe 1.0.77 release: question ids and acknowledgements, and the foreground guard.""" + +import json +from pathlib import Path + +from vibe_contract_text import flat + + +PLUGIN = Path(__file__).resolve().parents[2] +FLEET = PLUGIN / "skills" / "fleet-orchestrator" + + +def _text(path: Path) -> str: + return flat(path.read_text()) + + +def test_answers_echo_question_ids_and_unacknowledged_answers_are_flagged() -> None: + fleet = _text(FLEET / "SKILL.md") + assert "answered <writer> --id <id>" in fleet and "`UNACKED`" in fleet + templates = _text(FLEET / "references" / "templates.md") + assert "question <you> --id <id>" in templates and "ack <you> --id <id>" in templates + + +def test_vibe_worktrees_refuse_long_foreground_waits() -> None: + hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text())["hooks"]["PreToolUse"] + commands = [hook["command"] for entry in hooks if entry.get("matcher") == "Bash" for hook in entry["hooks"]] + assert any("foreground-guard.mjs" in command for command in commands) + + +def test_every_pr_open_step_requires_the_lanes_ci_would_run() -> None: + for path in (PLUGIN / "agents" / "vibe-environment-worker.md", PLUGIN / "skills" / "handoff" / "SKILL.md", FLEET / "SKILL.md"): + assert "pre-pr-lanes.mjs check" in _text(path), path.name + + +def test_green_watch_triages_each_lane_and_mocks_per_block() -> None: + fleet = _text(FLEET / "SKILL.md") + assert "`NEW-RED`" in fleet and "mock-gap.mjs" in fleet + + +def test_the_watch_set_finds_every_open_pr_and_names_its_writer() -> None: + snapshot = (PLUGIN / "skills" / "codex-fleet-monitor" / "scripts" / "snapshot.sh").read_text() + assert "--author" in snapshot and "done_prs" in snapshot + assert "liveness signal" in _text(PLUGIN / "skills" / "codex-fleet-monitor" / "SKILL.md") + assert "<owner/repo> <n> <writer>" in _text(FLEET / "SKILL.md") + + +def test_away_and_back_phrases_bring_the_away_protocol() -> None: + hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text())["hooks"] + assert any("away-protocol.mjs" in hook["command"] for entry in hooks["UserPromptSubmit"] for hook in entry["hooks"]) + assert "`away-protocol` hook" in _text(FLEET / "SKILL.md")