diff --git a/CHANGELOG.md b/CHANGELOG.md index 6564291a..a21b7dab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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.81 + +The 2026-10-23 and 2026-10-24 fleet lessons now have hooks, launcher behavior and evidence gates instead of prose alone. + +#### Added +- The pull request evidence gate requires a passing run of every lane `pnpm test:lanes` names for the diff, by the command it prints. A hand-picked subset no longer passes (#9238). A mention-only lane needs a recorded reason. +- A diff that changes a check, CI script or workflow needs `ciCheckRepros`: a run in the CI job's own tier and checkout shape. The new `ci-job-shape.mjs` reports each job's tier, fetch depth, env names and `if` (#9230, fixed in #9237). +- The Bash safety hook refuses three more things: starting `watch.sh` other than as its own `run_in_background` call, `set -- $var` (zsh does not word-split it), and `e2e-slot.sh` without `--head `. The remote `e2e-slot.sh` accepts `--head` and refuses a HEAD that differs from it; a staged next version adds the image check. +- A SendMessage hook adds a TaskStop reminder when a message asks its recipient to pause or hold itself. It never refuses, and ordinary freeze instructions such as "push nothing more" or "stop the investigation and report" get nothing. +- `image-head.mjs` records, per compose project, the commit and worktree that built the e2e images. vibe's e2e lock refuses a `--no-build` run whose images came from another commit or worktree (ISS-13067, #9241), and a passing build records its images. +- A writer turn that hits its time cap reports `CAP-HIT` with its changed-file count, whether it was mid-merge, and a saved patch of its tracked changes. + +#### Changed +- A review thread marked `source: bot` is fixed only when it reproduces and stays contained; otherwise it is answered with evidence and resolved. A human's reasoned answer still stays open. +- A new writer request supersedes a FAILED turn, and a turn whose launcher process is gone is closed by the next claim, so a stopped or rejected turn no longer wedges the queue. The launcher caps an over-long result instead of rejecting it. +- A session with no environment pushes through redeploy mode without requesting one. Human QA prep names `pnpm vibe up` and is ready only once a page loads past the org picker. +- The verify worker also runs the real-browser story test for every story file with a play on a component whose layout, position or scrolling changed, because the jsdom sweep passes those plays vacuously (#9238). +- The watcher's second-copy refusal says how to stop a detached copy. Fleet and worker templates run every named lane instead of `code_tests_for` alone, hold an agent with TaskStop, and batch asks to a writer. +- Instruction text is about 2 words shorter than in 1.0.80. + ### vibe v1.0.80 Targeted validation of the final committed diff is now a required gate before a vibe pull request opens. diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index ade43f27..82eff09d 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.80", + "version": "1.0.81", "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 90057e72..6d5cb9d6 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.80", + "version": "1.0.81", "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/README.md b/plugins/vibe/README.md index ec6fc52d..826c976a 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -523,7 +523,7 @@ harness switch instead of another worker doing its work. ## Runtime Files -Version `1.0.80` contains 12 skills, 16 agents, and one hook manifest +Version `1.0.81` contains 12 skills, 16 agents, and one hook manifest (`hooks/hooks.json`). It has no standalone commands, root-level shell scripts, or production Python tools under `tools/python/`; that directory contains the skill-contract test modules and diff --git a/plugins/vibe/agents/vibe-change-worker.md b/plugins/vibe/agents/vibe-change-worker.md index 6a94a912..ea26afd1 100644 --- a/plugins/vibe/agents/vibe-change-worker.md +++ b/plugins/vibe/agents/vibe-change-worker.md @@ -283,6 +283,9 @@ collapse a loop or `each` table left with one arm; and record each test still named or placed after the old module for engineering (a rename on the missing tests list, or done in a `pull-request` turn). +A new prop on a shared component goes into every capability ledger or +parity test listing a sibling prop, not only the one you edited. + ## Checks, review and recording Before publication/main-sync, read `environment.md`, "Main-sync before diff --git a/plugins/vibe/agents/vibe-environment-worker.md b/plugins/vibe/agents/vibe-environment-worker.md index 87b5e160..2db4f3fb 100644 --- a/plugins/vibe/agents/vibe-environment-worker.md +++ b/plugins/vibe/agents/vibe-environment-worker.md @@ -263,7 +263,9 @@ every conflict/source problem. Do not run a raw merge/commit/push yourself. if it failed. If the log shows the session's preview schema was dropped and recreated (that script's recovery for a failed preview migration), the session's data is gone: say so in your result. -7. Request the environment (below) again with the session's same mode (a +7. A session with no environment (no `lastRequestId`) stops after the push + with a Progress line "Pushed ``". Otherwise request the + environment (below) again with the session's same mode (a ready environment of the same mode is kept; only the deployments of the new branch head are made ready), read its result (below), then record and update the ticket (below), adding a Progress line "Redeployed ``". @@ -429,7 +431,9 @@ or `--body-file`). `gh api graphql` (`pullRequest.reviewThreads`, with `isResolved`, the thread `id`, and each comment's `databaseId`, author login and `__typename`, `path`, `line` and body) and return them, marking - `awaitingReply` each thread a human other than the author spoke last on. + `awaitingReply` each thread a human other than the author spoke last on, + and `source: bot` each opened by a `Bot`, a self-described automated review or a + `git config --get-all vibe.reviewBots` login. Change nothing. - Update, with the pushed fixing commit and the writer's per-thread results: 1. Confirm the branch on `origin` is at that commit (step 1 above). diff --git a/plugins/vibe/agents/vibe-verify-worker.md b/plugins/vibe/agents/vibe-verify-worker.md index 67b11e08..42cdc821 100644 --- a/plugins/vibe/agents/vibe-verify-worker.md +++ b/plugins/vibe/agents/vibe-verify-worker.md @@ -68,8 +68,8 @@ repository's related-test command, never a wider suite. Commands come from `.vibe/path-gates.json` or the `checks` of its `.vibe/storybook-rules.json`; record flag pairs with `flag: "on"` or `flag: "off"`. -Never run `pnpm test`, `pnpm verify`, `pnpm test:affected`, -`pnpm test:lanes --exec`, a whole lane or package suite locally. Select over +Never run `pnpm test`, `pnpm verify`, `pnpm test:affected` or a blind +`pnpm test:lanes --exec`. Select over the whole branch diff. Record import and E2E-reference selections in `testSelection`, and commands, cases, flags, results and SHAs in `testRuns` of `/vibe-pr-evidence.json`. `blocking.prEvidence` names gaps; skipped CI @@ -80,7 +80,10 @@ In `pre-pr` mode, read the final committed diff, never the plan, and write for the repository-owned lane names and commands. Inventory exact commands, files and cases for every selected Vitest package, importing test, changed or importing Storybook story, referenced web/Desktop E2E spec and matching -post-merge-only lane. Run that set once. After a writer fixes an isolated +post-merge-only lane. Run that set once, each named lane by the command +`pnpm test:lanes` prints (`testRuns` entry with `lane`). A changed check, CI +script or workflow reruns in its job's shape from +`../skills/handoff/scripts/ci-job-shape.mjs` (`ciCheckRepros`). After a writer fixes an isolated failure, refresh the inventory and rerun only that failed case and any target the fix newly reaches, never the whole set. Record every result and SHA. Carry an earlier pass only when its covered-file hashes match final HEAD. @@ -95,7 +98,8 @@ For targeted E2E, record the repository's capability probe and choose: Do not run host-browser visual-regression specs against Linux baselines; list them as `not run locally, covered by CI`. 3. Tier C: otherwise use the operator's configured remote runner and its - tested-head lock. + tested-head lock with `--head `, building fresh after a + worktree switch (`image-head.mjs` refuses stale images). `git config --bool vibe.noNativeE2e` is the machine-local operator override; when true, skip Tier B and use C or report no tier. If no tier is available, @@ -111,7 +115,9 @@ Three more rules: `.github/pr-test-event-tiers.md` and cite the tier of the job that runs it. A `batch-only` job does not run on the pull request, and its `skipped` conclusion there means admission, not validation. -- For every new or changed story with a `play` function, inspect every +- For every new or changed story with a `play` function, and every story + file with a `play` on a component whose layout, position or scrolling the + diff changes, inspect every `storybook/test` interaction and report any call that is not awaited. Then execute the touched stories' plays in a real browser with the repository's Storybook test runner or Playwright against its containerized Storybook diff --git a/plugins/vibe/hooks/hooks.json b/plugins/vibe/hooks/hooks.json index c4e02673..174303fe 100644 --- a/plugins/vibe/hooks/hooks.json +++ b/plugins/vibe/hooks/hooks.json @@ -30,6 +30,15 @@ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/agent-guard.mjs\"" } ] + }, + { + "matcher": "SendMessage", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pause-guard.mjs\"" + } + ] } ], "UserPromptSubmit": [ diff --git a/plugins/vibe/hooks/pause-guard.mjs b/plugins/vibe/hooks/pause-guard.mjs new file mode 100644 index 00000000..4561baad --- /dev/null +++ b/plugins/vibe/hooks/pause-guard.mjs @@ -0,0 +1,43 @@ +#!/usr/bin/env node +// PreToolUse reminder for SendMessage: a hold is TaskStop, not a message. A vibe +// session was told HOLD twice (2026-10-23) and kept its writer running: a +// message reaches a busy agent only at its next tool round, and never reaches +// the subagents it started. Repo memory already said so; the orchestrator +// skipped it. A message that asks the recipient to pause or hold itself gets a +// TaskStop reminder in context; the message still goes. Ordinary freeze +// instructions ("push nothing more", "stop the investigation and report") are +// not pauses and get nothing. +// +// Usage (hook): reads the PreToolUse JSON on stdin; prints additional context or nothing. + +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +/** Each pattern is a request that the recipient pause or hold itself. */ +export const HOLD_PATTERNS = Object.freeze([ + /^\s*(?:HOLD|PAUSE)\b/, + /\b(?:pause|hold|freeze)\s+(?:all\s+)?(?:of\s+)?(?:your\s+)?(?:work|writers?|sessions?|everything|agents?|subagents?|turns?)\b/i, + /\bstop\s+(?:all\s+)?(?:of\s+)?(?:your\s+)?(?:writers?|sessions?|everything|agents?|subagents?|all\s+work)\b/i, + /\bstand\s+down\b/i, +]); + +export const TASKSTOP_REMINDER = "This message asks an agent to pause or hold. A busy agent reads it only at its next tool round, and its subagents never see it: stop it with TaskStop, then check that nothing it started still runs under its worktree."; + +/** The TaskStop reminder for a message asking its recipient to pause or hold itself, or null. */ +export function holdReminder(input) { + const message = input?.message; + if (typeof message !== "string" || message.includes("TaskStop")) return null; + return HOLD_PATTERNS.some((pattern) => pattern.test(message)) ? TASKSTOP_REMINDER : null; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { + const event = JSON.parse(readFileSync(0, "utf8") || "{}"); + const reminder = event?.tool_name === "SendMessage" ? holdReminder(event.tool_input) : null; + if (reminder) { + process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext: reminder } })}\n`); + } + } catch (error) { + process.stderr.write(`pause-guard: ${error instanceof Error ? error.message : String(error)}\n`); + } +} diff --git a/plugins/vibe/hooks/pause-guard.test.mjs b/plugins/vibe/hooks/pause-guard.test.mjs new file mode 100644 index 00000000..398c781e --- /dev/null +++ b/plugins/vibe/hooks/pause-guard.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { holdReminder, TASKSTOP_REMINDER } from "./pause-guard.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const HOOK = path.join(HERE, "pause-guard.mjs"); + +test("a message asking its recipient to pause or hold itself gets the TaskStop reminder (two ignored HOLDs, 2026-10-23)", () => { + for (const message of ["HOLD: main is being reverted", "PAUSE until the revert lands", "Please pause your writer now.", + "hold all work on #9230", "Stop your subagents and wait", "Stand down until I say go", "freeze everything"]) { + assert.equal(holdReminder({ to: "vibe-writer", message }), TASKSTOP_REMINDER, message); + } +}); + +test("ordinary freeze and stop instructions, and a hold already done with TaskStop, get nothing", () => { + for (const message of ["Push nothing more to the branch.", "Stop the investigation and report what you found.", + "Hold off on pushing until CI is green", "Holding steady on #9230, ready for review", "Stop at the first failure and report it", + "Fix the stop button label", "hold the PR open for review", "HOLD: stopped with TaskStop; wait for the revert"]) { + assert.equal(holdReminder({ to: "vibe-writer", message }), null, message); + } + assert.equal(holdReminder({ to: "lead", message: { type: "shutdown_response", request_id: "1", approve: true } }), null); +}); + +test("the SendMessage hook adds the reminder as context, never refuses, and is registered", () => { + const run = (message) => spawnSync(process.execPath, [HOOK], { input: JSON.stringify({ tool_name: "SendMessage", tool_input: { to: "w", message } }), encoding: "utf8" }); + const output = JSON.parse(run("HOLD everything").stdout).hookSpecificOutput; + assert.equal(output.hookEventName, "PreToolUse"); + assert.equal(output.additionalContext, TASKSTOP_REMINDER); + assert.equal(output.permissionDecision, undefined); + assert.equal(run("Push nothing more to the branch.").stdout, ""); + const hooks = JSON.parse(readFileSync(path.join(HERE, "hooks.json"), "utf8")).hooks.PreToolUse; + assert.ok(hooks.some((entry) => entry.matcher === "SendMessage" && entry.hooks.some((hook) => hook.command.includes("hooks/pause-guard.mjs")))); +}); diff --git a/plugins/vibe/hooks/shell-safety-guard.mjs b/plugins/vibe/hooks/shell-safety-guard.mjs index 82ead021..3a35cce5 100644 --- a/plugins/vibe/hooks/shell-safety-guard.mjs +++ b/plugins/vibe/hooks/shell-safety-guard.mjs @@ -2,12 +2,25 @@ // PreToolUse guard for Bash: workers may not invent credential routes to fetch // evidence or force-push any remote. Both mistakes can escape a written rule, // especially through an ssh command aimed at another machine. +// Three more refusals, each from a written rule that was skipped (2026-10-23/24): +// the fleet watcher started with `&` or inside a compound command detached and +// held its lock while its events never arrived; `set -- $var` in a merge watcher +// never fired, because zsh (this tool's shell on macOS) does not word-split an +// unquoted parameter; and e2e-slot.sh reruns tested an old commit when they +// started before the push landed, because the slot captured whatever HEAD was. import { readFileSync } from "node:fs"; +import path from "node:path"; import { pathToFileURL } from "node:url"; +import { commandSegments } from "./command-segments.mjs"; const TOKEN = /(?:\b(?:gh_token|github_token|[a-z][a-z0-9_]*token)\b|\$\{?[a-z][a-z0-9_]*token\}?|--password(?:-stdin)?\b|(?:^|\s)-p(?:\s|$)|["']?auth["']?\s*:)/i; const CREDENTIAL_ROUTE = /(?:\bDOCKER_CONFIG\b|\bdocker\s+login\b|["']?auths["']?\s*:)/i; +const SHELLS = new Set(["bash", "sh", "zsh"]); +const BACKGROUND_AMPERSAND = /&/; +const REDIRECT_AMPERSAND = /\d*>&\d*|&>>?/g; +const ZSH_SPLIT = /(?:^|[\s;&|(])set\s+--\s+\$\{?[A-Za-z_][A-Za-z0-9_]*\}?(?=[\s;&|)]|$)/m; +const SHA = /^[0-9a-f]{7,40}$/i; const FORCE_PUSH = /\bgit\b(?:(?![;&|\n]).)*?\bpush\b(?:(?![;&|\n]).)*(?:\s-f(?:\s|$)|--force(?:-with-lease)?\b|(?:^|\s)\+[^\s]+)/i; /** The credential-safety refusal for a shell command, or null. */ @@ -22,12 +35,46 @@ export function forcePushProblem(command) { return "force push refused: never rewrite any remote, including another machine or a scratch ref; create a fresh proof/- ref and push normally"; } +/** The refusal for a fleet watcher launch that is not its own background tool call, or null. */ +export function watcherLaunchProblem(input) { + const command = typeof input?.command === "string" ? input.command.replace(REDIRECT_AMPERSAND, " ") : ""; + const segments = commandSegments(command).filter(({ words }) => words.length > 0); + if (!segments.some(({ words }) => invokes(words, "watch.sh"))) return null; + if (input.run_in_background === true && segments.length === 1 && !BACKGROUND_AMPERSAND.test(command)) return null; + return "start watch.sh only as its own Bash call with run_in_background, never with & or inside a compound command: a detached copy never reports to you and holds the lock (stop one with kill-guard.mjs own , then kill )"; +} + +/** The refusal for `set -- $var`, which splits nothing in zsh, or null. */ +export function wordSplitProblem(command) { + if (!ZSH_SPLIT.test(command)) return null; + return "set -- $var does not word-split in zsh, so $1 holds the whole string: use read -r first second <<< \"$var\""; +} + +/** The refusal for an e2e-slot.sh run that does not name the commit it must test, or null. */ +export function e2eSlotProblem(command) { + for (const { words } of commandSegments(command)) { + const start = words.findIndex((word, index) => path.basename(word) === "e2e-slot.sh" && (index === 0 || SHELLS.has(path.basename(words[index - 1])))); + if (start < 0) continue; + const head = words.indexOf("--head", start); + if (head < 0 || !SHA.test(words[head + 1] ?? "")) { + return "e2e-slot.sh needs --head before the owner label, the pushed commit to test: without it a rerun started before the push tests the old HEAD"; + } + } + return null; +} + +function invokes(words, script) { + const name = path.basename(words[0]); + return name === script || (SHELLS.has(name) && path.basename(words[1] ?? "") === script); +} + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { const event = JSON.parse(readFileSync(0, "utf8") || "{}"); const command = event?.tool_input?.command; if (event?.tool_name === "Bash" && typeof command === "string") { - const problem = credentialProblem(command) ?? forcePushProblem(command); + const problem = credentialProblem(command) ?? forcePushProblem(command) ?? watcherLaunchProblem(event.tool_input) + ?? wordSplitProblem(command) ?? e2eSlotProblem(command); if (problem) { process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem, diff --git a/plugins/vibe/hooks/shell-safety-guard.test.mjs b/plugins/vibe/hooks/shell-safety-guard.test.mjs index e5dce0be..818b9d22 100644 --- a/plugins/vibe/hooks/shell-safety-guard.test.mjs +++ b/plugins/vibe/hooks/shell-safety-guard.test.mjs @@ -4,7 +4,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { credentialProblem, forcePushProblem } from "./shell-safety-guard.mjs"; +import { credentialProblem, e2eSlotProblem, forcePushProblem, watcherLaunchProblem, wordSplitProblem } from "./shell-safety-guard.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const GUARD = path.join(HERE, "shell-safety-guard.mjs"); @@ -46,3 +46,30 @@ test("the PreToolUse hook denies unsafe Bash and is registered", () => { assert.equal(run("gh run view 123 --log").stdout, ""); assert.ok(readFileSync(path.join(HERE, "hooks.json"), "utf8").includes("hooks/shell-safety-guard.mjs")); }); + +test("the fleet watcher starts only as its own background call (started with & twice, 2026-10-24)", () => { + const refused = [ + { command: "zsh plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh orch 120 &", run_in_background: false }, + { command: "cd orch && scripts/watch.sh orch", run_in_background: true }, + { command: "scripts/watch.sh orch; echo done", run_in_background: true }, + { command: "scripts/watch.sh orch" }, + ]; + for (const input of refused) assert.match(watcherLaunchProblem(input) ?? "", /run_in_background/, input.command); + for (const input of [{ command: "zsh scripts/watch.sh orch 120", run_in_background: true }, + { command: "scripts/watch.sh orch 2>&1", run_in_background: true }, { command: "cat scripts/watch.sh" }]) { + assert.equal(watcherLaunchProblem(input), null, input.command); + } +}); + +test("set -- $var is refused because zsh does not split it (the merge watcher never fired, 2026-10-23)", () => { + assert.match(wordSplitProblem("st=$(gh pr view 1 --json state,headRefOid -q '.state+\" \"+.headRefOid'); set -- $st; [ \"$1\" = MERGED ]") ?? "", /read -r/); + for (const command of ["read -r state sha <<< \"$st\"", "set -- \"$st\"", "set -- ${=st}", "set -e"]) assert.equal(wordSplitProblem(command), null, command); +}); + +test("e2e-slot.sh runs only with the pushed commit named (two reruns tested a stale commit, ISS-13043)", () => { + for (const command of ["~/vibe-remote/e2e-slot.sh ISS-1 pnpm e2e", "ssh host 'zsh -lic \"cd ~/wt && ~/vibe-remote/e2e-slot.sh ISS-1 e2e/run.sh\"'", + "zsh e2e-slot.sh --head HEAD ISS-1 run"]) { + assert.match(e2eSlotProblem(command) ?? "", /--head /, command); + } + for (const command of ["~/vibe-remote/e2e-slot.sh --head 0123abc ISS-1 pnpm e2e", "cat ~/vibe-remote/e2e-slot.sh"]) assert.equal(e2eSlotProblem(command), null, command); +}); diff --git a/plugins/vibe/skills/codex-fleet-monitor/SKILL.md b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md index 9892d88e..12fab866 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/SKILL.md +++ b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md @@ -69,7 +69,7 @@ The label is what appears in the output lines. scripts/watch.sh [intervalSeconds] ``` -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 ` must print `watcher ok`; re-arm on `MISSING`. `sessions.txt` and `prs.txt` edits take effect on the next re-arm; `scripts/snapshot.sh ` prints the state once. +Start it as its own `run_in_background` call, never with `&` (a second copy 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 ` must print `watcher ok`; re-arm on `MISSING`. `sessions.txt` and `prs.txt` edits take effect on the next re-arm; `scripts/snapshot.sh ` prints the state once. ## Reading the output diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh index b28a0d3d..0c30b5c1 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh @@ -22,7 +22,7 @@ lock=$W/watch.lock if ! mkdir $lock 2>/dev/null; then other=$(cat $lock/pid 2>/dev/null) if [ -n "$other" ] && [ "$other" -gt 1 ] && kill -0 $other 2>/dev/null; then - print -u2 "watch.sh: watcher pid $other already runs for $W; read its events when it exits, then start it again with your harness's background option (run_in_background), never with &" + print -u2 "watch.sh: watcher pid $other already runs for $W; read its events when it exits, then start it again with your harness's background option (run_in_background), never with &. A detached copy reports to nobody: stop it with 'node /hooks/kill-guard.mjs own $other' and 'kill $other' as two steps, then wait out its sleep tick before starting it again" exit 3 fi rm -rf $lock; mkdir $lock || exit 3 diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs b/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs index 083b95e2..ef947be5 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.test.mjs @@ -114,6 +114,7 @@ test('a second watch.sh on the same work dir is refused while the first runs, an ]); assert.equal(status, 3); assert.match(stderr, new RegExp(`watcher pid ${child.pid} already runs`)); + assert.match(stderr, new RegExp(`kill-guard\\.mjs own ${child.pid}' and 'kill ${child.pid}' as two steps`)); assert.match(stderr, /run_in_background\), never with &/); assert.equal(JSON.parse(readFileSync(join(dir, 'watcher.json'), 'utf8')).pid, child.pid, 'the refused copy leaves the live watcher record alone'); const exited = new Promise((resolve) => child.on('exit', resolve)); diff --git a/plugins/vibe/skills/fleet-orchestrator/SKILL.md b/plugins/vibe/skills/fleet-orchestrator/SKILL.md index 281ea247..4b434117 100644 --- a/plugins/vibe/skills/fleet-orchestrator/SKILL.md +++ b/plugins/vibe/skills/fleet-orchestrator/SKILL.md @@ -56,7 +56,7 @@ The closedloop-graph MCP server is required; decide from it, not from guesses. T - 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 --plan-body `. 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`). -- 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. +- After any change, fix or merge, run every lane the repo's lane selector names for the diff, never a hand-picked subset, then rerun only failures. - 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 --path `. @@ -71,7 +71,7 @@ The closedloop-graph MCP server is required; decide from it, not from guesses. T Run [codex-fleet-monitor](../codex-fleet-monitor/SKILL.md) (`vibe:codex-fleet-monitor`) on the same `` 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 ` 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. 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 ` 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 `&`. +- 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 ` must print `watcher ok`; on `MISSING` (`board.mjs status` prints `WATCHER-MISSING`), re-arm it, 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, echoing its id (`board.mjs answered --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. @@ -97,6 +97,8 @@ scripts/fleet.sh local|remote session last [--count ] ## Claude workers - Launch each Claude writer with the [worker preamble](./references/templates.md#worker-preamble) saved to `prompts/`. +- Hold an agent with TaskStop, never a message; check nothing it started still runs. +- Batch asks to a writer at milestones; side work goes to read-only workers. - 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 duty --owner `. 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) once you accept its result, recorded with `scripts/helpers.mjs start|accepted|stopped `; run `helpers.mjs status` with every monitor event (`STOP`, `ORPHAN`). A helper records each process it starts with `helpers.mjs pid ` (`--group` only for one it spawned detached), and `stopped` ends them. @@ -124,7 +126,7 @@ scripts/fleet.sh local|remote session last [--count ] - Run heavy checks (full suites, typecheck, repo-wide lint and other gates, component story runs) through `scripts/fleet.sh local|remote slot heavy `, 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 --head ` on the machine that runs it, never a second lock; results name the commit they ran. Each run gets its own compose project, so a teardown removes only its own stack; name a stack another tool started with `--foreign-compose `. Unit tests, typecheck, story runs and stand-in server tests run outside it. - `scripts/slot-lock.sh --status ` 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. +- Kill only processes you started, by recorded pid; vibe's `hooks/kill-guard.mjs` refuses the rest. 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 @@ -147,10 +149,8 @@ scripts/fleet.sh local|remote session last [--count ] 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. -- Review threads are findings to verify on their merits, never human rulings or product authority: scheduled review bots post under teammates' accounts. One review round per PR (operator ruling) holds only in the delivery phase; once the program's core work is done it is retired and every valid finding is fixed in its PR, every round. During it, threads raised after the round-one fixes are pushed never block the merge: a valid one becomes a criterion on the PR's one follow-up ticket (current sprint, assigned to the operator, `TODO`, titled `: follow-ups from # review`), and the thread gets its link or why it is invalid, then is resolved. `shepherd.mjs --round-one-fixed-at` and `review-followup.mjs` need `--single-review-ruling` naming the ruling in force. -- 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. +- Review threads are findings to verify on their merits, never human rulings or product authority: scheduled review bots post under teammates' accounts. Fix a bot finding only when it reproduces and stays contained; otherwise reply with evidence and resolve. One review round per PR (operator ruling) holds only in the delivery phase; once the program's core work is done it is retired and every valid finding is fixed in its PR, every round. During it, threads raised after the round-one fixes are pushed never block the merge: a valid one becomes a criterion on the PR's one follow-up ticket (current sprint, assigned to the operator, `TODO`, titled `: follow-ups from # review`), and the thread gets its link or why it is invalid, then is resolved. `shepherd.mjs --round-one-fixed-at` and `review-followup.mjs` need `--single-review-ruling` naming the ruling in force. +- Resolve shared version-file conflicts right before each enqueue, in merge order. - 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 ` before calling it live. - Track merge order for stacked or overlapping PRs and tell each writer which one merges first. diff --git a/plugins/vibe/skills/fleet-orchestrator/references/templates.md b/plugins/vibe/skills/fleet-orchestrator/references/templates.md index 9bb6f062..e174c3ea 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/templates.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/templates.md @@ -29,7 +29,7 @@ You are the only writer on #, worktree on , Takeover: a Codex vibe session wrote this branch and stopped (). Resume that same vibe session and, before any other step, move its writer to you with vibe's harness switch (`writer-state.mjs migrate` with `harnessSwitch`, vibe's quality loop, "A writer whose runtime cannot run"). Never start a second session on this branch. Continue from its last pushed head ; commit normally. Re-verify every "done" claim in its report, change log and thread replies with a test or a measurement before building on it. Inherited scope: trace every exclusion, deferral, "obsolete" or narrowed criterion you inherit from the previous writer (its plan, PLN, change log or report) to a human source: a ticket AC, a PRD line, an operator ruling, or a plan with a named human approver. Anything without one is built or escalated, never dropped; `rulings-check.mjs --plan` lists a linked PLN's uncited exclusions. Before editing, read the repo docs that own the paths you touch (AGENTS.md files or contributor guide) or the team knowledge base. -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 run none and CI confirms. +Tests: after any change, fix or merge (a conflict resolution included), run every lane the repo's lane selector names for the diff, never a hand-picked subset, then rerun only failures. Heavy checks go through ; never run windowed e2e on the operator's daily machine. 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 : ` and an id, recorded with `board.mjs question --id --text `; acknowledge each answer by its id (`board.mjs ack --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. @@ -59,7 +59,7 @@ Left: Inherited scope: trace every exclusion, deferral, "obsolete" or narrowed criterion the previous writer left (its plan, PLN, change log or report) to a human source: a ticket AC, a PRD line, an operator ruling, or a plan with a named human approver. Anything without one is built or escalated, never dropped; `rulings-check.mjs --plan` lists a linked PLN's uncited exclusions. Rulings in force: read /rulings.md; the ticket carries the applicable per-ticket rulings through handoff's rulings check. 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. +Tests: after any change, fix or merge (a conflict resolution included), run every lane the repo's lane selector names for the diff, never a hand-picked subset, then rerun only failures. Budget: effort ; no computer use (no browser, in-app browser, desktop app UI or screenshots). Visual requests: draft one from the fleet-orchestrator skill's visual request template and file it only with `node ` from your worktree; it refuses an unpushed or stale head. If asked about a plugin version update, keep the current version. diff --git a/plugins/vibe/skills/handoff/SKILL.md b/plugins/vibe/skills/handoff/SKILL.md index 7d53b1c3..4375d3e9 100644 --- a/plugins/vibe/skills/handoff/SKILL.md +++ b/plugins/vibe/skills/handoff/SKILL.md @@ -497,10 +497,9 @@ steps 3 to 6 run again on the result. Before handoff, the findings file must pas `fixed: ` or a follow-up `ticket: `. Their reports feed the Design Review evidence in step 8. Owned prototype sessions keep their canonical single design review instead. -Every new or changed Storybook `play` runs awaited in a real browser, and any -card or narrow-layout interaction on WebKit iPhone and Chromium Pixel, as the -verify worker's checks mode says and records. For a requested pull request -these are opening gates (step 9), not missing-test notes. +Storybook plays run in a real browser, and card or narrow-layout +interactions on WebKit iPhone and Chromium Pixel, as the verify worker's +checks mode says; a requested pull request cannot open without them (step 9). ## 7. Upload the last changes @@ -853,7 +852,9 @@ resumes and every fix goes through this path, never around it: (tests included, under that turn's rules), and returns, per thread, `fixed` with the files it changed, `partial` with what remains, or `reason`; a flag-gated fix names the test or missing-tests entry pinning flag-off - behavior to main. A + behavior to main. A `source: bot` thread is fixed only when it reproduces + and stays contained, otherwise `reason` with `evidence`; bots contradict + themselves across PRs. A product question a thread raises goes through "Answers from the person"; a fix that needs a path the writer may never edit is logged as `Engineering needed (forbidden path)` and answered with that reason. @@ -879,10 +880,8 @@ resumes and every fix goes through this path, never around it: request it refreshes the body from the session record so it matches the diff, with the same template and rules as opening one, then replies on every thread through the REST review-comment reply endpoint, a fixed one - with the fixing commit SHA. Only - `scripts/review-threads.mjs resolve` resolves, and only a `fixed` thread - the reviewer `verified`, after its check passes in the same run; - `partial`, `reason` and `not-verified` threads get a reply and stay open. + with the fixing commit SHA. Only `scripts/review-threads.mjs resolve` + resolves, after its check passes in the same run. 8. `touch --status handed-off` again, stop local Storybook or Desktop if either runs, and tell the person in a line or two what changed and that the pull request is updated, with its link. Never a branch to merge. diff --git a/plugins/vibe/skills/handoff/scripts/ci-job-shape.mjs b/plugins/vibe/skills/handoff/scripts/ci-job-shape.mjs new file mode 100644 index 00000000..afcd78b1 --- /dev/null +++ b/plugins/vibe/skills/handoff/scripts/ci-job-shape.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +// The shape of each CI job that runs a check: its event tier, checkout depth, +// the env names it binds and its `if`. #9230 added a gate inside a batch-only +// job that checks out at depth 1 with no base ref. It passed in a full local +// clone, where origin/main exists, and every post-merge batch went red until +// #9237. Reproduce a changed check in this shape before calling it done, and +// record the run in `ciCheckRepros` (pr-facts.mjs). +// +// Usage: ci-job-shape.mjs --worktree --needle [--needle ...] +// +// Prints one JSON object per job whose block names a needle: +// {"workflow","job","tier","fetchDepth","env","if","checkout"}, and `NO-JOB ` +// for a needle no job names (a needle reached only through another script needs that +// script as a needle too). The tier comes from `.github/pr-test-event-tiers.md`, else +// "unlisted". Exits 2 on bad usage. Changes nothing. + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +const JOB_KEY = /^ {2}([A-Za-z0-9_-]+):\s*$/; +const TOP_KEY = /^\S/; +const FETCH_DEPTH = /^\s*fetch-depth:\s*(\d+)/m; +const ENV_NAME = /^\s+([A-Z][A-Z0-9_]{2,}):/gm; +const JOB_IF = /^ {4}if:\s*(.+)$/m; + +/** Each job of a workflow's text as `{ job, block }`. */ +export function workflowJobs(text) { + const jobs = []; + let inJobs = false; + let current; + for (const line of text.split("\n")) { + if (/^jobs:\s*$/.test(line)) { inJobs = true; continue; } + if (!inJobs) continue; + if (TOP_KEY.test(line)) { inJobs = false; current = undefined; continue; } + const key = JOB_KEY.exec(line); + if (key) { current = { job: key[1], block: "" }; jobs.push(current); continue; } + if (current) current.block += `${line}\n`; + } + return jobs; +} + +/** The tier the repository's event-tier table gives `job`, or "unlisted". */ +export function tierOf(tiers, job) { + const row = new RegExp(`^\\|\\s*\`${job.replace(/[-]/g, "\\-")}\`\\s*\\|\\s*\`([^\`]+)\``, "m").exec(tiers); + return row ? row[1] : "unlisted"; +} + +/** The shape of every job that names one of `needles`, with the needles no job names. */ +export function jobShapes(workflows, tiers, needles) { + const shapes = []; + const found = new Set(); + for (const { workflow, text } of workflows) { + for (const { job, block } of workflowJobs(text)) { + const hits = needles.filter((needle) => block.includes(needle)); + if (hits.length === 0) continue; + for (const hit of hits) found.add(hit); + const fetchDepth = Number(FETCH_DEPTH.exec(block)?.[1] ?? 1); + const env = [...new Set([...block.matchAll(ENV_NAME)].map((match) => match[1]))].sort(); + shapes.push({ workflow, job, tier: tierOf(tiers, job), fetchDepth, env, if: JOB_IF.exec(block)?.[1]?.trim() ?? null, + checkout: `fetch-depth ${fetchDepth === 0 ? "0 (full history)" : `${fetchDepth}, so no origin/main or base ref unless a step fetches one`}` }); + } + } + return { shapes, missing: needles.filter((needle) => !found.has(needle)) }; +} + +function main(argv) { + const { values } = parseArgs({ args: argv, options: { worktree: { type: "string" }, needle: { type: "string", multiple: true } } }); + if (!values.worktree || !values.needle?.length) { + process.stderr.write("Usage: ci-job-shape.mjs --worktree --needle