diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9b1192..65766a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ 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.78 + +The fleet runs from one fleet home, and the open 2026-10-22 lessons each get a check where one fits. + +#### Added +- One fleet home (`FLEET_HOME`, default `~/fleet-orchestrator`) holds what every orchestrator chat reuses at its top (`config.env`, `standing-rulings.md`, the lessons ledger, `handover.md` and `current`, the active program) and each program's state under `programs//`. `fleet-home.mjs start` begins a program that reads nothing of an earlier one; `resume` lists what a new chat reads, the Claude writers to relaunch from their saved prompts and a watcher another chat still holds; `migrate` turns a flat work dir into a home. Every fleet and monitor script resolves the home through `work-dir.mjs`, with session, request and remote state dirs derived per program (`REMOTE_FLEET_HOME` on the other machine). A new chat once inherited only part of a program: its resume log and ledger lived outside the work dir and its Codex writers' state in other directories. +- `board.mjs status` fails on state written outside the home: program files at its top, a symlink leaving it, a per-program key in its `config.env`, a shared remote state dir, a session in `sessions.txt` that `fleet.sh` did not start, and Codex thread state outside `sessions/`. +- The watcher never adds or reports a PR another chat owns: one in `prs-ignore.txt` or in another program's `prs.txt` in the same fleet home, so it is never `STALE-PR` here. Discovery once added a PR another chat was shepherding. +- Every test result names the commit it ran: `pre-pr-lanes.mjs run --head ` runs a lane and records it under that SHA, `record` needs `--head`, and `e2e-lock.mjs run` needs `--head`; each refuses when the worktree's HEAD is another commit or a tracked file is modified (`tested-head.mjs`) and fails when HEAD moves during the run. A worker's container runs once tested an old commit for an hour after every checkout failed, and passes were reported for a pull request never tested. +- Handoff's diff rules reject a newly enabled control unless the change ledger cites the ticket acceptance criterion or ruling that names that control on that surface. The guardrails review opens each citation, and running-app and visual QA inventory rendered controls against the sourced requirements. +- A changed visual baseline requires a counted `Baseline inventory:` and one sourced `Baseline element:` entry per new element. `pr-preflight.mjs` discovers baseline files from the actual Git diff and rejects missing ledgers, while green watch and guardrails compare each declared element with the image diff; "plausibly intended" is not a source. +- `pr-preflight.mjs` runs before a pull request opens (the environment worker's pull-request mode): the title with the ` (#n)` suffix fits 72 characters, and a body line claiming a result is identical or matches carries its measured count. A green-watch fix failed the metadata gate on a 73-character title, and another merged on a "pixel-identical" baseline about 3,900 pixels off. +- `hooks/merge-guard.mjs`, a PreToolUse hook, refuses `gh pr merge` (a merge, an enqueue or armed auto-merge) on a pull request another account authored until the operator approves it (`MERGE_AUTHOR_OK=`). An orchestrator once enqueued a teammate's pull request on a worker's misattribution. + +#### Changed +- The single review round (operator ruling) holds only in a program's delivery phase: once core work is done it is retired and every valid finding is fixed in its pull request, every round. `shepherd.mjs --round-one-fixed-at`, `review-followup.mjs plan` and a `later` result in `review-threads.mjs` now need the single-review ruling in force (`--single-review-ruling`, `singleReviewRuling`). +- The green-watch fixer is a vibe writer, so its pull request passes vibe's gates, and it attributes reds from `gh pr view --json author,headRefName`. The guardrails reviewer blocks a changed literal whose old value remains in the repository and a test locator widened to a non-unique page-wide match; a green-watch fix left both. +- `pre-pr-lanes.mjs` requires every lane the lane report names, e2e lanes included, and reads lane lines that start with the step's variables; `--skip` needs `--skip-ruling`. A pull request merged with two Desktop E2E reds because the check skipped e2e lanes by default. +- fleet-orchestrator: review threads are findings to verify on their merits, never human rulings, since scheduled review bots post under teammates' accounts; the green watch is a Sonnet watcher that starts an Opus fixer only for a red that needs attribution or a fix, and files another author's red only after that PR's ticket, PRD and design rulings show the change was not asked for (a requested change is accepted and cited); once core work is done the full e2e suites run on main before the batches find the reds, and an e2e fix PR gets auto-merge only after its specs and the lane's latest red set pass on the CI-identical runner (operator rulings). +- Instruction text the new checks enforce is trimmed so the release adds no words: Markdown under the vibe plugin is about 7 words shorter than in 1.0.77. + +#### Fixed +- The v1.0.77 entry said Markdown was about 930 words shorter than in 1.0.75; the merged change is about 830 shorter. The count was taken before the release's last commits. `instruction-words.mjs` (fleet-orchestrator) now measures the net change between two refs and, with `--changelog`, fails when an entry states a number the measurement does not match; the lessons loop runs it at the final head. + ### 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. @@ -17,7 +41,7 @@ The last 2026-10-22 lessons and a trim that brings 1.0.76 and 1.0.77 together to - `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. +- 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 830 words shorter than in 1.0.75. ### vibe v1.0.76 diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index 95f97b44..481f27ce 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.77", + "version": "1.0.78", "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 e672e311..078468f9 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.77", + "version": "1.0.78", "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 dff1d252..83e4ac1f 100644 --- a/plugins/vibe/README.md +++ b/plugins/vibe/README.md @@ -523,8 +523,9 @@ harness switch instead of another worker doing its work. ## Runtime Files -Version `1.0.68` contains 12 skills and 16 agents. It has no standalone -commands, hooks, root-level shell scripts, or production Python tools under +Version `1.0.78` 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 `vibe_contract_text.py`, the text helpers they share. Runtime helpers and tests live alongside their owning skills, including vibe diff --git a/plugins/vibe/agents/vibe-change-worker.md b/plugins/vibe/agents/vibe-change-worker.md index f6c8a165..c569c1b7 100644 --- a/plugins/vibe/agents/vibe-change-worker.md +++ b/plugins/vibe/agents/vibe-change-worker.md @@ -167,12 +167,11 @@ Raising and recording it is neither instruction inflation nor manufactured ambiguity: those rules protect settled rulings, and an unexamined mechanism is not one. -Build the outcome only along data directions that are already allowed. -Every new user-visible element needs a source that names it: ticket or PRD -text naming that element, a prototype that shows it, or the person's or an -operator's ruling. A ticket's list of capabilities or action types names no -new control, menu item or screen. Absorbing an existing ticket's work is fine; -record each absorbed ticket on the live ticket and in the pull request body. +Build the outcome only along data directions that are already allowed. A newly enabled control needs a +ticket acceptance criterion or ruling naming that control on that surface; +"preserve X" in one mode does not add X in another. Record `Control: : +: : `; `diff-rules.mjs` blocks +the diff without it. A capability list names no control, menu item or screen. Matching a prototype or design never removes what main ships; `diff-rules.mjs` blocks a removed mount or shipped string without a `Removal:` line citing the human source that retires it. diff --git a/plugins/vibe/agents/vibe-environment-worker.md b/plugins/vibe/agents/vibe-environment-worker.md index 5fcb416b..4ac01147 100644 --- a/plugins/vibe/agents/vibe-environment-worker.md +++ b/plugins/vibe/agents/vibe-environment-worker.md @@ -374,7 +374,8 @@ changes and gates fields. invention: - Summary: the confirmed summary with the live ticket's slug and its "Keys to see this work", then every `overlap`, `assertionChanges`, - `contractChanges` and answered `asks` entry. + `contractChanges` and answered `asks` entry, plus the change log's + `Control:`, `Baseline inventory:` and `Baseline element:` ledger lines. - Breaking changes: from Production impact and every `refusals` entry; "none" only when Production impact supports it and `refusals` is empty. - UI Feature Flag: the heading alone, unless the change adds a net-new @@ -392,7 +393,8 @@ 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. First `node ../skills/vibe/scripts/pre-pr-lanes.mjs check --worktree "" --report ` + tools; `node ../skills/vibe/scripts/pr-preflight.mjs --title "" --body-file <file> --worktree "<wt>" --base <inventory.baseCommit>` + must pass. First `node ../skills/vibe/scripts/pre-pr-lanes.mjs check --worktree "<wt>" --report <the repo's lane report output>` 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 <branch> --title "<title>" --body-file <file>`, diff --git a/plugins/vibe/agents/vibe-guardrails-reviewer.md b/plugins/vibe/agents/vibe-guardrails-reviewer.md index 8413e004..b85f74d7 100644 --- a/plugins/vibe/agents/vibe-guardrails-reviewer.md +++ b/plugins/vibe/agents/vibe-guardrails-reviewer.md @@ -132,7 +132,9 @@ path merely because another rule describes how its content should look. file whose exports only forward to the new owner (a pass-through or wrapper), a re-export alias, an export with no remaining importer, a prop no caller passes, and a loop or `each` table over one remaining - arm are each blocking, cited at `file:line`. + arm are each blocking, cited at `file:line`. So is a changed literal or + contract value whose old value `rg` still finds anywhere in the repo, + and a test locator widened to a page-wide match that is not unique. 9. Tests and checks: an added or deleted test file (`*.test.*`, `*.spec.*`, `__tests__/`, `e2e/`, or a snapshot or fixture a test reads), a changed snapshot, or a changed test that is not a pure repair is blocking in the @@ -295,16 +297,16 @@ path merely because another rule describes how its content should look. change log or ticket claims. Match each to the person's words, a ticket or PRD statement, a human comment, or a shipped rule found through the graph. If its only source is an agent's own rationale, it is a blocking - `engineering decision` finding. A detailed explanation written by an - agent is still not a product source, and neither is the session's own - ticket, PLN, plan, table or change log. This covers every new user-reachable - capability (a write path, action or control), refusal rule (a - PRD display cap is not one), new visual element or state presentation (held - to the copy rule), - numeric limit, criterion worded more strongly than its source and removed - accessibility behavior; a shared-module edit lists its callers in the - change log. A spec or criterion edited to match the code is blocking - without a human source (`spec-change` diff rule). + `engineering decision` finding. Session artifacts are not a product source. + This covers every new + user-reachable capability (a write path, action or control), refusal rule + (a PRD display cap is not one), visual element, numeric limit, criterion + worded more strongly than its source and removed accessibility. Open every + `Control:` source: it must name that control on that surface; preservation + elsewhere is insufficient. Compare each changed visual baseline with its + counted `Baseline inventory:` and cited `Baseline element:` lines; omission + or "plausibly intended" is blocking. A spec change without a human source + is blocking (`spec-change`). 16. Tech debt and flag keys (`guardrails.md`, "No new tech debt" and "Feature flags"): each item of a kind that section lists with no `Debt:` reason is blocking: a mechanism checks 1, 7, 8 or 12 find, a diff --git a/plugins/vibe/agents/vibe-verify-worker.md b/plugins/vibe/agents/vibe-verify-worker.md index 46bf552f..a74a98a3 100644 --- a/plugins/vibe/agents/vibe-verify-worker.md +++ b/plugins/vibe/agents/vibe-verify-worker.md @@ -100,8 +100,8 @@ rules: head: the lock refuses a second entry from the same worktree (exit 76). The containerized stack is one per machine (one compose project name, fixed ports and shared Dagger cache volumes), so start it only through - `node ../skills/vibe/scripts/e2e-lock.mjs run <the e2e command>`, which - waits until no other session's stack is running. Its exit 75 means the + `node ../skills/vibe/scripts/e2e-lock.mjs run --head <sha> <the e2e command>`, which + waits until no other session's stack is running and runs only that clean commit. Its exit 75 means the stack stayed busy past the wait: record that limitation with the holder it prints, never a pass, and never start the stack around the lock. When Docker or Dagger is missing, record that exact limitation; never claim @@ -269,7 +269,8 @@ session touched: `pnpm control console --level pageerror` and `pnpm control network`. Add `pnpm control api GET <path>` for the record the screen shows, and `pnpm control ipc <namespace.method>` read calls for a Desktop screen, - where relevant. + where relevant. Inventory the rendered controls against the sourced + requirements and report every control with no source on that surface. 4. When a baseline exists (an earlier control capture of the same entry on the merge-base or current main, never a commit that is not an ancestor), return its artifact paths beside the new ones and say what changed. When the session recorded reference images diff --git a/plugins/vibe/hooks/command-segments.mjs b/plugins/vibe/hooks/command-segments.mjs new file mode 100644 index 00000000..f8ced071 --- /dev/null +++ b/plugins/vibe/hooks/command-segments.mjs @@ -0,0 +1,24 @@ +// Splits a Bash command line into its simple commands for the PreToolUse +// guards: each segment's leading NAME=value assignments and its words, with +// wrappers (sudo, env, exec and the like) removed so words[0] is the program. + +import path from "node:path"; + +const SEPARATOR = /\|\||&&|[;|&\n]|\$\(|`|\(|\)/; +const ENV_ASSIGN = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/; +const WRAPPERS = new Set(["sudo", "command", "exec", "nohup", "time", "builtin", "env"]); + +/** `{ env, words }` for each simple command in `command`. */ +export function commandSegments(command) { + return command.split(SEPARATOR).map((segment) => { + const words = segment.trim().split(/\s+/).filter((word) => word && !/[<>]/.test(word)).map((word) => word.replace(/^["']|["']$/g, "")); + const env = {}; + while (words.length > 0) { + const assignment = ENV_ASSIGN.exec(words[0]); + if (assignment) env[assignment[1]] = assignment[2]; + else if (!WRAPPERS.has(path.basename(words[0]))) break; + words.shift(); + } + return { env, words }; + }); +} diff --git a/plugins/vibe/hooks/hooks.json b/plugins/vibe/hooks/hooks.json index c2f8d71c..22673f44 100644 --- a/plugins/vibe/hooks/hooks.json +++ b/plugins/vibe/hooks/hooks.json @@ -11,6 +11,10 @@ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/foreground-guard.mjs\"" + }, + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/merge-guard.mjs\"" } ] } diff --git a/plugins/vibe/hooks/kill-guard.mjs b/plugins/vibe/hooks/kill-guard.mjs index d10e3446..152c12c9 100644 --- a/plugins/vibe/hooks/kill-guard.mjs +++ b/plugins/vibe/hooks/kill-guard.mjs @@ -18,13 +18,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { commandSegments } from "./command-segments.mjs"; -const SEPARATOR = /\|\||&&|[;|&\n]|\$\(|`|\(|\)/; const SIGNAL_OPTION = /^-(?:[A-Za-z][A-Za-z0-9+-]*|\d+)$/; const SUBSTITUTED_KILL = /(?:^|[\s;&|(])kill\b[^;&|\n]*(?:\$\(|`)/; const BY_NAME = /^p?kill(?:all)?$/; -const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/; -const WRAPPERS = new Set(["sudo", "command", "exec", "nohup", "time", "builtin", "env"]); /** The file recording the pids each session called its own. */ export function ledgerFile(home = os.homedir()) { @@ -36,9 +34,7 @@ export function killProblems(command, isOwn) { const problems = SUBSTITUTED_KILL.test(command) ? ["kill with a command substitution signals whatever a search matched; kill only a pid you recorded as your own"] : []; - for (const segment of command.split(SEPARATOR)) { - const words = segment.trim().split(/\s+/).filter((word) => word && !/[<>]/.test(word)).map((word) => word.replace(/^["']|["']$/g, "")); - while (words.length > 0 && (ENV_ASSIGN.test(words[0]) || WRAPPERS.has(path.basename(words[0])))) words.shift(); + for (const { words } of commandSegments(command)) { const name = path.basename(words[0] ?? ""); if (name !== "kill" && BY_NAME.test(name)) { problems.push(`${name} signals by pattern, which can end other sessions' processes; record your own pid when you start a process and run kill <pid>`); diff --git a/plugins/vibe/hooks/merge-guard.mjs b/plugins/vibe/hooks/merge-guard.mjs new file mode 100644 index 00000000..5fcdd4b2 --- /dev/null +++ b/plugins/vibe/hooks/merge-guard.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// PreToolUse guard for Bash: `gh pr merge` (a merge, an enqueue or armed +// auto-merge) runs only on a pull request the signed-in account authored. An +// orchestrator once enqueued a teammate's pull request under the operator's +// account because a worker misattributed it. +// +// Another author's pull request is merged only after the operator approves +// it: rerun as `MERGE_AUTHOR_OK=<author login> gh pr merge ...`. When gh +// cannot answer, the guard stays silent and says why on stderr. +// +// Usage (hook): reads the PreToolUse JSON on stdin; prints a deny decision or nothing. +// MERGE_GUARD_GH overrides the gh binary. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { commandSegments } from "./command-segments.mjs"; + +const VALUE_FLAGS = new Set(["-R", "--repo", "-b", "--body", "-F", "--body-file", "-t", "--subject", "--match-head-commit", "-A", "--author-email"]); + +/** Each `gh pr merge` in `command`: `{ target, repo, allowed }` (target null for the current branch's PR). */ +export function mergeCalls(command) { + const calls = []; + for (const { env, words } of commandSegments(command)) { + if (path.basename(words[0] ?? "") !== "gh" || words[1] !== "pr" || words[2] !== "merge") continue; + let target = null; + let repo = null; + for (let index = 3; index < words.length; index += 1) { + const [flag, inline] = words[index].split(/=(.*)/s); + if (VALUE_FLAGS.has(flag)) { + let value = inline; + if (value === undefined) { + index += 1; + value = words[index]; + } + if (flag === "-R" || flag === "--repo") repo = value; + } else if (!words[index].startsWith("-") && target === null) { + target = words[index]; + } + } + calls.push({ target, repo, allowed: env.MERGE_AUTHOR_OK ?? null }); + } + return calls; +} + +/** The deny reason for `command`, or null; `gh(args)` returns gh's stdout. */ +export function mergeProblem(command, gh) { + const calls = mergeCalls(command); + if (calls.length === 0) return null; + const me = gh(["api", "user", "--jq", ".login"]).trim(); + for (const call of calls) { + const args = ["pr", "view", ...(call.target ? [call.target] : []), ...(call.repo ? ["-R", call.repo] : []), "--json", "author,headRefName"]; + const { author, headRefName } = JSON.parse(gh(args)); + if (author?.login && author.login !== me && call.allowed !== author.login) { + return `pull request ${call.target ?? "for this branch"} is ${author.login}'s (branch ${headRefName}), not ${me}'s: never merge, enqueue or arm auto-merge on another author's pull request without asking the operator; once they approve, rerun as MERGE_AUTHOR_OK=${author.login} gh pr merge ...`; + } + } + return null; +} + +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" && mergeCalls(command).length > 0) { + const cwd = typeof event.cwd === "string" ? event.cwd : process.cwd(); + const gh = (args) => execFileSync(process.env.MERGE_GUARD_GH || "gh", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 }); + const problem = mergeProblem(command, gh); + if (problem) { + process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: problem } })}\n`); + } + } + } catch (error) { + process.stderr.write(`merge-guard: ${error instanceof Error ? error.message : String(error)}\n`); + } +} diff --git a/plugins/vibe/hooks/merge-guard.test.mjs b/plugins/vibe/hooks/merge-guard.test.mjs new file mode 100644 index 00000000..5114e193 --- /dev/null +++ b/plugins/vibe/hooks/merge-guard.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, 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 { mergeCalls, mergeProblem } from "./merge-guard.mjs"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const GUARD = path.join(HERE, "merge-guard.mjs"); + +/** A gh stand-in: the signed-in login, and each PR number's author and branch. */ +const fakeGh = (me, prs) => (args) => { + if (args[0] === "api") return `${me}\n`; + const pr = prs[args[2]?.startsWith("-") ? "current" : args[2]]; + return JSON.stringify({ author: { login: pr.author }, headRefName: pr.branch }); +}; + +test("merging, enqueueing or arming auto-merge on another author's pull request is refused (#9169 was enqueued for its author)", () => { + const gh = fakeGh("operator", { 9169: { author: "teammate", branch: "fix/their-work" }, 9160: { author: "operator", branch: "vibe/bulk" }, current: { author: "operator", branch: "vibe/x" } }); + assert.match(mergeProblem("gh pr merge 9169 --squash --auto -R acme/app", gh), /^pull request 9169 is teammate's \(branch fix\/their-work\), not operator's/); + assert.match(mergeProblem("cd wt && gh pr merge --auto 9169", gh), /teammate's/); + assert.equal(mergeProblem("gh pr merge 9160 --squash --auto", gh), null, "our own pull request"); + assert.equal(mergeProblem("gh pr merge --squash --auto", gh), null, "the current branch's own pull request"); + assert.equal(mergeProblem("MERGE_AUTHOR_OK=teammate gh pr merge 9169 --squash --auto", gh), null, "the operator approved it"); + assert.match(mergeProblem("MERGE_AUTHOR_OK=someone-else gh pr merge 9169 --auto", gh), /teammate's/); + assert.equal(mergeProblem("gh pr view 9169 && git merge main", gh), null); +}); + +test("the repo and the target are read past value flags", () => { + assert.deepEqual(mergeCalls("gh pr merge -t 'title' --repo=acme/app 12 --auto"), [{ target: "12", repo: "acme/app", allowed: null }]); + assert.deepEqual(mergeCalls("gh pr merge --body-file b.md -R acme/app https://github.com/acme/app/pull/12"), [{ target: "https://github.com/acme/app/pull/12", repo: "acme/app", allowed: null }]); +}); + +test("the hook denies through the PreToolUse contract and stays silent when gh cannot answer", (t) => { + const dir = mkdtempSync(path.join(tmpdir(), "merge-guard-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const gh = path.join(dir, "gh"); + writeFileSync(gh, '#!/usr/bin/env bash\nif [ "$1" = api ]; then echo operator; else echo \'{"author":{"login":"teammate"},"headRefName":"fix/x"}\'; fi\n', { mode: 0o755 }); + const run = (command, bin = gh) => spawnSync(process.execPath, [GUARD], { input: JSON.stringify({ tool_name: "Bash", cwd: dir, tool_input: { command } }), encoding: "utf8", env: { ...process.env, MERGE_GUARD_GH: bin } }); + assert.equal(JSON.parse(run("gh pr merge 9169 --auto").stdout).hookSpecificOutput.permissionDecision, "deny"); + assert.equal(run("git status").stdout, ""); + const broken = run("gh pr merge 9169 --auto", path.join(dir, "missing-gh")); + assert.equal(broken.stdout, ""); + assert.match(broken.stderr, /^merge-guard: /); + assert.ok(readFileSync(path.join(HERE, "hooks.json"), "utf8").includes("hooks/merge-guard.mjs"), "the hook is registered"); +}); diff --git a/plugins/vibe/skills/codex-fleet-monitor/SKILL.md b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md index fee3daaf..9892d88e 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/SKILL.md +++ b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md @@ -20,7 +20,7 @@ A Codex turn that hits a usage limit stays open and stops producing items, so "i ## Set up a work dir -Pick a scratch dir (one per orchestration) and create: +Use fleet-orchestrator's fleet home (its active program; `scripts/work-dir.mjs` resolves it and derives the state dirs) or a dir of your own per orchestration, and create: `config.env` @@ -28,6 +28,7 @@ Pick a scratch dir (one per orchestration) and create: LOCAL_STATE_DIR=/path/to/local/state/files # only for two-column sessions REMOTE_HOST=user@host # omit when there are no remote sessions REMOTE_STATE_DIR=/path/on/remote # remote scratch dir; also holds state files for two-column sessions +# REMOTE_FLEET_HOME=/path/on/remote # in a fleet home, replaces REMOTE_STATE_DIR and REMOTE_REQUESTS_DIR REMOTE_SHELL="zsh -lic" # login shell so node/codex are on PATH (default) STALL_SECONDS=720 # no new thread items for this long means STALLED (default 12m) # CLAUDE_USAGE=0 # turn off the claude-usage line; CLAUDE_USAGE_SECONDS=600 sets how often it is read @@ -60,7 +61,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`: 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. +`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, except one in `prs-ignore.txt` or another program's `prs.txt` (another chat owns it), 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 diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/claude-usage.mjs b/plugins/vibe/skills/codex-fleet-monitor/scripts/claude-usage.mjs index ff6c7949..d892d1f7 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/claude-usage.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/claude-usage.mjs @@ -13,6 +13,7 @@ import { execFileSync } from 'node:child_process'; import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { isEntryPoint } from './liveness.mjs'; +import { resolveWorkDir } from './work-dir.mjs'; import { highUsagePercent, usageBand } from './ratelimit.mjs'; const LINE = /^\s*Current (session|week \(all models\)):\s*(\d+(?:\.\d+)?)%\s*used(?:\s*·\s*resets\s+(.+?))?\s*$/gim; @@ -51,7 +52,7 @@ function main() { process.stderr.write('Usage: claude-usage.mjs <workDir> [label]\n'); process.exit(2); } - const file = join(workDir, 'claude-usage.txt'); + const file = join(resolveWorkDir(workDir).dir, 'claude-usage.txt'); const interval = Number(process.env.CLAUDE_USAGE_SECONDS) > 0 ? Number(process.env.CLAUDE_USAGE_SECONDS) : DEFAULT_INTERVAL_SECONDS; let line = cachedLine(file, interval); if (!line) { diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh index 1cda8969..0e623ff2 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh @@ -2,7 +2,8 @@ # 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. prs.txt lines are `<owner/repo> <number> [<writer>]`. -K=${0:A:h}; W=${1:?workDir}; [ -f $W/config.env ] && source $W/config.env +# <workDir> is a fleet home (its active program), a program dir or a flat work dir; work-dir.mjs resolves it. +K=${0:A:h}; env_lines=$(node $K/work-dir.mjs sh ${1:?workDir}) || exit 2; eval "$env_lines" : ${STALL_SECONDS:=720} ${BOT_LOGIN_REGEX:=vercel|codex|github-actions|copilot|bot} ${REMOTE_SHELL:=zsh -lic} : > $W/live.txt HIGH=${USAGE_HIGH_PERCENT:-${MOVE_AT_PERCENT:-}} @@ -55,12 +56,16 @@ zsh $K/classify.sh $W/item-history.txt $STALL_SECONDS ${QUESTION_SECONDS:-900} < # 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. +# A PR another chat owns is never added or reported: one listed in prs-ignore.txt, or in another program's prs.txt +# in the same fleet home. Discovery once put a PR another chat was shepherding in this fleet's watch set. touch $W/prs.txt +others=$({ cat $W/prs-ignore.txt; [ -n "$FLEET_HOME" ] && for p in $FLEET_HOME/programs/*/prs.txt(N); do [ $p != $W/prs.txt ] && cat $p; done } 2>/dev/null | awk '{print $1" "$2}') 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 + [[ $'\n'$others$'\n' == *$'\n'"$r $n"$'\n'* ]] && continue grep -Eq "^$r $n( |\$)" $W/prs.txt || echo "$r $n" >> $W/prs.txt done done @@ -81,6 +86,7 @@ THREADS='query($o:String!,$n:String!,$p:Int!){repository(owner:$o,name:$n){pullR done_prs=() while read r n owner; do [ -z "$n" ] && continue + [[ $'\n'$others$'\n' == *$'\n'"$r $n"$'\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 # GitHub reports UNKNOWN while it recomputes; keep the last known value so it does not flap. 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 6116ca8f..c33dca24 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs @@ -176,3 +176,30 @@ test('the watch set adds open PRs on any branch prefix and drops a merged PR aft assert.match(watched, /^acme\/app 9116 writer-9098$/m); assert.doesNotMatch(f.run('ok'), /#42 /, 'and then it leaves the watch set'); }); + +test('a PR another chat owns is never added or reported: prs-ignore.txt, or another program in the same fleet home (#9058)', TIMEOUT, () => { + const f = fixture(); + const ago = new Date(Date.now() - 100 * 60_000).toISOString().replace(/\.\d{3}Z$/, 'Z'); + writeFileSync(join(f.work, 'prs.txt'), 'acme/app 9058 their-writer\n'); + writeFileSync(join(f.root, 'view-9058'), 'OPEN abcdef12 REVIEW_REQUIRED CONFLICTING\n'); + writeFileSync(join(f.root, 'push-9058'), `false ${ago}\n`); + writeFileSync(join(f.root, 'mine'), '9058\n9149\n'); + writeFileSync(join(f.work, 'prs-ignore.txt'), 'acme/app 9058\n'); + const out = f.run('ok'); + assert.doesNotMatch(out, /#9058 /, 'an ignored PR is never reported, so never STALE-PR, even when listed by hand'); + const watched = readFileSync(join(f.work, 'prs.txt'), 'utf8'); + assert.doesNotMatch(watched, /^acme\/app 9058$/m); + assert.match(watched, /^acme\/app 9149$/m); + const home = join(f.root, 'home'); + mkdirSync(join(home, 'programs', 'other'), { recursive: true }); + mkdirSync(join(home, 'programs', 'mine')); + writeFileSync(join(home, 'current'), 'mine\n'); + writeFileSync(join(home, 'programs', 'mine', 'config.env'), 'WATCH_NEW_PR_REPOS="acme/app"\n'); + writeFileSync(join(home, 'programs', 'other', 'prs.txt'), 'acme/app 9149 their-writer\n'); + writeFileSync(join(f.root, 'gh-mode'), 'ok'); + const result = spawnSync('zsh', [SNAPSHOT, home], { env: { ...process.env, FAKE_ROOT: f.root, PATH: `${join(f.root, 'bin')}:${process.env.PATH}`, FLEET_SUPERVISOR_LOCK: join(f.root, 'supervisor.lock') }, encoding: 'utf8', timeout: 50_000 }); + assert.equal(result.status, 0, result.stderr); + const mine = readFileSync(join(home, 'programs', 'mine', 'prs.txt'), 'utf8'); + assert.doesNotMatch(mine, /^acme\/app 9149$/m, 'the other program owns it'); + assert.match(mine, /^acme\/app 9058$/m); +}); diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.mjs b/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.mjs index ce6e5417..d907a892 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.mjs @@ -13,6 +13,7 @@ import { supervisorLockPath, timedPass, writeHeartbeat, } from './recover.mjs'; import { isEntryPoint } from './liveness.mjs'; +import { resolveWorkDir } from './work-dir.mjs'; export const DEFAULT_INTERVAL_SECONDS = 60; export const SUMMARY_INTERVAL_MS = 3_600_000; @@ -64,8 +65,8 @@ async function tick(workDir, stateDir, config) { } async function main() { - const [workDir, intervalArg] = process.argv.slice(2); - if (!workDir) { + const [workDirArg, intervalArg] = process.argv.slice(2); + if (!workDirArg) { console.error('Usage: supervise.mjs <workDir> [intervalSeconds]'); process.exit(2); } @@ -83,6 +84,7 @@ async function main() { console.error(`supervise: ${error.message}`); process.exit(2); } + const workDir = resolveWorkDir(workDirArg).dir; const stateDir = process.env.LOCAL_STATE_DIR || workDir; const logPath = process.env.SUPERVISOR_LOG || `${workDir}/supervisor.log`; const log = (line) => { diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.sh index a2974d47..399f19fa 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/supervise.sh @@ -1,9 +1,10 @@ #!/bin/sh -# Usage: supervise.sh <workDir> [intervalSeconds]. Loads <workDir>/config.env, then runs supervise.mjs, +# Usage: supervise.sh <workDir> [intervalSeconds]. Resolves <workDir> and loads its config.env (work-dir.mjs), then runs supervise.mjs, # which loops until stopped. Start it from launchd or systemd (templates/), or by hand in a terminal. K=$(cd "$(dirname "$0")" && pwd) -W=${1:?workDir} +env_lines=$(node "$K/work-dir.mjs" sh "${1:?workDir}") || exit 2 set -a -if [ -f "$W/config.env" ]; then . "$W/config.env"; fi +eval "$env_lines" set +a -exec node "$K/supervise.mjs" "$@" +shift +exec node "$K/supervise.mjs" "$W" "$@" diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh index 02672ef7..33e4db3e 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/watch.sh @@ -2,9 +2,10 @@ # Usage: watch.sh <workDir> [intervalSeconds]. Exits with the diff on the first change, or with HEARTBEAT # after HEARTBEAT_SECONDS (config.env or environment, default 1800) with none, so an armed watcher wakes its # orchestrator at least that often to read teammate messages: writers' questions once sat unread for 7 hours. -K=${0:A:h}; W=${1:?workDir}; I=${2:-120} -H=${HEARTBEAT_SECONDS:-$([ -f $W/config.env ] && sed -n 's/^HEARTBEAT_SECONDS=\([0-9][0-9]*\).*/\1/p' $W/config.env | tail -1)} -H=${H:-1800}; T=$(( H / I )); (( T < 1 )) && T=1 +# <workDir> resolves through work-dir.mjs, so a fleet home watches its active program's dir. +K=${0:A:h}; I=${2:-120}; H=${HEARTBEAT_SECONDS:-} +env_lines=$(node $K/work-dir.mjs sh ${1:?workDir}) || exit 2; eval "$env_lines" +H=${H:-${HEARTBEAT_SECONDS:-1800}}; T=$(( H / I )); (( T < 1 )) && T=1 S=${WATCH_SNAPSHOT:-$K/snapshot.sh} # One watcher per work dir. A second copy (one started with `&` in a foreground shell, then started again) # double-reports every event and outlives the turn that started it, so it is refused, not run. diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.mjs b/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.mjs index f14e1ea8..570fbb10 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/watcher.mjs @@ -14,6 +14,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import { resolveWorkDir } from "./work-dir.mjs"; const TICK_SLACK_SECONDS = 120; @@ -59,10 +60,10 @@ function main() { const { values, positionals } = parseArgs({ allowPositionals: true, options: { now: { type: "string" } } }); const [workDir] = positionals; if (!workDir) { - process.stderr.write("Usage: watcher.mjs <workDir> [--now <iso>]\n"); + process.stderr.write("Usage: watcher.mjs <workDir: a fleet home, program dir or flat work dir> [--now <iso>]\n"); return 2; } - const { ok, line } = watcherState(workDir, values.now ? Date.parse(values.now) : Date.now()); + const { ok, line } = watcherState(resolveWorkDir(workDir).dir, values.now ? Date.parse(values.now) : Date.now()); process.stdout.write(`${line}\n`); return ok ? 0 : 1; } diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/work-dir.mjs b/plugins/vibe/skills/codex-fleet-monitor/scripts/work-dir.mjs new file mode 100644 index 00000000..cae79f4e --- /dev/null +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/work-dir.mjs @@ -0,0 +1,117 @@ +#!/usr/bin/env node +// Resolves a fleet work dir from one fleet home, so every script that writes +// orchestration state writes it in the same place. A new orchestrator chat +// once inherited only part of a program: its resume log and lessons ledger +// lived beside the work dir, its Codex writers' state in a second machine's +// own directory, and nothing said where any of it was. +// +// A fleet home is a directory with a `programs/` subdirectory: reusable state +// (config.env, standing-rulings.md, lessons.md, handover.md and `current`, +// the active program's slug) at its top, and each program's state under +// programs/<slug>/. Given the home, the work dir is the active program's dir; +// given programs/<slug> itself, that dir. Any other directory is a flat work +// dir from before the home layout and is used as is. +// +// In a home, config.env at the top is read first and programs/<slug>/config.env +// second, and the per-program state keys are always derived, never configured: +// LOCAL_STATE_DIR is <program>/sessions and REQUESTS_DIR <program>/requests, and +// with REMOTE_FLEET_HOME (the fleet home on REMOTE_HOST) REMOTE_STATE_DIR and +// REMOTE_REQUESTS_DIR are the same program's dirs there. A home-level +// LOCAL_STATE_DIR once kept serving a finished program's sessions. +// +// Usage: work-dir.mjs sh <dir> prints the shell lines a script evals: W, FLEET_HOME, FLEET_PROGRAM, the +// config files sourced in order, then the derived keys. Exits 2 for a home +// with no active program. + +import { existsSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const PER_PROGRAM_KEYS = ["LOCAL_STATE_DIR", "REQUESTS_DIR", "REMOTE_STATE_DIR", "REMOTE_REQUESTS_DIR"]; + +/** `{ home, program, dir }` for `dir`; home and program are null for a flat work dir. */ +export function resolveWorkDir(dir) { + const abs = path.resolve(dir); + if (isDir(path.join(abs, "programs"))) { + const slug = existsSync(path.join(abs, "current")) ? readFileSync(path.join(abs, "current"), "utf8").trim() : ""; + if (!slug || !isDir(path.join(abs, "programs", slug))) { + throw new Error(`no active program in ${abs}: start one with fleet-home.mjs start <slug>`); + } + return { home: abs, program: slug, dir: path.join(abs, "programs", slug) }; + } + if (path.basename(path.dirname(abs)) === "programs") { + return { home: path.dirname(path.dirname(abs)), program: path.basename(abs), dir: abs }; + } + return { home: null, program: null, dir: abs }; +} + +/** The KEY=value pairs of a config.env file (quotes stripped, no expansion). */ +export function parseConfig(text) { + const values = {}; + for (const match of text.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/gm)) { + values[match[1]] = match[2].replace(/\s+#.*$/, "").trim().replace(/^(["'])(.*)\1$/, "$2"); + } + return values; +} + +/** The merged configuration for a resolved work dir, with the per-program keys derived in a home. */ +export function readConfig(resolved) { + const files = configFiles(resolved); + const config = Object.assign({}, ...files.map((file) => parseConfig(readFileSync(file, "utf8")))); + return { ...config, ...derivedKeys(resolved, config.REMOTE_FLEET_HOME) }; +} + +function derivedKeys(resolved, remoteHome) { + if (!resolved.program) return {}; + const keys = { LOCAL_STATE_DIR: path.join(resolved.dir, "sessions"), REQUESTS_DIR: path.join(resolved.dir, "requests") }; + if (remoteHome) { + keys.REMOTE_STATE_DIR = `${remoteHome}/programs/${resolved.program}/sessions`; + keys.REMOTE_REQUESTS_DIR = `${remoteHome}/programs/${resolved.program}/requests`; + } + return keys; +} + +function configFiles(resolved) { + return [resolved.home && path.join(resolved.home, "config.env"), path.join(resolved.dir, "config.env")] + .filter((file) => file && existsSync(file)); +} + +/** The shell lines that set W, the home and program, source the config files and set the derived keys. */ +export function shellLines(resolved) { + const lines = [`W=${quote(resolved.dir)}`, `FLEET_HOME=${quote(resolved.home ?? "")}`, `FLEET_PROGRAM=${quote(resolved.program ?? "")}`]; + for (const file of configFiles(resolved)) lines.push(`. ${quote(file)}`); + if (resolved.program) { + for (const [key, value] of Object.entries(derivedKeys(resolved))) lines.push(`${key}=${quote(value)}`); + const program = quote(resolved.program); + lines.push(`if [ -n "\${REMOTE_FLEET_HOME:-}" ]; then REMOTE_STATE_DIR="$REMOTE_FLEET_HOME/programs/"${program}/sessions; REMOTE_REQUESTS_DIR="$REMOTE_FLEET_HOME/programs/"${program}/requests; fi`); + } + return `${lines.join("\n")}\n`; +} + +function quote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function isDir(file) { + try { + return statSync(file).isDirectory(); + } catch { + return false; + } +} + +function main(argv) { + const [command, dir] = argv; + if (command !== "sh" || !dir) throw new Error("Usage: work-dir.mjs sh <dir>"); + process.stdout.write(shellLines(resolveWorkDir(dir))); + return 0; +} + +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/SKILL.md b/plugins/vibe/skills/fleet-orchestrator/SKILL.md index ca399185..6a8cd6aa 100644 --- a/plugins/vibe/skills/fleet-orchestrator/SKILL.md +++ b/plugins/vibe/skills/fleet-orchestrator/SKILL.md @@ -19,13 +19,13 @@ You run a program of several PRs at once: you plan, route, review, push back and 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 or the team knowledge base; 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 `<fleetHome>/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, `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 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. +- First run: when `<fleetHome>/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 @@ -35,7 +35,7 @@ Works on one machine with no remote config. More machines need ssh key auth betw - 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). +- The operator gets QA, merges and enqueues, anything irreversible, and product decisions; you settle everything else (principle 1). Vibe's merge guard refuses `gh pr merge` on another author's pull request until the operator approves. - 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 | @@ -62,11 +62,9 @@ The closedloop-graph MCP server is required; decide from it, not from guesses. T ## State and persistence -- 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. -- 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). +- All state lives in one fleet home, never `/tmp` or a scratchpad: reused files at its top, each program under `programs/<slug>/` ([layout and a new chat's steps](./references/state.md)). `<orchDir>` below is the home or the program dir; `board.mjs status` fails on state written outside it. +- Write every prompt to `prompts/` before sending it, and keep the dated resume log; after a restart or compaction, follow a new chat's steps and tell the operator what you restarted. +- A remote machine that lost session state cannot reattach its Codex threads: start fresh ones in the same worktrees with [resume prompts](./references/templates.md#resume-prompt). ## Monitoring @@ -90,9 +88,9 @@ 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; the watcher marks a thread on another model or effort `MODEL(...)`, so restart it on the configured one. +- `turn` binds a new thread to `--cwd` (refusing another later), resumes it afterwards and 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 not starting with `$vibe`. Start every Codex writer this way; restart a thread the watcher marks `MODEL(...)` on the configured model. - `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. +- Run Codex at high effort, never the maximum (it burns the limit for little gain), and never name a sub-agent model in a prompt; vibe's 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. @@ -101,13 +99,13 @@ scripts/fleet.sh <orchDir> local|remote session last <name> [--count <n>] - 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. +- Stop a helper (reviewer, verifier, QA prep, poller) once you accept its result, recorded with `scripts/helpers.mjs <orchDir> start|accepted|stopped <name>`; run `helpers.mjs <orchDir> status` with every monitor event (`STOP`, `ORPHAN`). A helper records each process it starts with `helpers.mjs <orchDir> pid <name> <pid...>` (`--group` only for one it spawned detached), and `stopped` ends them. ## Fleet board and direct messages `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, 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. +- The status sweep, a standing `status-sweep` duty. At every writer assignment and every merge, and at every status report, snapshot each in-scope PRD's children (status, writer, PRs, latest body of each merged or DONE one, plans, absorbing umbrella) and run `node scripts/status-audit.mjs --snapshot <file>` until each `MISMATCH`, `UNOWNED`, `UNREAD`, `UNCHECKED`, `PLAN-OPEN` and `ABSORBED-OPEN` child is fixed or under `outOfScope` with who ruled it out. Closing an umbrella closes its absorbed children in the same step. 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 it; one shared component beats each PR building its own. @@ -116,18 +114,15 @@ scripts/fleet.sh <orchDir> local|remote session last <name> [--count <n>] ## Moving work between runtimes and accounts -- 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 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, or on `LIMIT` or `LIMIT-SPENDING-CREDITS`, move its busiest writers to Claude writers. The watcher's `claude-usage` line (`claude -p "/usage"`) works the same way in reverse: on `HIGH` tell the operator its reset time and, when away, move the busiest Claude writers to Codex, at once for one at 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. -- 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), 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. -- Every resume prompt says to keep the current plugin version, since fresh and resumed threads may ask. +- To Codex (another machine, account, or from Claude): a fresh `fleet.sh` thread in the branch's worktree there with a [resume prompt](./references/templates.md#resume-prompt), which says to keep the current plugin version. Codex to Claude: a Claude writer in the same worktree with the [takeover preamble](./references/templates.md#worker-preamble), which first moves the vibe writer with vibe's harness switch. ## Machines and CPU - 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. +- A shared e2e stack with fixed ports runs only under vibe's machine-wide e2e lock: `node ../vibe/scripts/e2e-lock.mjs run --head <sha> <command...>` 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 <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. @@ -136,21 +131,17 @@ scripts/fleet.sh <orchDir> local|remote session last <name> [--count <n>] - 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. 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. +- 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, 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. +- Agent-first QA, zero human checkpoints by default: an agent verifies and records everything it can (e2e on the head, DOM and accessibility reads, API second views, screenshots against the reference, measured contrast, geometry and focus). 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. +- Human QA runs in a Claude QA-guide session per PR that the operator talks to directly. When the QA plan is ready, save the [QA guide prompt](./references/templates.md#qa-guide-prompt) to `prompts/`, launch it headless from the repo root (`claude -p --output-format json`, its tools allowed), give the operator `cd <repo> && claude --resume <session_id>`, and keep a re-armed background wait on its findings file, routing 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]`. 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. @@ -158,7 +149,7 @@ scripts/fleet.sh <orchDir> local|remote session last <name> [--count <n>] 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. +- 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 `<PR ticket>: follow-ups from #<PR> 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. - 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. @@ -178,27 +169,22 @@ Pushback applies principle 2; more patterns: [references/judgment.md](./referenc ## 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> --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. +- Green watch: `board.mjs` requires a `green-watch` owner while PRs are watched (`MISSING-DUTY`), a Sonnet watcher: `green-watch.mjs --repo <owner/repo>` reports each `NEW-RED`, with `mock-gap.mjs` checking each mock block, and starts an Opus fixer only for attribution or a fix: a vibe writer (Claude `vibe:vibe`, Codex `$vibe`) briefed by `GREEN_WATCH_AGENT`, stopped after merge. Its head passes lanes, `pr-preflight.mjs` and guardrails; attribute from `gh pr view <n> --json author,headRefName`, never a worker's word. For every changed visual baseline, compare the image diff and record its counted `Baseline inventory:` plus one `Baseline element:` citation per new element; "plausibly intended" never passes. File another author's red only once that PR's ticket, PRD and design rulings show the change was not asked for (a requested change is accepted and cited). After core work, run the full e2e suites on main yourself on the CI-identical runner; an e2e fix auto-merges only after its specs and latest reds pass. - 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. -- 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. +- Deadline mode, only when the operator rules it (record it in `rulings.md` with its end): no pre-merge e2e (`pre-pr-lanes.mjs --skip <lanes> --skip-ruling <the ruling>`), 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. 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. +- A plugin PR that encodes lessons replaces the prose its gate enforces and reports the net change in instruction length, measured at the final head with `node scripts/instruction-words.mjs --base <last release> --changelog CHANGELOG.md --version <x.y.z>`. The plugin must not get heavier. - 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. +- A lesson's primary fix lives in vibe itself (writer instructions plus a mechanical check), since many run vibe without an orchestrator; a rule here is a second layer. Lessons PRs encode general rules; one repo's rules, paths and component names go in that repo's own files. ## 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, 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; 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. +Hard rule: a ticket, PR body, comment, reply or other artifact for people never names a path on the operator's machine or says something is on someone's laptop. Evidence is embedded (screenshots as inline images, text quoted briefly); a plan is a linked plan document or a ticket attachment, never a path. Check every worker draft before it is posted; vibe's ticket and PR body checks refuse a local path. ## Reporting to the operator diff --git a/plugins/vibe/skills/fleet-orchestrator/references/setup.md b/plugins/vibe/skills/fleet-orchestrator/references/setup.md index f26bbe08..8002a1fc 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/setup.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/setup.md @@ -7,16 +7,13 @@ ## config.env -`<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. +`<fleetHome>/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> CODEX_MACHINES=<local, remote or both: where Codex accounts have budget> UX_PRECEDENT=<the established product the team follows for UX precedent> -LOCAL_STATE_DIR=<orchDir>/sessions # Codex session state files on this machine -REQUESTS_DIR=<orchDir>/requests # visual-work request files from sessions here - # Codex model policy, with names from your Codex account CODEX_MODEL=<model for each Codex writer thread> CODEX_EFFORT=<the high effort level, not the maximum> @@ -33,14 +30,13 @@ REQUIRED_DUTIES="green-watch" # board.mjs status prints MISSING-DU # One other machine; leave all of these out for a single machine REMOTE_HOST=<ssh host or alias> -REMOTE_STATE_DIR=<orchestration dir on that machine>/sessions -REMOTE_REQUESTS_DIR=<orchestration dir on that machine>/requests +REMOTE_FLEET_HOME=<the fleet home on that machine> REMOTE_SHELL="zsh -lic" # a login shell there that puts node, codex and gh on PATH 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 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. +Session, request and remote state dirs are derived per program. `fleet.sh` uses `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 @@ -48,18 +44,18 @@ Ask once, in one question set, only for keys `config.env` lacks; offer the defau | Question | Default | Writes | |---|---|---| -| Machines: this one only, or more (the ssh host of each)? | this one only | `REMOTE_HOST`, `REMOTE_STATE_DIR`, `REMOTE_REQUESTS_DIR`, `REMOTE_SHELL` | +| Machines: this one only, or more (the ssh host of each)? | this one only | `REMOTE_HOST`, `REMOTE_FLEET_HOME`, `REMOTE_SHELL` | | Writers: Claude only, Codex only, or both? | Claude only | `WRITERS` | | Codex (when chosen): which machine's account has budget, at what usage band to move work, and which thread model and effort? | this machine; move at 80 | `CODEX_MACHINES`, `MOVE_AT_PERCENT`, `CODEX_MODEL`, `CODEX_EFFORT` | | Which product does the team follow for UX precedent? | none; repo siblings only | `UX_PRECEDENT` | -Then set `LOCAL_STATE_DIR`, `REQUESTS_DIR` and the slot counts from this machine's layout and core count, write the file, and run steps 3 and 5 of the checklist below plus a check that this plugin is installed on each machine. Report each check as passed or failed. +Then set the slot counts from this machine's core count, write the file, and run steps 3 and 5 of the checklist below plus a check that this plugin is installed on each machine. Report each check as passed or failed. ## Checklist -1. Create the orchestration dir in a path that survives a reboot, with `sessions/`, `prompts/`, `requests/` and `findings/` ([layout](./state.md)), and write `config.env`. +1. Run `scripts/fleet-home.mjs start <slug>` ([layout](./state.md)) and write `<fleetHome>/config.env`. 2. Fill `sessions.txt` and `prs.txt` as codex-fleet-monitor describes; a session started through `fleet.sh` is listed as `local|remote <name>`. -3. For each other machine: create its orchestration dir the same way, then check ssh and its login shell with `ssh <host> "<REMOTE_SHELL> 'command -v node codex gh'"`, which must print three paths. Install this plugin there in a persistent path. +3. For each other machine: create its fleet home the same way, then check ssh and its login shell with `ssh <host> "<REMOTE_SHELL> 'command -v node codex gh'"`, which must print three paths. Install this plugin there in a persistent path. 4. Get a branch to another machine's clone without going through the code host: `git push <host>:<path to its clone> <branch>:<branch>`, then on that machine `git worktree add <worktree path> <branch>` and run the bootstrap the target repo's docs name. Push to a branch that is not checked out there. 5. On each machine, check closedloop-graph answers (its `sync_status` tool) from Claude Code and from Codex. 6. Check the transport with `fleet.sh <orchDir> local|remote session last <name>` on an existing session. diff --git a/plugins/vibe/skills/fleet-orchestrator/references/state.md b/plugins/vibe/skills/fleet-orchestrator/references/state.md index 0c8f4920..e25ecb49 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/state.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/state.md @@ -2,24 +2,28 @@ ## Layout -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. +One fleet home per machine holds everything (`FLEET_HOME`, default `~/fleet-orchestrator`); `scripts/fleet-home.mjs` documents it, and every script given the home works in its active program. ``` -<orchDir>/ - config.env sessions.txt prs.txt monitor inputs (see codex-fleet-monitor) - last-snapshot.txt item-history.txt checks-*.txt monitor-owned; leave alone - sessions/<name>.json session state written by fleet.sh: threadId, cwd, model, effort, sandbox, turns - prompts/<name>-<nn>-<slug>.md every prompt sent to any worker, written before sending - requests/ REQUESTS_DIR: visual-work request files from Codex sessions - findings/ review, verification, QA and visual results, one file per task - rulings.md program rulings, dated and sourced; mirrored to every worker host - attention.md operator decisions needed, recommendations and blocked work - review.md one operator review page with stable finding IDs - resume-log.md - lessons.md +<fleetHome>/ + config.env standing-rulings.md lessons.md handover.md reused by every program + current the active program's slug + programs/<slug>/ + sessions.txt prs.txt board.md watcher and monitor files (codex-fleet-monitor) + sessions/<name>.json written by fleet.sh; a remote session leaves <name>.remote.json + prompts/<name>-<nn>-<slug>.md every prompt sent to any worker, written before sending + requests/ findings/ evidence/ + resume-log.md rulings.md attention.md review.md ``` -A remote machine keeps `sessions/` and `requests/` under its own persistent dir (`REMOTE_STATE_DIR` and `REMOTE_REQUESTS_DIR`). Prompts stay in this machine's `prompts/`; `fleet.sh` copies each one over when it sends it. +`fleet-home.mjs start <slug>` makes a program current; it reads nothing of an earlier one. A remote machine keeps its sessions under `REMOTE_FLEET_HOME` the same way, and `fleet.sh` copies each prompt there when it sends it. `fleet-home.mjs migrate` moves a flat work dir in. + +## A new chat + +1. `fleet-home.mjs resume`: read each `READ` file in order. On `NO-ACTIVE-PROGRAM`, `start` a fresh one. +2. `WATCHER-LIVE <pid>`: the previous chat still holds the watch lock; stop its watcher there (or close that chat), then arm yours. +3. Relaunch each `RELAUNCH <writer> <prompt>` Claude worker from that saved prompt; resume Codex writers through `fleet.sh`. +4. Run `fleet-home.mjs check` and fix every line before acting. ## Resume log @@ -36,15 +40,9 @@ A remote machine keeps `sessions/` and `requests/` under its own persistent dir Read it top to bottom after any restart or compaction before acting. -## Program rulings +## Rulings -`rulings.md` is the program-wide index, separate from the status-oriented -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`. The pre-audit -reconciliation is in SKILL.md, Quality gates and QA. +`standing-rulings.md` holds operator rulings for every program (models, review rounds, test lanes); the program's `rulings.md` holds the rest, separate from the resume log. Each entry is `- Ruling YYYY-MM-DD (operator message|AskUserQuestion|ticket: <source>): <decision>`. `fleet.sh` mirrors both to every worker host; point every worker and QA-prep prompt at them. Per-ticket rulings still go through handoff's `rulings-check.mjs`. ## Lessons ledger diff --git a/plugins/vibe/skills/fleet-orchestrator/references/templates.md b/plugins/vibe/skills/fleet-orchestrator/references/templates.md index 18ee53fd..97a0d3a1 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/templates.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/templates.md @@ -29,18 +29,17 @@ You are the only writer on <owner/repo>#<number>, worktree <path> on <machine>, Takeover: a Codex vibe session wrote this branch and stopped (<usage-limit|runtime-unavailable>). 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 <sha>; 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. -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. +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. +Heavy checks go through <fleet.sh slot command for the chosen machine>; 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 <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. +Artifacts for people (tickets, PR bodies, comments, replies) never name a path on the operator's machine; embed evidence and attach plans. Fleet board: <orchDir>/board.md shows every writer's PR, head, status and dependencies. Message another writer directly when your work touches theirs: Codex writers through the app-server-orchestrator skill's send, Claude workers and QA guides through SendMessage. Program rulings: read <orchDir>/rulings.md before planning, editing or auditing; the ticket carries the applicable per-ticket rulings through handoff's rulings check. Declare each push-ready head with `board.mjs <orchDir> writer <you> --ready <sha>`; nobody pushes a head of yours you have not declared. Push a tested checkpoint at least every 45 minutes of active work, never held for optional evidence; send review findings after that as follow-up commits, and add no new scope to an unpushed checkpoint. -Report when done: pushed head SHA, files changed, tests run and results, open items, anything that needs the operator. +Report when done: pushed head SHA, files changed, tests run with the SHA each ran at and results, open items, anything that needs the operator. Task: <task> diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs index a56626ca..2d53ebff 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs @@ -50,6 +50,8 @@ // 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. +// <orchDir> is a fleet home or a program dir (work-dir.mjs). `status` also prints +// fleet-home.mjs check's lines for a home, or for a flat dir that watches sessions or PRs. // 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 @@ -61,6 +63,8 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; import { watcherState } from "../../codex-fleet-monitor/scripts/watcher.mjs"; +import { readConfig, resolveWorkDir } from "../../codex-fleet-monitor/scripts/work-dir.mjs"; +import { homeProblems } from "./fleet-home.mjs"; import { readThreads } from "../../vibe/scripts/pr-threads.mjs"; const STATE = "board.json"; @@ -114,8 +118,7 @@ export function missingDuties(board, required) { } function requiredDuties(orchDir) { - const file = path.join(orchDir, "config.env"); - const configured = existsSync(file) ? /^REQUIRED_DUTIES=["']?([^"'\n]*)["']?\s*$/m.exec(readFileSync(file, "utf8"))?.[1] : undefined; + const configured = readConfig(resolveWorkDir(orchDir)).REQUIRED_DUTIES; if (configured !== undefined) return configured.split(/\s+/).filter(Boolean); return existsSync(path.join(orchDir, "prs.txt")) ? ["green-watch", "status-sweep"] : []; } @@ -228,7 +231,7 @@ function pushedHeads(board, texts) { } function main(argv) { - const [orchDir, action, ...rest] = argv; + const [workDir, action, ...rest] = argv; const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, @@ -239,9 +242,10 @@ function main(argv) { runtime: { type: "string" }, owner: { type: "string" }, text: { type: "string" }, ready: { type: "string" }, id: { type: "string" }, }, }); - if (!orchDir || !["writer", "deliver", "told", "duty", "question", "answered", "ack", "push-check", "status"].includes(action)) { + if (!workDir || !["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 orchDir = resolveWorkDir(workDir).dir; const board = read(orchDir); const now = values.now ? Date.parse(values.now) : Date.now(); const at = new Date(now).toISOString(); @@ -265,8 +269,10 @@ function main(argv) { 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, ${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 home = watched || resolveWorkDir(workDir).home ? homeProblems(orchDir) : []; + for (const line of home) process.stdout.write(`${line}\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"}${home.length > 0 ? `, ${home.length} outside the fleet home` : ""}\n`); + return stale.length + open.length + slow.length + duties.length + missing.length + asked.length + home.length + (watcher.ok ? 0 : 1) === 0 ? 0 : 1; } const [name, recipient] = positionals; if (!name) { diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs index 355d4608..36527e35 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs @@ -1,16 +1,19 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, 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'; const HERE = path.dirname(fileURLToPath(import.meta.url)); +// A fleet home's active program dir (fleet-home.mjs), so a watched program is not reported as a flat layout. const tempDir = (t) => { - const dir = mkdtempSync(path.join(os.tmpdir(), 'fleet-board-')); - t.after(() => rmSync(dir, { recursive: true, force: true })); - return dir; + const home = mkdtempSync(path.join(os.tmpdir(), 'fleet-board-')); + t.after(() => rmSync(home, { recursive: true, force: true })); + mkdirSync(path.join(home, 'programs', 'p1'), { recursive: true }); + writeFileSync(path.join(home, 'current'), 'p1\n'); + return path.join(home, 'programs', 'p1'); }; const run = (args, env = {}) => spawnSync(process.execPath, [path.join(HERE, 'board.mjs'), ...args], { encoding: 'utf8', env: { ...process.env, ...env } }); @@ -196,3 +199,25 @@ test('a head is pushed for a writer only when the writer declared it ready (a pa assert.equal(run([dir, 'push-check', 'w1', '--head', '5ec']).status, 1, 'a short prefix proves nothing'); assert.match(readFileSync(path.join(dir, 'board.md'), 'utf8'), /\| w1 \| \| a77421d8fa00 \| 5ec6633da7ff \|/); }); + +test('status fails on state outside the fleet home: a flat watched dir, and a session not started through fleet.sh', (t) => { + const flat = mkdtempSync(path.join(os.tmpdir(), 'fleet-board-flat-')); + t.after(() => rmSync(flat, { recursive: true, force: true })); + writeFileSync(path.join(flat, 'prs.txt'), ''); + const watcher = JSON.stringify({ pid: process.pid, startedAt: '2026-10-22T05:50:00Z', lastTickAt: '2026-10-22T05:59:00Z', intervalSeconds: 120 }); + writeFileSync(path.join(flat, 'watcher.json'), watcher); + writeFileSync(path.join(flat, 'config.env'), 'REQUIRED_DUTIES=""\n'); + const now = ['--now', '2026-10-22T06:00:00Z']; + const flatStatus = run([flat, 'status', ...now]); + assert.equal(flatStatus.status, 1); + assert.match(flatStatus.stdout, /^FLAT-LAYOUT /m); + const dir = tempDir(t); + writeFileSync(path.join(dir, 'watcher.json'), watcher); + writeFileSync(path.join(dir, 'config.env'), 'REQUIRED_DUTIES=""\n'); + writeFileSync(path.join(dir, 'sessions.txt'), 'remote prd723-vibe\n'); + const home = path.dirname(path.dirname(dir)); + assert.match(run([home, 'status', ...now]).stdout, /^UNMANAGED remote prd723-vibe: not started through fleet.sh/m, 'the home resolves to its active program'); + mkdirSync(path.join(dir, 'sessions')); + writeFileSync(path.join(dir, 'sessions', 'prd723-vibe.remote.json'), '{}\n'); + assert.equal(run([home, 'status', ...now]).status, 0); +}); diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.mjs index 8c931b4d..9105c3f6 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.mjs @@ -22,6 +22,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from import path from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import { readConfig, resolveWorkDir } from "../../codex-fleet-monitor/scripts/work-dir.mjs"; /** What is missing from each finding, or an empty list when every finding can be acted on. */ export function findingProblems(findings, exists = existsSync) { @@ -72,8 +73,7 @@ function evidenceName(finding, index, file) { } function remoteDir(orchDir) { - const config = existsSync(path.join(orchDir, "config.env")) ? readFileSync(path.join(orchDir, "config.env"), "utf8") : ""; - const value = /^REMOTE_STATE_DIR=(.*)$/m.exec(config)?.[1]?.trim().replace(/^["']|["']$/g, ""); + const value = readConfig(resolveWorkDir(orchDir)).REMOTE_STATE_DIR; if (!value) { throw new Error("--where remote needs REMOTE_STATE_DIR in config.env"); } diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.test.mjs index a85ce345..920e5930 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.test.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/finding-brief.test.mjs @@ -47,7 +47,7 @@ test('a remote brief copies the screenshots beside the prompt, names them at the const bin = join(f.root, 'bin'); mkdirSync(bin); - writeFileSync(join(bin, 'node'), '#!/usr/bin/env bash\nexit 0\n', { mode: 0o755 }); + writeFileSync(join(bin, 'node'), `#!/usr/bin/env bash\ncase "$1" in *work-dir.mjs) exec ${JSON.stringify(process.execPath)} "$@";; esac\nexit 0\n`, { mode: 0o755 }); writeFileSync(join(bin, 'ssh'), '#!/usr/bin/env bash\nexec bash -c "${@: -1}"\n', { mode: 0o755 }); const sent = spawnSync('bash', [FLEET, f.orch, 'remote', 'session', 'turn', 'w2', '--prompt-file', prompt, '--cwd', '/wt'], { encoding: 'utf8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, CODEX_EFFORT: 'high' }, diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/fleet-home.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet-home.mjs new file mode 100644 index 00000000..92db93c9 --- /dev/null +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet-home.mjs @@ -0,0 +1,298 @@ +#!/usr/bin/env node +// One fleet home holds everything an orchestrator chat reuses, and keeps one +// program's state away from the next. A new chat once inherited only part of +// a program: the resume log and lessons ledger lived outside the work dir, +// Codex writers started without fleet.sh kept their state in other +// directories, and Claude pane workers and the watcher lock died or stayed +// with the old chat. +// +// Layout (codex-fleet-monitor/scripts/work-dir.mjs resolves it for every script): +// <fleetHome>/config.env, standing-rulings.md, lessons.md, handover.md reused by every program +// <fleetHome>/current the active program's slug +// <fleetHome>/programs/<slug>/ that program's state +// +// Usage: fleet-home.mjs start <slug> [--home <fleetHome>] +// fleet-home.mjs close [--home <fleetHome>] +// fleet-home.mjs resume [--home <fleetHome>] +// fleet-home.mjs check [--home <fleetHome or program dir>] +// fleet-home.mjs migrate --from <flat work dir> --program <slug> [--home <fleetHome, default --from>] [--dry-run] +// +// --home defaults to FLEET_HOME, else ~/fleet-orchestrator. +// `start` creates programs/<slug>/ and makes it current; it refuses while +// another program is current, so a new program never reads an older one's +// state. `close` clears `current`. +// `resume` prints what a new chat reads, in order: the reusable files, then +// `ACTIVE <slug>` and the program's files, `RELAUNCH <writer> <prompt>` for +// each Claude writer on the board (its newest prompts/<writer>-*.md) and +// `WATCHER-LIVE <pid>` while another chat's watcher holds the lock; or +// `NO-ACTIVE-PROGRAM`. +// `check` prints, and exits 1 on, state outside the home or the program: +// `FLAT <name>` (program state at the home's top), `OUTSIDE <link> -> <target>` +// (a symlink in the home resolving outside it), `CONFIG <key>` (a per-program +// key in the home's config.env), `SHARED-REMOTE` (REMOTE_STATE_DIR without +// REMOTE_FLEET_HOME, shared by every program), `UNMANAGED <where> <name>` (a +// sessions.txt session with no state file fleet.sh wrote) and +// `UNMANAGED-STATE <file>` (Codex thread state outside sessions/). Given a +// flat work dir it prints `FLAT-LAYOUT`. board.mjs status prints the same lines. +// `migrate` turns a flat work dir into a home in place (or moves it to +// --home): the reusable files stay at the top, everything else moves to +// programs/<slug>/, a LOCAL_STATE_DIR or REQUESTS_DIR inside it becomes the +// program's sessions/ or requests/, those keys are commented out of +// config.env, and symlinks beside the old dir that pointed at a moved file +// are repointed. It refuses while a watcher holds the lock. rulings.md moves +// with the program: copy its standing rulings into standing-rulings.md. + +import { existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { PER_PROGRAM_KEYS, parseConfig, resolveWorkDir } from "../../codex-fleet-monitor/scripts/work-dir.mjs"; + +export const REUSABLE = ["config.env", "standing-rulings.md", "lessons.md", "handover.md", "current", "programs"]; +const PROGRAM_DIRS = ["sessions", "prompts", "requests", "findings", "evidence"]; +const PROGRAM_FILES = ["resume-log.md", "rulings.md", "attention.md"]; + +/** The fleet home: --home, FLEET_HOME, else ~/fleet-orchestrator. */ +export function defaultHome(env = process.env) { + return path.resolve(env.FLEET_HOME || path.join(os.homedir(), "fleet-orchestrator")); +} + +/** Problems that put state outside the home or the program, as printable lines. */ +export function homeProblems(dir) { + const resolved = resolveWorkDir(dir); + if (!resolved.home) return [`FLAT-LAYOUT ${resolved.dir}: migrate it into a fleet home with fleet-home.mjs migrate --from <dir> --program <slug>`]; + const problems = []; + for (const name of readdirSync(resolved.home)) { + if (!REUSABLE.includes(name) && !name.startsWith(".")) problems.push(`FLAT ${name}: program state belongs in programs/${resolved.program}/`); + } + problems.push(...outsideLinks(resolved.home, resolved.home)); + const homeConfig = readConfigFile(path.join(resolved.home, "config.env")); + for (const key of PER_PROGRAM_KEYS) { + if (homeConfig[key] !== undefined && !(key.startsWith("REMOTE_") && !homeConfig.REMOTE_FLEET_HOME)) { + problems.push(`CONFIG ${key}: derived per program; remove it from config.env`); + } + } + const config = { ...homeConfig, ...readConfigFile(path.join(resolved.dir, "config.env")) }; + if (config.REMOTE_STATE_DIR && !config.REMOTE_FLEET_HOME) { + problems.push("SHARED-REMOTE REMOTE_STATE_DIR is shared by every program: set REMOTE_FLEET_HOME to the fleet home on REMOTE_HOST"); + } + problems.push(...unmanagedSessions(resolved.dir), ...unmanagedState(resolved.home), ...unmanagedState(resolved.dir)); + return problems; +} + +function readConfigFile(file) { + return existsSync(file) ? parseConfig(readFileSync(file, "utf8")) : {}; +} + +function outsideLinks(dir, home, depth = 0) { + const problems = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const file = path.join(dir, entry.name); + if (entry.isSymbolicLink()) { + const target = path.resolve(dir, readlinkSync(file)); + if (target !== home && !target.startsWith(`${home}${path.sep}`)) problems.push(`OUTSIDE ${path.relative(home, file)} -> ${target}`); + } else if (entry.isDirectory() && depth < 3 && !entry.name.startsWith(".")) { + problems.push(...outsideLinks(file, home, depth + 1)); + } + } + return problems; +} + +function unmanagedSessions(programDir) { + const file = path.join(programDir, "sessions.txt"); + if (!existsSync(file)) return []; + return readFileSync(file, "utf8").split("\n").map((line) => line.trim().split(/\s+/)).filter((fields) => ["local", "remote"].includes(fields[0]) && fields[1]) + .filter(([where, name, threadId]) => threadId || !existsSync(path.join(programDir, "sessions", where === "local" ? `${name}.json` : `${name}.remote.json`))) + .map(([where, name]) => `UNMANAGED ${where} ${name}: not started through fleet.sh; start its next turn with fleet.sh so its state lives in sessions/`); +} + +function unmanagedState(dir) { + return readdirSync(dir).filter((name) => name.endsWith(".json")).filter((name) => { + try { + return typeof JSON.parse(readFileSync(path.join(dir, name), "utf8")).threadId === "string"; + } catch { + return false; + } + }).map((name) => `UNMANAGED-STATE ${path.join(dir, name)}: Codex thread state outside sessions/; start sessions with fleet.sh`); +} + +/** The reading list a new chat follows. */ +export function resumeLines(home) { + const lines = ["config.env", "standing-rulings.md", "lessons.md", "handover.md"].filter((name) => existsSync(path.join(home, name))) + .map((name) => `READ ${path.join(home, name)}`); + let resolved = { program: null }; + try { + resolved = resolveWorkDir(home); + } catch {} + if (!resolved.program) return [...lines, `NO-ACTIVE-PROGRAM: start one with fleet-home.mjs start <slug>; it reads no earlier program's state`]; + lines.push(`ACTIVE ${resolved.program} ${resolved.dir}`); + for (const name of ["rulings.md", "attention.md", "resume-log.md", "board.md"]) { + if (existsSync(path.join(resolved.dir, name))) lines.push(`READ ${path.join(resolved.dir, name)}`); + } + lines.push(...relaunchLines(resolved.dir)); + const pid = Number(readOr(path.join(resolved.dir, "watch.lock", "pid"), "").trim()); + if (pid > 1 && alive(pid)) lines.push(`WATCHER-LIVE ${pid}: the previous chat still watches; stop its watcher there, then arm yours`); + return lines; +} + +function relaunchLines(programDir) { + let board; + try { + board = JSON.parse(readFileSync(path.join(programDir, "board.json"), "utf8")); + } catch { + return []; + } + const prompts = existsSync(path.join(programDir, "prompts")) ? readdirSync(path.join(programDir, "prompts")).sort() : []; + return Object.entries(board.writers ?? {}).filter(([, row]) => row.runtime === "claude").map(([name]) => { + const prompt = prompts.filter((file) => file.startsWith(`${name}-`)).at(-1); + return prompt ? `RELAUNCH ${name} ${path.join(programDir, "prompts", prompt)}` : `RELAUNCH ${name} (no saved prompt in prompts/)`; + }); +} + +function readOr(file, fallback) { + try { + return readFileSync(file, "utf8"); + } catch { + return fallback; + } +} + +function alive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +/** Creates programs/<slug>/ and makes it current; refuses while another program is current. */ +export function startProgram(home, slug) { + if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) throw new Error(`invalid program slug ${slug}`); + const current = readOr(path.join(home, "current"), "").trim(); + if (current && current !== slug && existsSync(path.join(home, "programs", current))) { + throw new Error(`ACTIVE ${current}: close it with fleet-home.mjs close before starting ${slug}`); + } + const dir = path.join(home, "programs", slug); + for (const sub of PROGRAM_DIRS) mkdirSync(path.join(dir, sub), { recursive: true }); + for (const name of PROGRAM_FILES) if (!existsSync(path.join(dir, name))) writeFileSync(path.join(dir, name), ""); + writeFileSync(path.join(home, "current"), `${slug}\n`); + return dir; +} + +/** The moves that turn a flat work dir into a fleet home: `{ moves, configLines, notes }`. */ +export function migrationPlan(from, home, slug) { + const program = path.join(home, "programs", slug); + const config = readConfigFile(path.join(from, "config.env")); + const renamed = {}; + for (const [key, sub] of [["LOCAL_STATE_DIR", "sessions"], ["REQUESTS_DIR", "requests"]]) { + const value = config[key] && path.resolve(config[key]); + if (value && value.startsWith(`${path.resolve(from)}${path.sep}`) && path.dirname(value) === path.resolve(from)) renamed[path.basename(value)] = sub; + } + const moves = []; + for (const name of readdirSync(from)) { + if (name === "programs" || name === "current" || name.startsWith(".")) continue; + if (REUSABLE.includes(name)) { + if (path.resolve(from) !== path.resolve(home)) moves.push([path.join(from, name), path.join(home, name)]); + continue; + } + moves.push([path.join(from, name), path.join(program, renamed[name] ?? name)]); + } + const notes = []; + if (existsSync(path.join(from, "rulings.md"))) notes.push(`SPLIT-RULINGS copy the rulings that bind every program from ${path.join(program, "rulings.md")} into ${path.join(home, "standing-rulings.md")}`); + if (config.REMOTE_STATE_DIR && !config.REMOTE_FLEET_HOME) notes.push("SHARED-REMOTE keep REMOTE_STATE_DIR until this program's remote sessions finish, then set REMOTE_FLEET_HOME"); + const configLines = ["LOCAL_STATE_DIR", "REQUESTS_DIR"].filter((key) => config[key] !== undefined); + return { program, moves, configLines, notes }; +} + +function migrate(from, home, slug, dryRun) { + const fromDir = path.resolve(from); + const pid = Number(readOr(path.join(fromDir, "watch.lock", "pid"), "").trim()); + if (pid > 1 && alive(pid)) throw new Error(`WATCHER-LIVE ${pid}: stop the watcher for ${fromDir} first`); + const plan = migrationPlan(fromDir, home, slug); + const lines = plan.moves.map(([source, target]) => `MOVE ${source} -> ${target}`); + lines.push(...plan.configLines.map((key) => `CONFIG comment out ${key} (derived per program)`), ...plan.notes); + const relinks = siblingLinks(fromDir, plan.moves); + lines.push(...relinks.map(([link, target]) => `RELINK ${link} -> ${target}`)); + if (dryRun) return lines; + mkdirSync(plan.program, { recursive: true }); + for (const [source, target] of plan.moves) renameSync(source, target); + for (const sub of PROGRAM_DIRS) mkdirSync(path.join(plan.program, sub), { recursive: true }); + const configFile = path.join(home, "config.env"); + if (existsSync(configFile) && plan.configLines.length > 0) { + const pattern = new RegExp(`^(\\s*(?:export\\s+)?(?:${plan.configLines.join("|")})=)`, "gm"); + writeFileSync(configFile, readFileSync(configFile, "utf8").replace(pattern, "# derived per program since vibe 1.0.78: $1")); + } + const inside = movedLinks(home, plan.moves, 4); + for (const [link, target] of [...relinks, ...inside]) { + unlinkSync(link); + symlinkSync(target, link); + } + lines.push(...inside.map(([link, target]) => `RELINK ${link} -> ${target}`)); + writeFileSync(path.join(home, "current"), `${slug}\n`); + return lines; +} + +/** Symlinks beside `fromDir` whose target was moved, with the target's new path. */ +function siblingLinks(fromDir, moves) { + return movedLinks(path.dirname(fromDir), moves, 0); +} + +/** Symlinks under `dir` (to `depth` levels) whose absolute target was moved, with the target's new path. */ +function movedLinks(dir, moves, depth) { + const links = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const link = path.join(dir, entry.name); + if (entry.isDirectory() && depth > 0) links.push(...movedLinks(link, moves, depth - 1)); + if (!entry.isSymbolicLink()) continue; + const target = path.resolve(dir, readlinkSync(link)); + const move = moves.find(([source]) => target === source || target.startsWith(`${source}${path.sep}`)); + if (move) links.push([link, move[1] + target.slice(move[0].length)]); + } + return links; +} + +function main(argv) { + const [command, ...rest] = argv; + const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: { + home: { type: "string" }, from: { type: "string" }, program: { type: "string" }, "dry-run": { type: "boolean" }, + } }); + const home = values.home ? path.resolve(values.home) : defaultHome(); + const print = (lines) => process.stdout.write(lines.map((line) => `${line}\n`).join("")); + if (command === "start") { + if (!positionals[0]) throw new Error("Usage: fleet-home.mjs start <slug> [--home <fleetHome>]"); + print([`started ${startProgram(home, positionals[0])}`]); + return 0; + } + if (command === "close") { + if (existsSync(path.join(home, "current"))) unlinkSync(path.join(home, "current")); + print([`no active program in ${home}`]); + return 0; + } + if (command === "resume") { + print(resumeLines(home)); + return 0; + } + if (command === "check") { + const problems = homeProblems(home); + print(problems.length > 0 ? problems : [`fleet-home: ok ${resolveWorkDir(home).dir}`]); + return problems.length > 0 ? 1 : 0; + } + if (command === "migrate") { + if (!values.from || !values.program) throw new Error("Usage: fleet-home.mjs migrate --from <flat work dir> --program <slug> [--home <fleetHome>] [--dry-run]"); + const target = values.home ? home : path.resolve(values.from); + print(migrate(values.from, target, values.program, values["dry-run"])); + return 0; + } + throw new Error("Usage: fleet-home.mjs start|close|resume|check|migrate ..."); +} + +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/fleet-home.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet-home.test.mjs new file mode 100644 index 00000000..a60d4da3 --- /dev/null +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet-home.test.mjs @@ -0,0 +1,153 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const MONITOR = path.join(HERE, '..', '..', 'codex-fleet-monitor', 'scripts'); +const run = (args, env = {}) => spawnSync(process.execPath, [path.join(HERE, 'fleet-home.mjs'), ...args], { encoding: 'utf8', env: { ...process.env, ...env } }); +const tempRoot = (t) => { + const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'fleet-home-'))); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +}; + +test('a new program reads none of an earlier program; resume names the active one and its Claude writers to relaunch', (t) => { + const home = path.join(tempRoot(t), 'fleet'); + assert.match(run(['resume', '--home', home]).stdout, /^NO-ACTIVE-PROGRAM/m); + assert.equal(run(['start', 'iss-1', '--home', home]).status, 0); + const first = path.join(home, 'programs', 'iss-1'); + writeFileSync(path.join(first, 'resume-log.md'), '## old program\n'); + writeFileSync(path.join(first, 'board.json'), JSON.stringify({ writers: { 'green-keeper': { runtime: 'claude' }, w1: { runtime: 'codex' } } })); + writeFileSync(path.join(first, 'prompts', 'green-keeper-01-start.md'), 'start\n'); + writeFileSync(path.join(first, 'prompts', 'green-keeper-02-resume.md'), 'resume\n'); + writeFileSync(path.join(home, 'lessons.md'), 'ledger\n'); + const resumed = run(['resume'], { FLEET_HOME: home }).stdout; + assert.match(resumed, new RegExp(`^READ ${path.join(home, 'lessons.md')}$`, 'm'), 'the reusable layer is read by every program'); + assert.match(resumed, new RegExp(`^ACTIVE iss-1 ${first}$`, 'm')); + assert.match(resumed, new RegExp(`^RELAUNCH green-keeper ${path.join(first, 'prompts', 'green-keeper-02-resume.md')}$`, 'm')); + assert.doesNotMatch(resumed, /RELAUNCH w1/, 'Codex writers resume through fleet.sh, not a relaunch'); + + const refused = run(['start', 'iss-2', '--home', home]); + assert.equal(refused.status, 2); + assert.match(refused.stderr, /^ACTIVE iss-1: close it/m); + assert.equal(run(['close', '--home', home]).status, 0); + assert.equal(run(['start', 'iss-2', '--home', home]).status, 0); + const second = run(['resume', '--home', home]).stdout; + assert.match(second, /^ACTIVE iss-2 /m); + assert.doesNotMatch(second, /iss-1/, 'the new program is shown nothing of the earlier one'); + assert.equal(readFileSync(path.join(home, 'programs', 'iss-2', 'resume-log.md'), 'utf8'), ''); +}); + +test('check reports state written outside the home or the program, and passes once it is inside', (t) => { + const root = tempRoot(t); + const home = path.join(root, 'fleet'); + run(['start', 'iss-1', '--home', home]); + const program = path.join(home, 'programs', 'iss-1'); + assert.equal(run(['check', '--home', home]).status, 0); + writeFileSync(path.join(root, 'resume-log.md'), 'outside\n'); + symlinkSync(path.join(root, 'resume-log.md'), path.join(program, 'resume-log-old.md')); + writeFileSync(path.join(home, 'prs.txt'), ''); + writeFileSync(path.join(home, 'config.env'), `LOCAL_STATE_DIR=${root}/state\nREMOTE_STATE_DIR=/srv/vibe-remote\n`); + writeFileSync(path.join(program, 'sessions.txt'), 'remote prd723-vibe\nlocal w1\nlocal audit tid-1\n'); + writeFileSync(path.join(program, 'w9-local.json'), JSON.stringify({ threadId: 't9', cwd: '/x' })); + const out = run(['check', '--home', home]); + assert.equal(out.status, 1); + const lines = out.stdout.split('\n').filter(Boolean).map((line) => line.split(':')[0]); + assert.deepEqual(lines.sort(), [ + 'CONFIG LOCAL_STATE_DIR', + `OUTSIDE programs/iss-1/resume-log-old.md -> ${path.join(root, 'resume-log.md')}`, + 'FLAT prs.txt', + 'SHARED-REMOTE REMOTE_STATE_DIR is shared by every program', + 'UNMANAGED local audit', + 'UNMANAGED local w1', + 'UNMANAGED remote prd723-vibe', + `UNMANAGED-STATE ${path.join(program, 'w9-local.json')}`, + ].sort()); + rmSync(path.join(program, 'resume-log-old.md')); + rmSync(path.join(home, 'prs.txt')); + rmSync(path.join(program, 'w9-local.json')); + writeFileSync(path.join(home, 'config.env'), 'REMOTE_FLEET_HOME=/srv/fleet\n'); + writeFileSync(path.join(program, 'sessions.txt'), 'remote prd723-vibe\nlocal w1\n'); + writeFileSync(path.join(program, 'sessions', 'prd723-vibe.remote.json'), '{}\n'); + writeFileSync(path.join(program, 'sessions', 'w1.json'), JSON.stringify({ threadId: 't1' })); + assert.equal(run(['check', '--home', home]).status, 0, run(['check', '--home', home]).stdout); +}); + +test('every script resolves the home to the active program, with per-program state dirs derived', (t) => { + const home = path.join(tempRoot(t), 'fleet'); + run(['start', 'iss-1', '--home', home]); + writeFileSync(path.join(home, 'config.env'), 'CODEX_MODEL=m1\nREMOTE_FLEET_HOME=/srv/fleet\n'); + writeFileSync(path.join(home, 'programs', 'iss-1', 'config.env'), 'CODEX_MODEL=m2\n'); + const shell = (dir) => spawnSync('bash', ['-c', `lines=$(node ${JSON.stringify(path.join(MONITOR, 'work-dir.mjs'))} sh "$1") || exit 2; eval "$lines"; echo "$W|$CODEX_MODEL|$LOCAL_STATE_DIR|$REMOTE_STATE_DIR"`, 'x', dir], { encoding: 'utf8' }); + const program = path.join(home, 'programs', 'iss-1'); + const expected = `${program}|m2|${program}/sessions|/srv/fleet/programs/iss-1/sessions\n`; + assert.equal(shell(home).stdout, expected); + assert.equal(shell(program).stdout, expected); + run(['close', '--home', home]); + assert.equal(shell(home).status, 2, 'a home with no active program runs nothing'); +}); + +test('fleet.sh in a home keeps local sessions and remote session markers in the program dir', (t) => { + const root = tempRoot(t); + const home = path.join(root, 'fleet'); + run(['start', 'iss-1', '--home', home]); + const bin = path.join(root, 'bin'); + mkdirSync(bin); + writeFileSync(path.join(bin, 'node'), `#!/usr/bin/env bash\ncase "$1" in *work-dir.mjs) exec ${JSON.stringify(process.execPath)} "$@";; esac\nprintf "arg:%s\\n" "$@"\n`, { mode: 0o755 }); + writeFileSync(path.join(bin, 'ssh'), '#!/usr/bin/env bash\nexec bash -c "${@: -1}"\n', { mode: 0o755 }); + writeFileSync(path.join(home, 'config.env'), `REMOTE_HOST=box\nREMOTE_FLEET_HOME=${root}/remote\nREMOTE_SHELL="bash -c"\nCODEX_MODEL=m\nCODEX_EFFORT=high\n`); + const prompt = path.join(home, 'programs', 'iss-1', 'prompts', 'w1-01.md'); + writeFileSync(prompt, '$vibe ISS-1\n'); + const fleet = (...args) => spawnSync('bash', [path.join(HERE, 'fleet.sh'), home, ...args], { encoding: 'utf8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }); + const local = fleet('local', 'session', 'turn', 'w1', '--prompt-file', prompt); + assert.match(local.stdout, new RegExp(`^arg:${path.join(home, 'programs', 'iss-1', 'sessions')}$`, 'm')); + const remote = fleet('remote', 'session', 'turn', 'w2', '--prompt-file', prompt); + assert.equal(remote.status, 0, remote.stderr); + assert.match(remote.stdout, new RegExp(`^arg:${root}/remote/programs/iss-1/sessions$`, 'm')); + assert.ok(existsSync(path.join(home, 'programs', 'iss-1', 'sessions', 'w2.remote.json'))); +}); + +test('migrate turns a flat work dir into a home in place and repoints links beside it', (t) => { + const root = tempRoot(t); + const flat = path.join(root, 'orch'); + mkdirSync(path.join(flat, 'local-codex-state'), { recursive: true }); + mkdirSync(path.join(flat, 'prompts')); + writeFileSync(path.join(flat, 'config.env'), `LOCAL_STATE_DIR=${flat}/local-codex-state\nCODEX_MODEL=m\n`); + for (const name of ['lessons.md', 'handover.md', 'resume-log.md', 'rulings.md', 'prs.txt']) writeFileSync(path.join(flat, name), `${name}\n`); + writeFileSync(path.join(flat, 'sessions.txt'), 'local w1\nremote prd723-vibe\n'); + writeFileSync(path.join(flat, 'local-codex-state', 'w1.json'), '{"threadId":"t1"}\n'); + symlinkSync(path.join(flat, 'resume-log.md'), path.join(root, 'program-resume.md')); + symlinkSync(path.join(flat, 'lessons.md'), path.join(root, 'ledger.md')); + symlinkSync(path.join(flat, 'resume-log.md'), path.join(flat, 'prompts', 'latest-log.md')); + const dry = run(['migrate', '--from', flat, '--program', 'iss-1', '--dry-run']); + assert.equal(dry.status, 0, dry.stderr); + assert.match(dry.stdout, new RegExp(`^MOVE ${flat}/local-codex-state -> ${flat}/programs/iss-1/sessions$`, 'm')); + assert.match(dry.stdout, /^SPLIT-RULINGS /m); + assert.ok(existsSync(path.join(flat, 'resume-log.md')), 'a dry run moves nothing'); + + const watcher = spawn('sleep', ['30']); + t.after(() => watcher.kill()); + mkdirSync(path.join(flat, 'watch.lock')); + writeFileSync(path.join(flat, 'watch.lock', 'pid'), `${watcher.pid}\n`); + const live = run(['migrate', '--from', flat, '--program', 'iss-1']); + assert.equal(live.status, 2); + assert.match(live.stderr, /^WATCHER-LIVE /m); + rmSync(path.join(flat, 'watch.lock'), { recursive: true }); + + assert.equal(run(['migrate', '--from', flat, '--program', 'iss-1']).status, 0); + const program = path.join(flat, 'programs', 'iss-1'); + for (const name of ['lessons.md', 'handover.md', 'config.env']) assert.ok(existsSync(path.join(flat, name)), name); + for (const name of ['resume-log.md', 'rulings.md', 'prs.txt', 'sessions.txt', 'prompts', 'sessions/w1.json']) assert.ok(existsSync(path.join(program, name)), name); + assert.equal(readlinkSync(path.join(root, 'program-resume.md')), path.join(program, 'resume-log.md')); + assert.equal(readlinkSync(path.join(program, 'prompts', 'latest-log.md')), path.join(program, 'resume-log.md'), 'an absolute link inside the moved state follows it'); + assert.ok(lstatSync(path.join(root, 'ledger.md')).isSymbolicLink() && readlinkSync(path.join(root, 'ledger.md')) === path.join(flat, 'lessons.md'), 'a link to a reusable file is unchanged'); + assert.match(readFileSync(path.join(flat, 'config.env'), 'utf8'), /^# derived per program since vibe 1\.0\.78: LOCAL_STATE_DIR=/m); + const check = run(['check', '--home', flat]); + assert.match(check.stdout, /^UNMANAGED remote prd723-vibe/m, 'a remote session started without fleet.sh stays reported'); + assert.doesNotMatch(check.stdout, /^UNMANAGED local w1/m, 'the moved state dir is the program sessions dir'); + assert.doesNotMatch(check.stdout, /^(FLAT|CONFIG|OUTSIDE)/m); +}); diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.sh b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.sh index 3d8d391d..3d15b9a2 100755 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.sh +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.sh @@ -2,7 +2,8 @@ # Usage: fleet.sh <orchDir> <local|remote> session <turn|steer|last> <name> [codex-session.mjs options] # fleet.sh <orchDir> <local|remote> slot <lockName> <owner> <dir> <command...> # A heavy slot first runs load-check.sh on that machine and refuses (exit 4) when its load per core is over MAX_LOAD_PER_CORE. -# Settings come from <orchDir>/config.env (references/setup.md); without the REMOTE_* keys only `local` works. +# <orchDir> is a fleet home (its active program) or a program dir; codex-fleet-monitor/scripts/work-dir.mjs +# resolves it and loads config.env (references/setup.md); without the REMOTE_* keys only `local` works. # `remote` copies the scripts it needs, and a session's prompt file (with its <prompt>.evidence/ folder), to <REMOTE_STATE_DIR>/.fleet-orchestrator/ # on REMOTE_HOST over ssh and runs them there in REMOTE_SHELL. set -euo pipefail @@ -16,9 +17,11 @@ usage() { orch=$1 where=$2 action=$3 shift 3 [ -d "$orch" ] || { echo "fleet: no orchestration dir $orch" >&2; exit 2; } -if [ -f "$orch/config.env" ]; then . "$orch/config.env"; fi here=$(cd "$(dirname "$0")" && pwd) skills=$(cd "$here/../.." && pwd) +env_lines=$(node "$skills/codex-fleet-monitor/scripts/work-dir.mjs" sh "$orch") || exit 2 +eval "$env_lines" +orch=$W case "$where" in local) ;; remote) @@ -28,7 +31,7 @@ case "$where" in *) usage ;; esac -ship=(codex-fleet-monitor/scripts/liveness.mjs fleet-orchestrator/scripts/codex-session.mjs fleet-orchestrator/scripts/slot-lock.sh fleet-orchestrator/scripts/load-check.sh fleet-orchestrator/scripts/request-visual.mjs) +ship=(codex-fleet-monitor/scripts/liveness.mjs codex-fleet-monitor/scripts/work-dir.mjs fleet-orchestrator/scripts/codex-session.mjs fleet-orchestrator/scripts/slot-lock.sh fleet-orchestrator/scripts/load-check.sh fleet-orchestrator/scripts/request-visual.mjs) extra=() # Runs a shell command line on REMOTE_HOST after unpacking the shipped scripts (and `extra`) into remote_dir. @@ -61,6 +64,11 @@ session() { mkdir -p "$state" exec node "$here/codex-session.mjs" "$command" "$state" "$name" ${args[@]+"${args[@]}"} fi + # fleet-home.mjs check reports a remote session in sessions.txt without this marker as started outside fleet.sh. + if [ "$command" = turn ]; then + mkdir -p "$orch/sessions" + printf '{"host":"%s","stateDir":"%s"}\n' "$REMOTE_HOST" "$REMOTE_STATE_DIR" > "$orch/sessions/$name.remote.json" + fi for i in "${!args[@]}"; do case "${args[$i]}" in --prompt-file) prompt=${args[$((i + 1))]:-} ;; @@ -79,9 +87,10 @@ session() { esac done fi - # The program rulings are a shared spec, not resume-log status. Mirror the - # current index whenever work is sent to another host. + # The standing and program rulings are a shared spec, not resume-log status. Mirror + # both whenever work is sent to another host. if [ -f "$orch/rulings.md" ]; then extra+=("-C" "$orch" "rulings.md"); fi + if [ -n "$FLEET_HOME" ] && [ -f "$FLEET_HOME/standing-rulings.md" ]; then extra+=("-C" "$FLEET_HOME" "standing-rulings.md"); fi run_remote "$(printf '%q ' node "$remote_dir/fleet-orchestrator/scripts/codex-session.mjs" "$command" "$REMOTE_STATE_DIR" "$name" ${args[@]+"${args[@]}"})" } diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.test.mjs index c99ebc6d..98515b5a 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.test.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/fleet.test.mjs @@ -21,7 +21,8 @@ function fixture(config) { mkdirSync(orch); mkdirSync(bin); if (config !== undefined) writeFileSync(join(orch, 'config.env'), config); - writeFileSync(join(bin, 'node'), '#!/usr/bin/env bash\nprintf "arg:%s\\n" "$@"\n'); + // work-dir.mjs resolves the orchestration dir with the real node; every other node call is recorded. + writeFileSync(join(bin, 'node'), `#!/usr/bin/env bash\ncase "$1" in *work-dir.mjs) exec ${JSON.stringify(process.execPath)} "$@";; esac\nprintf "arg:%s\\n" "$@"\n`); writeFileSync(join(bin, 'ssh'), `#!/usr/bin/env bash\nprintf '%s\\n' "$@" > ${root}/ssh-args\nexec bash -c "\${@: -1}"\n`); chmodSync(join(bin, 'node'), 0o755); chmodSync(join(bin, 'ssh'), 0o755); diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/helpers.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/helpers.mjs index 8b4e8180..5fc553ae 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/helpers.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/helpers.mjs @@ -44,6 +44,7 @@ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { parseArgs } from "node:util"; +import { resolveWorkDir } from "../../codex-fleet-monitor/scripts/work-dir.mjs"; const LEDGER = "helpers.json"; const STACKS = "#stacks"; @@ -183,7 +184,7 @@ function requireName(action, name) { } async function main(argv) { - const [orchDir, action, ...rest] = argv; + const [workDir, action, ...rest] = argv; const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, @@ -197,9 +198,10 @@ async function main(argv) { now: { type: "string" }, }, }); - if (!orchDir || !["start", "pid", "accepted", "stopped", "hand", "unhand", "status"].includes(action)) { + if (!workDir || !["start", "pid", "accepted", "stopped", "hand", "unhand", "status"].includes(action)) { throw new Error("Usage: helpers.mjs <orchDir> start|pid|accepted|stopped|hand|unhand <name> | status [--idle-minutes <n>]"); } + const orchDir = resolveWorkDir(workDir).dir; const ledger = read(orchDir); const now = values.now ? Date.parse(values.now) : Date.now(); const at = new Date(now).toISOString(); diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/instruction-words.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/instruction-words.mjs new file mode 100644 index 00000000..94612497 --- /dev/null +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/instruction-words.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// The net change in instruction words a lessons release reports. A changelog +// once said "about 930 words shorter" from a count taken before the release's +// last commits, when the merged change was 832 shorter. Run it at the final +// head, and with --changelog it fails when the version's entry states a number +// that differs from the measured one. +// +// Usage: instruction-words.mjs --base <ref of the release to compare against> [--head <ref, default HEAD>] +// [--path <dir, default plugins/vibe>] [--changelog <CHANGELOG.md> --version <x.y.z>] [--repo <dir, default .>] +// +// Counts whitespace-separated words on added and removed Markdown lines under +// --path. Prints `added <a> removed <r> net <n>`. With --changelog, the entry +// `### vibe v<version>` must say "about <N> words shorter" (net below zero) or +// "about <N> words longer" with N within 2% (at least 10 words) of the net; +// exits 1 otherwise. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +/** Added and removed words on the changed Markdown lines of a unified diff. */ +export function countWords(diff) { + let added = 0; + let removed = 0; + for (const line of diff.split("\n")) { + if (line.startsWith("+++ ") || line.startsWith("--- ")) continue; + const words = line.slice(1).split(/\s+/).filter(Boolean).length; + if (line.startsWith("+")) added += words; + else if (line.startsWith("-")) removed += words; + } + return { added, removed, net: added - removed }; +} + +/** Problems with the number a changelog entry states for `net`. */ +export function changelogProblems(changelog, version, net) { + const start = changelog.indexOf(`### vibe v${version}`); + if (start < 0) return [`no "### vibe v${version}" entry`]; + const next = changelog.indexOf("\n### ", start + 1); + const entry = changelog.slice(start, next < 0 ? changelog.length : next); + const stated = /about ([\d,]+) words (shorter|longer)/.exec(entry); + if (!stated) return [`the v${version} entry states no "about <N> words shorter|longer"`]; + const number = Number(stated[1].replace(/,/g, "")); + const signed = stated[2] === "shorter" ? -number : number; + const tolerance = Math.max(10, Math.abs(net) * 0.02); + return Math.abs(signed - net) <= tolerance ? [] : [`the v${version} entry says about ${stated[1]} words ${stated[2]}; the measured net is ${net}`]; +} + +function main(argv) { + const { values } = parseArgs({ args: argv, options: { + base: { type: "string" }, head: { type: "string" }, path: { type: "string" }, changelog: { type: "string" }, version: { type: "string" }, repo: { type: "string" }, + } }); + if (!values.base || (values.changelog && !values.version)) { + throw new Error("Usage: instruction-words.mjs --base <ref> [--head <ref>] [--path <dir>] [--changelog <file> --version <x.y.z>]"); + } + const diff = execFileSync("git", ["-C", values.repo ?? ".", "diff", "-U0", values.base, values.head ?? "HEAD", "--", `${values.path ?? "plugins/vibe"}/**/*.md`], + { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + const { added, removed, net } = countWords(diff); + process.stdout.write(`added ${added} removed ${removed} net ${net}\n`); + if (!values.changelog) return 0; + const problems = changelogProblems(readFileSync(values.changelog, "utf8"), values.version, net); + for (const problem of problems) process.stdout.write(`${problem}\n`); + return problems.length === 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/instruction-words.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/instruction-words.test.mjs new file mode 100644 index 00000000..98c32c6a --- /dev/null +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/instruction-words.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, 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 { changelogProblems, countWords } from './instruction-words.mjs'; + +const SCRIPT = fileURLToPath(new URL('./instruction-words.mjs', import.meta.url)); + +test('counts words on added and removed lines only', () => { + assert.deepEqual(countWords('--- a/x.md\n+++ b/x.md\n@@ -1 +1 @@\n-one two three\n+one two\n'), { added: 2, removed: 3, net: -1 }); +}); + +test('a changelog number taken before the last commits fails at the final head (1.0.77 said 930 for 832)', () => { + const entry = (n) => `### vibe v1.0.77\n\n- Markdown is about ${n} words shorter than in 1.0.75.\n\n### vibe v1.0.76\n`; + assert.match(changelogProblems(entry('930'), '1.0.77', -832).join(), /says about 930 words shorter; the measured net is -832/); + assert.deepEqual(changelogProblems(entry('830'), '1.0.77', -832), []); + assert.match(changelogProblems(entry('830').replace('shorter', 'longer'), '1.0.77', -832).join(), /measured net is -832/); + assert.match(changelogProblems('### vibe v1.0.77\n\n- Fixed.\n', '1.0.77', -5).join(), /states no/); +}); + +test('the command measures the Markdown between two refs and checks the changelog', (t) => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'instruction-words-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const git = (...args) => execFileSync('git', ['-C', dir, '-c', 'user.name=t', '-c', 'user.email=t', ...args], { encoding: 'utf8' }).trim(); + git('init', '--quiet'); + mkdirSync(path.join(dir, 'plugins/vibe/skills'), { recursive: true }); + writeFileSync(path.join(dir, 'plugins/vibe/skills/SKILL.md'), 'one two three four five\n'); + git('add', '-A'); + git('commit', '--quiet', '-m', 'base'); + const base = git('rev-parse', 'HEAD'); + writeFileSync(path.join(dir, 'plugins/vibe/skills/SKILL.md'), 'one two\n'); + writeFileSync(path.join(dir, 'CHANGELOG.md'), '### vibe v1.0.78\n\n- Markdown is about 3 words shorter.\n'); + git('add', '-A'); + git('commit', '--quiet', '-m', 'trim'); + const ok = spawnSync(process.execPath, [SCRIPT, '--repo', dir, '--base', base, '--changelog', path.join(dir, 'CHANGELOG.md'), '--version', '1.0.78'], { encoding: 'utf8' }); + assert.equal(ok.status, 0, ok.stdout); + assert.match(ok.stdout, /^added 2 removed 5 net -3$/m); +}); diff --git a/plugins/vibe/skills/handoff/SKILL.md b/plugins/vibe/skills/handoff/SKILL.md index 7893cf2d..c5cd7c26 100644 --- a/plugins/vibe/skills/handoff/SKILL.md +++ b/plugins/vibe/skills/handoff/SKILL.md @@ -320,7 +320,8 @@ outside the allowed paths). When a removal does undo one: - the touched screen is rendered again in the verify worker's control mode on the final commit, never left as last seen. -Re-run inventory and independent read-only guardrails checks. Keep the writer's Design blocks for the ticket. A flag key without +Re-run inventory and independent read-only guardrails checks; every cleared +`Control:` citation must name that control on that surface. Keep the writer's Design blocks for the ticket. A flag key without the person's recorded yes gets its approval question once, as the vibe skill's section 7 says; with no yes the writer removes the gate. @@ -477,7 +478,8 @@ visual QA (`/visual-qa` in Claude Code, `$visual-qa` in Codex) for a UI change, against the session's acceptance criteria when the ticket or its PRD has them and otherwise as the design pack's best-practice review (`../vibe/references/quality-loop.md`, "Visual QA during the session", -"What it checks"), logical QA (`/logical-qa`, `$logical-qa`) for a data or metric +"What it checks"); its rendered-control inventory flags anything with no +sourced requirement. Run logical QA (`/logical-qa`, `$logical-qa`) for a data or metric surface, then product-contract QA (`/metal-andrew`, `$metal-andrew`) once that evidence is visible. Each skill dispatches its own read-only agents. Confirmed findings resume the SAME writer in fix mode for every touched surface, even when @@ -905,7 +907,8 @@ every two minutes and stops on a conflict (merge main at once in final-merge mode, push, run it again), failed checks (the SAME writer fixes them), an unresolved round-one review thread the writer has not answered last (armed auto-merge is not done), a later-round thread (pass `--round-one-fixed-at` -once round one is pushed; triage it into the one follow-up ticket with +and `--single-review-ruling` once round one is pushed, only while that ruling +is in force; triage it into the one follow-up ticket with `../vibe/scripts/review-followup.mjs`, reply with its link or why it is invalid, and resolve it with `review-threads.mjs`, round `later`), a merge or a close. diff --git a/plugins/vibe/skills/handoff/scripts/diff-rules.mjs b/plugins/vibe/skills/handoff/scripts/diff-rules.mjs index 525914b7..1ce408fb 100644 --- a/plugins/vibe/skills/handoff/scripts/diff-rules.mjs +++ b/plugins/vibe/skills/handoff/scripts/diff-rules.mjs @@ -19,6 +19,7 @@ import { routeSpecs } from "./route-specs.mjs"; import { escapeRegExp, IMPORT_SPECIFIER, resolveImport, SOURCE_FILE } from "./source-imports.mjs"; import { checkStorybookRules, storybookRuleTests } from "./storybook-rules.mjs"; import { isUnversionedStory, storyExportNames, storyMetaTitle } from "./storybook-taxonomy.mjs"; +import { isVisualBaseline, visualBaselineLedgerProblems } from "./visual-baseline-ledger.mjs"; const PRIVATE_PLANS = ".closedloop-ai/vibe-plans/"; const MIGRATION_DIR = /^packages\/database\/prisma\/migrations\/[^/]+\/migration\.sql$/; @@ -80,6 +81,9 @@ const JSX_MOUNT = /<([A-Z][A-Za-z0-9]*)\b/g; const RESTORE_LIMIT = 40; const DEEPEN_STEPS = ["--deepen=1000", "--deepen=10000", "--unshallow"]; const DEEPEN_TIMEOUT_MS = 10 * 60 * 1000; +const CONTROL_PROP = /\b((?:show|enable|allow|with|render|on)[A-Z][A-Za-z0-9]*)\s*(?::|=)/; +const CONTROL_TERM = /selectall|checkbox|button|menu|action|toolbar|filter|search|picker|popover|dialog|drawer|toggle|favorite|star|bulk|control|disclosure|pagination/i; +const CONTROL_MOUNT = /<([A-Z][A-Za-z0-9]*)\b/g; /** Parses `git diff --unified=0` output into per-file added and removed lines. */ export function parseUnifiedDiff(text) { @@ -159,6 +163,11 @@ export function evaluateDiff(files, changeLog, repo) { checkFrozenRenders(files, repo, logged, add); checkLostStoryIds(files, repo, add); checkCopyProvenance(live, files, repo, logged, add); + checkNewControls(live, changeLog, add); + for (const problem of visualBaselineLedgerProblems(changeLog, live.filter((file) => isVisualBaseline(file.path)).map((file) => file.path))) { + const file = problem.slice(0, problem.indexOf(":")); + add("visual-baseline-ledger", file, 1, problem.slice(problem.indexOf(":") + 2), "compare the baseline diff with the requirement inventory; record its counted `Baseline inventory:` and one cited `Baseline element:` line per new element"); + } checkCapabilityRemovals(live, files, repo, logged, add); checkSpecChanges(files, changeLog, add); checkRestoredRemovalsDeepening(live, files, repo, changeLog, add); @@ -169,6 +178,36 @@ export function evaluateDiff(files, changeLog, repo) { return { ok: blocking.length === 0, blocking, requiredTests, requiredChecks }; } +function controlSignals(file) { + const signals = new Map(); + const removedMounts = new Set(file.removed.flatMap((entry) => [...entry.text.matchAll(CONTROL_MOUNT)].map((match) => match[1]))); + for (const entry of file.added) { + const prop = CONTROL_PROP.exec(entry.text)?.[1]; + if (prop && CONTROL_TERM.test(prop)) { + signals.set(prop, entry.line); + } + for (const match of entry.text.matchAll(CONTROL_MOUNT)) { + if (CONTROL_TERM.test(match[1]) && !removedMounts.has(match[1])) { + signals.set(`<${match[1]}>`, entry.line); + } + } + } + return signals; +} + +/** A newly wired control needs a cited criterion or ruling that names it on this surface. */ +function checkNewControls(files, changeLog, add) { + for (const file of files.filter((entry) => COPY_FILE.test(entry.path) && !isTestFile(entry.path) && !STORY_FILE.test(entry.path))) { + for (const [signal, line] of controlSignals(file)) { + const prefix = `Control: ${file.path}: ${signal}:`; + const record = changeLog.split("\n").find((entry) => entry.trimStart().startsWith(prefix)); + if (!record || !/^\s*[^:\n]+:\s*(?:(?:ISS|PRD|FEA)-\d+\s+(?:AC(?:-?\d+)?|acceptance criterion|ruling)\b|operator ruling\b)/i.test(record.slice(record.indexOf(prefix) + prefix.length))) { + add("control-provenance", file.path, line, `newly enables ${signal} with no surface-specific ticket acceptance criterion or ruling`, `remove it, or record \`Control: ${file.path}: ${signal}: <surface>: <ticket AC or ruling that names this control on that surface>\`; "preserve" in another mode is not authorization`); + } + } + } +} + function checkMigrations(files, repo, logged, add, requiredChecks) { let touched = false; for (const file of files) { diff --git a/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs b/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs index 0012d897..3e681d34 100644 --- a/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs +++ b/plugins/vibe/skills/handoff/scripts/diff-rules.test.mjs @@ -319,6 +319,29 @@ test("copy provenance: a new toast, label or JSX string needs a cited source; ex assert.deepEqual(run(t, { head: { "src/ui/banner.jsx": "export const B = () => <p>Brand new words</p>;\n", "src/ui/use-banner.js": "import { B } from \"./banner\";\n" } }).found, [["copy-provenance", "src/ui/banner.jsx"]]); }); +test("a #8962-shaped prop that enables a control needs a source naming it on that surface", (t) => { + const file = "packages/app/branches/components/branches-grid-v2.tsx"; + const base = { [file]: "export const props = { columns };\n" }; + const head = { [file]: "export const props = {\n columns,\n onSelectAll: selection?.onSelectAll,\n showSelectAll: selection?.showSelectAll,\n};\n" }; + const bare = run(t, { base, head }).blocking.filter((finding) => finding.rule === "control-provenance"); + assert.deepEqual(bare.map((finding) => finding.problem), [ + "newly enables onSelectAll with no surface-specific ticket acceptance criterion or ruling", + "newly enables showSelectAll with no surface-specific ticket acceptance criterion or ruling", + ]); + const cited = [ + `Control: ${file}: onSelectAll: Branches header: ISS-100 AC-3`, + `Control: ${file}: showSelectAll: Branches header: ISS-100 AC-3`, + ].join("\n"); + assert.deepEqual(run(t, { base, head, changeLog: cited }).found.filter(([rule]) => rule === "control-provenance"), []); +}); + +test("a changed screenshot baseline needs its counted element ledger before handoff", (t) => { + const file = "e2e/__screenshots__/visual-regression.spec.ts/branches-desktop.png"; + assert.deepEqual(run(t, { base: { [file]: "old\n" }, head: { [file]: "new\n" } }).found, [["visual-baseline-ledger", file]]); + const ledger = `Baseline inventory: ${file}: Branches: 1 new element\nBaseline element: ${file}: Branches: star column: ISS-12918 AC-2\n`; + assert.deepEqual(run(t, { base: { [file]: "old\n" }, head: { [file]: "new\n" }, changeLog: ledger }).found, []); +}); + test("a lost story ID blocks unless recorded as a replacement, and every reference to it blocks", (t) => { const story = "export default { title: \"Composites/Rows\" };\nexport const Default = {};\nexport const WithLongText = {};\n"; const result = run(t, { diff --git a/plugins/vibe/skills/handoff/scripts/review-threads.mjs b/plugins/vibe/skills/handoff/scripts/review-threads.mjs index 471efab8..b488b03c 100644 --- a/plugins/vibe/skills/handoff/scripts/review-threads.mjs +++ b/plugins/vibe/skills/handoff/scripts/review-threads.mjs @@ -12,7 +12,10 @@ // raised after the round-one fixes were pushed (`"round": "later"`) is never // fixed in it; its result is `followup` (`followupUrl`, the pull request's one // follow-up ticket, from review-followup.mjs) or `invalid` (`reason`), and it -// is resolved after that reply, so it never blocks the merge. +// is resolved after that reply, so it never blocks the merge. That holds only +// during the delivery phase: once the program's core work is done the ruling +// is retired (operator, 2026-10-22) and every valid finding is fixed here, so +// a `later` result needs `singleReviewRuling` (the ruling in force) in results.json. // // Usage: // review-threads.mjs resolve --results <results.json> --threads <threads.json> [--gh <gh CLI>] @@ -21,7 +24,7 @@ // "result": "fixed"|"partial"|"reason", "fixingCommit", "remaining", "reason", // "round": "later" with "result": "followup"|"invalid" and "followupUrl" or "reason", // "verification": {"by", "verdict": "verified"|"not-verified", "evidence"}, -// "flagGated", "flagOffEqualsMain": "test:<path>"|"missing-test:<entry>"}]} +// "flagGated", "flagOffEqualsMain": "test:<path>"|"missing-test:<entry>"}], "singleReviewRuling"} // threads.json: the environment worker's `reviewThreads: read` list. // Prints {"ok", "resolved", "replyOnly", "problems"}; exits 1 on any problem. @@ -51,6 +54,9 @@ export function checkThreads(results, unresolved) { continue; } const found = problemsFor(thread, writer); + if (thread.round === "later" && !text(results.singleReviewRuling)) { + found.push("a later round is deferred only while the single-review ruling is in force (singleReviewRuling); after core work, fix it here"); + } problems.push(...found.map((problem) => `${id}: ${problem}`)); if (!found.length) (thread.result === "fixed" || thread.round === "later" ? resolve : replyOnly).push(id); } diff --git a/plugins/vibe/skills/handoff/scripts/review-threads.test.mjs b/plugins/vibe/skills/handoff/scripts/review-threads.test.mjs index b9b43d98..d382d9e6 100644 --- a/plugins/vibe/skills/handoff/scripts/review-threads.test.mjs +++ b/plugins/vibe/skills/handoff/scripts/review-threads.test.mjs @@ -79,9 +79,13 @@ test("the writer's own word, an unverified verdict, a short SHA or a missing res test("a later-round thread is answered with the follow-up ticket's link, or why it is invalid, and resolved; never fixed here", () => { const later = (extra) => ({ threadId: "T9", commentId: 9, round: "later", ...extra }); const unresolved = [{ threadId: "T9" }]; - const followup = checkThreads({ writer: "w", threads: [later({ result: "followup", followupUrl: "https://app.example.test/issues/ISS-2" })] }, unresolved); + const ruling = { writer: "w", singleReviewRuling: "operator, 2026-10-22, program rulings" }; + const followup = checkThreads({ ...ruling, threads: [later({ result: "followup", followupUrl: "https://app.example.test/issues/ISS-2" })] }, unresolved); assert.deepEqual([followup.ok, followup.resolve, followup.replyOnly], [true, ["T9"], []]); - assert.deepEqual(checkThreads({ writer: "w", threads: [later({ result: "invalid", reason: "already covered by AC-3" })] }, unresolved).resolve, ["T9"]); - assert.match(checkThreads({ writer: "w", threads: [later({ result: "followup" })] }, unresolved).problems.join(), /follow-up ticket's link/); - assert.match(checkThreads({ writer: "w", threads: [later({ result: "fixed", fixingCommit: "a".repeat(40) })] }, unresolved).problems.join(), /never fixed in this pull request/); + assert.deepEqual(checkThreads({ ...ruling, threads: [later({ result: "invalid", reason: "already covered by AC-3" })] }, unresolved).resolve, ["T9"]); + assert.match(checkThreads({ ...ruling, threads: [later({ result: "followup" })] }, unresolved).problems.join(), /follow-up ticket's link/); + assert.match(checkThreads({ ...ruling, threads: [later({ result: "fixed", fixingCommit: "a".repeat(40) })] }, unresolved).problems.join(), /never fixed in this pull request/); + const retired = checkThreads({ writer: "w", threads: [later({ result: "followup", followupUrl: "https://app.example.test/issues/ISS-2" })] }, unresolved); + assert.equal(retired.ok, false, "after core work the single round is retired, so a later round is fixed here"); + assert.match(retired.problems.join(), /single-review ruling is in force/); }); diff --git a/plugins/vibe/skills/handoff/scripts/visual-baseline-ledger.mjs b/plugins/vibe/skills/handoff/scripts/visual-baseline-ledger.mjs new file mode 100644 index 00000000..426de6c0 --- /dev/null +++ b/plugins/vibe/skills/handoff/scripts/visual-baseline-ledger.mjs @@ -0,0 +1,46 @@ +const IMAGE = /\.(?:png|jpe?g|webp)$/i; +const BASELINE_PATH = /(?:^|\/)(?:__screenshots__|snapshots?|baselines?|goldens?)(?:\/|$)/i; +const HUMAN_SOURCE = /^(?:(?:ISS|PRD|FEA)-\d+\s+(?:AC(?:-?\d+)?|acceptance criterion|ruling)\b|operator ruling\b)/i; + +/** Whether a changed path is a visual test baseline rather than an ordinary image asset. */ +export function isVisualBaseline(file) { + return IMAGE.test(file) && BASELINE_PATH.test(file); +} + +function entries(body, label, file) { + const prefix = `${label}: ${file}:`; + return body.split("\n").filter((line) => line.trimStart().startsWith(prefix)).map((line) => line.trim().slice(prefix.length).trim()); +} + +/** + * Requires a counted element inventory for each changed visual baseline. + * Reviewers compare the declared count and sources with the rendered diff. + */ +export function visualBaselineLedgerProblems(body, files) { + const problems = []; + for (const file of [...new Set(files.filter(isVisualBaseline))]) { + const inventories = entries(body, "Baseline inventory", file); + if (inventories.length !== 1) { + problems.push(`${file}: needs exactly one \`Baseline inventory: <file>: <surface>: <N> new elements\` line`); + continue; + } + const inventory = /^(.*?):\s*(\d+)\s+new elements?$/.exec(inventories[0]); + if (!inventory) { + problems.push(`${file}: baseline inventory must end with \`<N> new elements\``); + continue; + } + const surface = inventory[1].trim(); + const expected = Number(inventory[2]); + const elements = entries(body, "Baseline element", file); + if (elements.length !== expected) { + problems.push(`${file}: baseline inventory declares ${expected} new elements but lists ${elements.length}`); + } + for (const element of elements) { + const parsed = /^(.*?):\s*(.*?):\s*(.*)$/.exec(element); + if (!parsed || parsed[1].trim() !== surface || !parsed[2].trim() || !HUMAN_SOURCE.test(parsed[3].trim())) { + problems.push(`${file}: each \`Baseline element\` must name the inventory surface, the element, and its ticket acceptance criterion or ruling; "plausibly intended" is not a source`); + } + } + } + return problems; +} diff --git a/plugins/vibe/skills/vibe/SKILL.md b/plugins/vibe/skills/vibe/SKILL.md index c3ad3435..799b19b0 100644 --- a/plugins/vibe/skills/vibe/SKILL.md +++ b/plugins/vibe/skills/vibe/SKILL.md @@ -409,7 +409,9 @@ Only after the person chose resume, run `node scripts/vibe-sessions.mjs list` - the fixes are pushed to the same branch (never forced); - the pull request body is refreshed from the session record; - each review thread is answered with the fixing commit and resolved once - fixed, in round one. One review round per pull request: a thread raised + fixed, in round one. One review round per pull request, only while an + operator's single-review ruling is in force (otherwise every valid thread + is fixed, every round): a thread raised after the round-one fixes are pushed is never fixed in it; a valid one becomes a criterion on the pull request's one follow-up ticket (`scripts/review-followup.mjs`), and the thread gets that ticket's link diff --git a/plugins/vibe/skills/vibe/references/quality-loop.md b/plugins/vibe/skills/vibe/references/quality-loop.md index 13a181c4..094ec676 100644 --- a/plugins/vibe/skills/vibe/references/quality-loop.md +++ b/plugins/vibe/skills/vibe/references/quality-loop.md @@ -823,7 +823,8 @@ and `$design` route production review through. the change against them: the run gets the session decision table's `## Acceptance Criteria` list (those criteria, plus any the person's requests added) and reports each visual and UX criterion as met or not, - with what it saw. + with what it saw. It also inventories rendered controls and flags any with + no requirement naming that control on that surface. - When neither has any, it falls back to the design pack's best-practice review, against `.claude/design/soul.md`, as the skill does when there is no spec. diff --git a/plugins/vibe/skills/vibe/scripts/e2e-lock.mjs b/plugins/vibe/skills/vibe/scripts/e2e-lock.mjs index 2f121858..546fc699 100644 --- a/plugins/vibe/skills/vibe/scripts/e2e-lock.mjs +++ b/plugins/vibe/skills/vibe/scripts/e2e-lock.mjs @@ -7,7 +7,7 @@ // stack to be free, runs the command, and releases the lock when the command // ends. A lock left by a process that no longer exists is taken over. // -// Usage: e2e-lock.mjs run [--wait-seconds <n>] [--lock-dir <dir>] [--foreign-compose <project>]... <command> [args...] +// Usage: e2e-lock.mjs run --head <sha> [--wait-seconds <n>] [--lock-dir <dir>] [--foreign-compose <project>]... <command> [args...] // e2e-lock.mjs status [--lock-dir <dir>] // // The lock's own options come first; the first other word starts the command, @@ -27,6 +27,11 @@ // this lock: while `docker compose ls` shows it running, the stack counts as // busy. Without Docker, or when the listing fails, nothing is checked. // +// `run` starts only when the worktree's HEAD is `--head` with no modified +// tracked file (tested-head.mjs), prints `e2e-lock: testing <sha>` and fails +// (exit 1) when HEAD moved during the run: runs once went on at an old commit +// after every checkout failed, and passes were reported for a commit never tested. +// // `run` exits with the command's own code. When the stack stays busy past // `--wait-seconds` (default 3600) it runs nothing, prints // {"status":"busy","holder":{...}} and exits 75. `status` prints @@ -38,6 +43,7 @@ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { testedHead } from "./tested-head.mjs"; export const BUSY_EXIT = 75; export const DUPLICATE_EXIT = 76; @@ -165,7 +171,7 @@ function isAlive(pid) { } } -const OWN_OPTIONS = new Set(["--wait-seconds", "--lock-dir", "--foreign-compose"]); +const OWN_OPTIONS = new Set(["--head", "--wait-seconds", "--lock-dir", "--foreign-compose"]); const REPEATED_OPTIONS = new Set(["--foreign-compose"]); /** The first of `projects` that `docker compose ls` lists as running, or null; no Docker means none. */ @@ -240,8 +246,9 @@ async function main(argv) { return 0; } if (action !== "run" || !command) { - throw new Error("Usage: e2e-lock.mjs run [--wait-seconds <n>] [--lock-dir <dir>] [--foreign-compose <project>]... <command> [args...]"); + throw new Error("Usage: e2e-lock.mjs run --head <sha> [--wait-seconds <n>] [--lock-dir <dir>] [--foreign-compose <project>]... <command> [args...]"); } + const tested = testedHead(worktreeOf(process.cwd()), values.head); const waitSeconds = values["wait-seconds"] === undefined ? DEFAULT_WAIT_SECONDS : Number(values["wait-seconds"]); if (!Number.isFinite(waitSeconds) || waitSeconds < 0) { throw new Error("--wait-seconds must be zero or a positive number of seconds."); @@ -291,9 +298,13 @@ async function main(argv) { return BUSY_EXIT; } const composeProject = composeProjectFor(process.env, owner.token); - process.stderr.write(`e2e-lock: compose project ${composeProject}\n`); + process.stderr.write(`e2e-lock: compose project ${composeProject}\ne2e-lock: testing ${tested}\n`); try { - return await runCommand(command, args, { ...process.env, COMPOSE_PROJECT_NAME: composeProject }); + const code = await runCommand(command, args, { ...process.env, COMPOSE_PROJECT_NAME: composeProject }); + const after = execFileSync("git", ["-C", worktreeOf(process.cwd()), "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + if (after === tested) return code; + process.stderr.write(`HEAD-MOVED from ${tested.slice(0, 12)} to ${after.slice(0, 12)} during the run; its result is not for ${tested.slice(0, 12)}\n`); + return code || 1; } finally { release(lockDir, owner); } diff --git a/plugins/vibe/skills/vibe/scripts/e2e-lock.test.mjs b/plugins/vibe/skills/vibe/scripts/e2e-lock.test.mjs index b6b74497..6d7dd371 100644 --- a/plugins/vibe/skills/vibe/scripts/e2e-lock.test.mjs +++ b/plugins/vibe/skills/vibe/scripts/e2e-lock.test.mjs @@ -8,8 +8,18 @@ import { fileURLToPath } from "node:url"; import { BUSY_EXIT, DUPLICATE_EXIT, composeProjectFor, defaultLockDir, readHolder, release, runningForeignCompose, sameWorktreeEntry, tryAcquire } from "./e2e-lock.mjs"; const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "e2e-lock.mjs"); -const root = mkdtempSync(path.join(os.tmpdir(), "vibe-e2e-lock-")); +const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "vibe-e2e-lock-"))); after(() => rmSync(root, { recursive: true, force: true })); +// `run` tests a named commit of a clean worktree, so every run happens in a repository at a known HEAD. +const commitAt = (dir) => { + const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.test", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.test", GIT_AUTHOR_DATE: "2026-10-22T00:00:00Z", GIT_COMMITTER_DATE: "2026-10-22T00:00:00Z" }; + const git = (...args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", env }).stdout.trim(); + git("init", "--quiet", "--initial-branch=main"); + git("commit", "--quiet", "--allow-empty", "-m", "base"); + return git("rev-parse", "HEAD"); +}; +const SHA = commitAt(root); +process.chdir(root); const owner = (pid, token) => ({ pid, token, cwd: root, command: "e2e", startedAt: "2026-10-19T00:00:00.000Z" }); // A pid that cannot exist: above the macOS and Linux pid ceilings. @@ -51,13 +61,16 @@ test("a lock folder whose owner is still writing its record is not taken; an old test("run serializes two commands on one stack and releases the lock after each", async () => { const lockDir = path.join(root, "serial"); const marks = path.join(root, "marks.log"); - const step = (name) => [SCRIPT, "run", "--lock-dir", lockDir, process.execPath, "-e", + const step = (name) => [SCRIPT, "run", "--head", SHA, "--lock-dir", lockDir, process.execPath, "-e", `const fs=require("node:fs");fs.appendFileSync(${JSON.stringify(marks)},"${name}-start\\n");` + `setTimeout(()=>{fs.appendFileSync(${JSON.stringify(marks)},"${name}-end\\n")},1500)`]; const first = spawn(process.execPath, step("a"), { stdio: "ignore" }); const firstStarted = await waitFor(() => existsSync(marks)); assert.ok(firstStarted); - const second = spawnSync(process.execPath, step("b"), { encoding: "utf8", timeout: 60_000, cwd: root }); + const otherWorktree = path.join(root, "wt-serial"); + mkdirSync(otherWorktree); + assert.equal(commitAt(otherWorktree), SHA); + const second = spawnSync(process.execPath, step("b"), { encoding: "utf8", timeout: 60_000, cwd: otherWorktree }); assert.equal(second.status, 0, second.stderr); await new Promise((resolve) => first.once("close", resolve)); assert.deepEqual(readFileSync(marks, "utf8").trim().split("\n"), ["a-start", "a-end", "b-start", "b-end"]); @@ -66,11 +79,11 @@ test("run serializes two commands on one stack and releases the lock after each" test("run keeps the command's exit code and runs nothing when the stack stays busy", () => { const lockDir = path.join(root, "busy"); - const code = spawnSync(process.execPath, [SCRIPT, "run", "--lock-dir", lockDir, process.execPath, "-e", "process.exit(4)"]); + const code = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--lock-dir", lockDir, process.execPath, "-e", "process.exit(4)"]); assert.equal(code.status, 4); - assert.equal(tryAcquire(lockDir, owner(process.pid, "holder")).acquired, true); + assert.equal(tryAcquire(lockDir, { ...owner(process.pid, "holder"), worktree: path.join(root, "another-worktree") }).acquired, true); const marker = path.join(root, "should-not-exist"); - const busy = spawnSync(process.execPath, [SCRIPT, "run", "--wait-seconds", "0", "--lock-dir", lockDir, + const busy = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--wait-seconds", "0", "--lock-dir", lockDir, process.execPath, "-e", `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran")`], { encoding: "utf8" }); assert.equal(busy.status, BUSY_EXIT); assert.equal(JSON.parse(busy.stdout).holder.token, "holder"); @@ -83,11 +96,12 @@ test("one session takes one entry: a second run from the same worktree is refuse const lockDir = path.join(root, "per-head"); mkdirSync(path.join(root, "wt-a"), { recursive: true }); const worktree = realpathSync(path.join(root, "wt-a")); + assert.equal(commitAt(worktree), SHA); assert.equal(tryAcquire(lockDir, { ...owner(process.pid, "first"), worktree }).acquired, true); assert.equal(sameWorktreeEntry(lockDir, worktree).token, "first"); assert.equal(sameWorktreeEntry(lockDir, path.join(root, "wt-b")), null); const marker = path.join(root, "per-spec-ran"); - const second = spawnSync(process.execPath, [SCRIPT, "run", "--wait-seconds", "0", "--lock-dir", lockDir, + const second = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--wait-seconds", "0", "--lock-dir", lockDir, process.execPath, "-e", `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran")`], { encoding: "utf8", cwd: worktree }); assert.equal(second.status, DUPLICATE_EXIT); assert.match(JSON.parse(second.stdout).reason, /one run per head/); @@ -104,7 +118,7 @@ test("options after the command belong to the command, not the lock", () => { const lockDir = path.join(root, "passthrough"); const echo = path.join(root, "echo-args.cjs"); writeFileSync(echo, "process.stdout.write(JSON.stringify(process.argv.slice(2)))"); - const result = spawnSync(process.execPath, [SCRIPT, "run", "--lock-dir", lockDir, process.execPath, echo, + const result = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--lock-dir", lockDir, process.execPath, echo, "--lock-dir", "theirs", "--wait-seconds", "9"], { encoding: "utf8" }); assert.equal(result.status, 0, result.stderr); assert.deepEqual(JSON.parse(result.stdout), ["--lock-dir", "theirs", "--wait-seconds", "9"]); @@ -123,13 +137,13 @@ test("each run gets its own compose project unless the caller set one", () => { const env = { ...process.env }; Reflect.deleteProperty(env, "COMPOSE_PROJECT_NAME"); Reflect.deleteProperty(env, "VIBE_E2E_FOREIGN_COMPOSE"); - const first = spawnSync(process.execPath, [SCRIPT, "run", "--lock-dir", lockDir, ...print], { encoding: "utf8", env }); - const second = spawnSync(process.execPath, [SCRIPT, "run", "--lock-dir", lockDir, ...print], { encoding: "utf8", env }); + const first = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--lock-dir", lockDir, ...print], { encoding: "utf8", env }); + const second = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--lock-dir", lockDir, ...print], { encoding: "utf8", env }); assert.equal(first.status, 0, first.stderr); assert.match(first.stdout, /^vibe-e2e-[0-9a-f]{8}$/); assert.notEqual(first.stdout, second.stdout, "two runs never share a project, so one teardown cannot remove the other's stack"); assert.match(first.stderr, new RegExp(`compose project ${first.stdout}`)); - const kept = spawnSync(process.execPath, [SCRIPT, "run", "--lock-dir", lockDir, ...print], { encoding: "utf8", env: { ...env, COMPOSE_PROJECT_NAME: "callers-own" } }); + const kept = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--lock-dir", lockDir, ...print], { encoding: "utf8", env: { ...env, COMPOSE_PROJECT_NAME: "callers-own" } }); assert.equal(kept.stdout, "callers-own"); assert.equal(composeProjectFor({}, "0123abcd-ef45-6789"), "vibe-e2e-0123abcd"); }); @@ -148,19 +162,19 @@ test("a stack started outside the lock on a named compose project keeps the stac const env = { ...process.env, VIBE_E2E_DOCKER: docker }; Reflect.deleteProperty(env, "VIBE_E2E_FOREIGN_COMPOSE"); const command = [process.execPath, "-e", `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "ran")`]; - const busy = spawnSync(process.execPath, [SCRIPT, "run", "--wait-seconds", "0", "--lock-dir", lockDir, "--foreign-compose", "fixed-e2e", ...command], { encoding: "utf8", env }); + const busy = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--wait-seconds", "0", "--lock-dir", lockDir, "--foreign-compose", "fixed-e2e", ...command], { encoding: "utf8", env }); assert.equal(busy.status, BUSY_EXIT, busy.stderr); assert.deepEqual(JSON.parse(busy.stdout).holder, { composeProject: "fixed-e2e" }); assert.equal(existsSync(marker), false); assert.equal(existsSync(lockDir), false, "the lock is released while the foreign stack runs"); - const fromEnv = spawnSync(process.execPath, [SCRIPT, "run", "--wait-seconds", "0", "--lock-dir", lockDir, ...command], { encoding: "utf8", env: { ...env, VIBE_E2E_FOREIGN_COMPOSE: "x, fixed-e2e" } }); + const fromEnv = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--wait-seconds", "0", "--lock-dir", lockDir, ...command], { encoding: "utf8", env: { ...env, VIBE_E2E_FOREIGN_COMPOSE: "x, fixed-e2e" } }); assert.equal(fromEnv.status, BUSY_EXIT); writeFileSync(listing, "[]"); - const free = spawnSync(process.execPath, [SCRIPT, "run", "--wait-seconds", "0", "--lock-dir", lockDir, "--foreign-compose", "fixed-e2e", ...command], { encoding: "utf8", env }); + const free = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--wait-seconds", "0", "--lock-dir", lockDir, "--foreign-compose", "fixed-e2e", ...command], { encoding: "utf8", env }); assert.equal(free.status, 0, free.stderr); assert.equal(existsSync(marker), true); rmSync(marker); - const noDocker = spawnSync(process.execPath, [SCRIPT, "run", "--wait-seconds", "0", "--lock-dir", lockDir, "--foreign-compose", "fixed-e2e", ...command], + const noDocker = spawnSync(process.execPath, [SCRIPT, "run", "--head", SHA, "--wait-seconds", "0", "--lock-dir", lockDir, "--foreign-compose", "fixed-e2e", ...command], { encoding: "utf8", env: { ...env, VIBE_E2E_DOCKER: path.join(root, "no-docker") } }); assert.equal(noDocker.status, 0, noDocker.stderr); assert.equal(existsSync(marker), true); @@ -175,3 +189,30 @@ async function waitFor(predicate) { } return false; } + +test("run tests only the named commit of a clean worktree and names it (23/23 reported for an untested PR)", () => { + const lockDir = path.join(root, "tested-head"); + const wt = path.join(root, "wt-head"); + mkdirSync(wt); + commitAt(wt); + writeFileSync(path.join(wt, "tracked.txt"), "one\n"); + spawnSync("git", ["-C", wt, "add", "tracked.txt"]); + spawnSync("git", ["-C", wt, "-c", "user.name=t", "-c", "user.email=t@example.test", "commit", "--quiet", "-m", "pr head"]); + const head = spawnSync("git", ["-C", wt, "rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(); + const run = (sha, ...command) => spawnSync(process.execPath, [SCRIPT, "run", "--head", sha, "--lock-dir", lockDir, ...command], { encoding: "utf8", cwd: wt }); + const ok = [process.execPath, "-e", "0"]; + const stale = run(SHA, ...ok); + assert.equal(stale.status, 2); + assert.match(stale.stderr, /^HEAD-MISMATCH /); + writeFileSync(path.join(wt, "tracked.txt"), "leftover\n"); + const dirty = run(head, ...ok); + assert.equal(dirty.status, 2); + assert.match(dirty.stderr, /^DIRTY-WORKTREE .*tracked\.txt/); + spawnSync("git", ["-C", wt, "checkout", "--quiet", "--", "tracked.txt"]); + const clean = run(head.slice(0, 8), ...ok); + assert.equal(clean.status, 0, clean.stderr); + assert.match(clean.stderr, new RegExp(`^e2e-lock: testing ${head}$`, "m")); + const moved = run(head, "git", "checkout", "--quiet", SHA); + assert.equal(moved.status, 1); + assert.match(moved.stderr, /^HEAD-MOVED /m); +}); diff --git a/plugins/vibe/skills/vibe/scripts/pr-preflight.mjs b/plugins/vibe/skills/vibe/scripts/pr-preflight.mjs new file mode 100644 index 00000000..cfa16a61 --- /dev/null +++ b/plugins/vibe/skills/vibe/scripts/pr-preflight.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// The pull request title and body checks a code host gate would run, before +// the pull request opens or its title or body changes. A fix pull request once +// failed the metadata gate on a 73-character title: the squash subject is the +// title plus the ` (#<n>)` the host appends, and the limit counts both. Another +// merged on a regenerated baseline called "pixel-identical" to CI from a broken +// compare; it differed by about 3,900 pixels. So a line claiming a result is +// identical, matches or shows no difference carries the measured count. +// +// Usage: pr-preflight.mjs --title "<title>" [--body-file <file>] [--worktree <dir> --base <sha>] [--pr <number>] [--max <72>] +// +// Without --pr the suffix is counted at five digits. Prints {"ok","problems"} +// and exits 1 on any problem. + +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; +import { isVisualBaseline, visualBaselineLedgerProblems } from "../../handoff/scripts/visual-baseline-ledger.mjs"; + +const CLAIM = /\b(?:(?:pixel|byte)[- ]identical|identical to|no (?:visual |pixel )?(?:diffs?|differences?)\b|match(?:es|ing)? (?:the )?(?:CI|baseline|snapshot|reference|golden|Linux|main)\b)/i; +const MEASURED = /\b\d[\d,.]*\s*(?:%|px\b|pixels?\b|bytes?\b|lines?\b|differ\w*|diffs?\b|of\s+\d)/i; + +/** Problems with a pull request title: its length with the ` (#<n>)` suffix the host appends. */ +export function titleProblems(title, { pr = null, max = 72 } = {}) { + const suffix = ` (#${pr ?? "99999"})`.length; + const total = title.trim().length + suffix; + return total > max ? [`title is ${total} characters with the ${suffix}-character " (#n)" suffix; the limit is ${max}`] : []; +} + +/** Body lines that claim an identical or matching result without the measured count. */ +export function claimProblems(body) { + return body.split("\n").filter((line) => CLAIM.test(line) && !MEASURED.test(line)) + .map((line) => `an "identical/matches" claim names no measured count (for example "0 of 1,048,576 pixels differ"): ${line.trim().slice(0, 160)}`); +} + +/** Changed visual baselines between the session base and its committed head. */ +export function changedVisualBaselines(worktree, base) { + return execFileSync("git", ["-C", worktree, "diff", "--name-only", "--diff-filter=ACMRT", base, "HEAD", "--"], { encoding: "utf8" }) + .split("\n").filter(isVisualBaseline); +} + +function main(argv) { + const { values } = parseArgs({ args: argv, options: { title: { type: "string" }, "body-file": { type: "string" }, worktree: { type: "string" }, base: { type: "string" }, pr: { type: "string" }, max: { type: "string" } } }); + if (!values.title?.trim() || Boolean(values.worktree) !== Boolean(values.base)) throw new Error("Usage: pr-preflight.mjs --title <title> [--body-file <file>] [--worktree <dir> --base <sha>] [--pr <number>] [--max <72>]"); + const body = values["body-file"] ? readFileSync(values["body-file"], "utf8") : ""; + const problems = [ + ...titleProblems(values.title, { pr: values.pr ?? null, max: Number(values.max ?? 72) }), + ...claimProblems(body), + ...visualBaselineLedgerProblems(body, values.worktree ? changedVisualBaselines(values.worktree, values.base) : []), + ]; + process.stdout.write(`${JSON.stringify({ ok: problems.length === 0, problems })}\n`); + return problems.length === 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/vibe/scripts/pr-preflight.test.mjs b/plugins/vibe/skills/vibe/scripts/pr-preflight.test.mjs new file mode 100644 index 00000000..9f3953d5 --- /dev/null +++ b/plugins/vibe/skills/vibe/scripts/pr-preflight.test.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, 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 { claimProblems, titleProblems } from "./pr-preflight.mjs"; +import { visualBaselineLedgerProblems } from "../../handoff/scripts/visual-baseline-ledger.mjs"; + +const SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), "pr-preflight.mjs"); + +test("a title over 72 characters with the ' (#n)' suffix fails, as the metadata gate does (#9188 at 73)", () => { + const title = (length) => `ISS-12901: ${"x".repeat(length - 11)}`; + assert.match(titleProblems(title(65), { pr: 9188 }).join(), /title is 73 characters/); + assert.deepEqual(titleProblems(title(64), { pr: 9188 }), []); + assert.match(titleProblems(title(64)).join(), /title is 73 characters/, "before the PR exists the suffix counts five digits"); +}); + +test("an identical or matching claim needs its measured count (#9186 was about 3,900 pixels off)", () => { + assert.equal(claimProblems("Regenerated baseline is pixel-identical to CI.").length, 1); + assert.equal(claimProblems("Snapshot matches the Linux render.").length, 1); + assert.equal(claimProblems("No visual diff against main.").length, 1); + assert.deepEqual(claimProblems("Regenerated baseline is pixel-identical to CI: 0 of 1,048,576 pixels differ."), []); + assert.deepEqual(claimProblems("Matches CI within 0.02% (210 pixels differ, threshold 0.1%)."), []); + assert.deepEqual(claimProblems("Fixes the star column header."), []); + assert.deepEqual(claimProblems("The empty state matches the design and the ticket's wording."), [], "only result comparisons need a count"); +}); + +test("the command fails a 73-character title and passes a fitting one", (t) => { + const dir = mkdtempSync(path.join(os.tmpdir(), "pr-preflight-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const body = path.join(dir, "body.md"); + writeFileSync(body, "## Summary\n- Fix the reds.\n"); + const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" }); + const long = run("--title", "x".repeat(65), "--pr", "9188", "--body-file", body); + assert.equal(long.status, 1); + assert.equal(JSON.parse(long.stdout).ok, false); + assert.equal(run("--title", "x".repeat(64), "--pr", "9188", "--body-file", body).status, 0); +}); + +test("a changed visual baseline needs a counted, sourced element ledger (#9188)", (t) => { + const file = "e2e/__screenshots__/visual-regression.spec.ts/branches-desktop.png"; + assert.match(visualBaselineLedgerProblems("The checkbox is plausibly intended.\n", [file]).join(), /needs exactly one/); + const incomplete = [ + `Baseline inventory: ${file}: Branches: 3 new elements`, + `Baseline element: ${file}: Branches: star column: ISS-12918 AC-2`, + `Baseline element: ${file}: Branches: header select-all checkbox: plausibly intended`, + ].join("\n"); + assert.match(visualBaselineLedgerProblems(incomplete, [file]).join(), /declares 3 new elements but lists 2/); + assert.match(visualBaselineLedgerProblems(incomplete, [file]).join(), /plausibly intended/); + + const dir = mkdtempSync(path.join(os.tmpdir(), "pr-baseline-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + execFileSync("git", ["init", "--quiet", "--initial-branch=main", dir]); + execFileSync("git", ["-C", dir, "config", "user.email", "test@example.com"]); + execFileSync("git", ["-C", dir, "config", "user.name", "Test"]); + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), "old"); + execFileSync("git", ["-C", dir, "add", "."]); + execFileSync("git", ["-C", dir, "commit", "--quiet", "-m", "base"]); + const base = execFileSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + writeFileSync(path.join(dir, file), "new"); + execFileSync("git", ["-C", dir, "commit", "--quiet", "-am", "baseline"]); + const body = path.join(dir, "body.md"); + writeFileSync(body, incomplete); + const failed = spawnSync(process.execPath, [SCRIPT, "--title", "ISS-12901: Fix baselines", "--body-file", body, "--worktree", dir, "--base", base], { encoding: "utf8" }); + assert.equal(failed.status, 1); + writeFileSync(body, `${incomplete}\nBaseline element: ${file}: Branches: avatar style: ISS-12576 ruling\n`.replace("header select-all checkbox: plausibly intended", "header select-all checkbox: ISS-100 AC-3")); + assert.equal(spawnSync(process.execPath, [SCRIPT, "--title", "ISS-12901: Fix baselines", "--body-file", body, "--worktree", dir, "--base", base], { encoding: "utf8" }).status, 0); +}); diff --git a/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.mjs b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.mjs index 219198cc..abe3b56b 100644 --- a/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.mjs +++ b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.mjs @@ -5,30 +5,45 @@ // 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>] +// pre-pr-lanes.mjs run --worktree <path> --head <sha> --command "<the lane's command, as the report prints it>" +// pre-pr-lanes.mjs record --worktree <path> --head <sha the run tested> --command "<lane command>" --result pass|fail +// pre-pr-lanes.mjs check --worktree <path> --report <the lane report's output, e.g. `pnpm test:lanes`> [--skip <regex> --skip-ruling "<who, when, where>"] // // 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` +// package runner (pnpm, npm, npx, yarn, node), after any `KEY='value'` +// assignments the lane's CI step binds; 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. +// +// Every lane the report names is required, e2e lanes included: a pull request +// once merged with two Desktop E2E reds because this check skipped e2e lanes +// by default and could not read a lane line that starts with the step's +// variables. `--skip` drops lanes only with a recorded operator ruling +// (deadline mode), printing `SKIPPED-LANE <command> (<ruling>)` for each. +// +// `run` runs a lane in the worktree and records its result; `record` takes a +// result run elsewhere. Both refuse unless the worktree's HEAD is `--head` +// with no modified tracked file (tested-head.mjs), and `run` records a failure +// when HEAD moved while it ran, so every result names the commit it tested. +import { spawnSync } from "node:child_process"; 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"; +import { testedHead } from "./tested-head.mjs"; -const LANE_LINE = /^ {4}((?:pnpm|npm|npx|yarn|node)\s.*?)(?:\s{2,}\(.*\))?\s*$/; +const LANE_LINE = /^ {4}((?:[A-Za-z_][A-Za-z0-9_]*=(?:'(?:[^']|'\\'')*'|\S+)\s+)*(?:pnpm|npm|npx|yarn|node)\s.*?)(?:\s+\(.*\))?\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) { +export function reportLanes(report) { 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)); + return all; } /** Lanes with no passing record at `head`. */ @@ -48,27 +63,39 @@ function readRecords(worktree) { 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" }, + worktree: { type: "string" }, head: { type: "string" }, command: { type: "string" }, result: { type: "string" }, report: { type: "string" }, skip: { type: "string" }, "skip-ruling": { 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() }); + if (command === "run" || command === "record") { + if (!values.command?.trim() || (command === "record" && !["pass", "fail"].includes(values.result))) throw new Error(`${command} needs --head, --command${command === "record" ? " and --result pass|fail" : ""}`); + const tested = testedHead(values.worktree, values.head); + let result = values.result; + if (command === "run") { + const run = spawnSync(values.command, { cwd: values.worktree, shell: true, stdio: "inherit" }); + const after = git(values.worktree, ["rev-parse", "HEAD"]); + result = run.status === 0 && after === tested ? "pass" : "fail"; + if (after !== tested) process.stdout.write(`HEAD-MOVED from ${tested.slice(0, 12)} to ${after.slice(0, 12)} during the run\n`); + } + const records = readRecords(values.worktree).filter((record) => !(record.command === values.command.trim() && record.head === tested)); + records.push({ command: values.command.trim(), head: tested, 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; + process.stdout.write(`recorded ${result} for ${values.command.trim()} at ${tested}\n`); + return result === "pass" ? 0 : 1; } + const head = git(values.worktree, ["rev-parse", "HEAD"]); 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) { + if (values.skip && !values["skip-ruling"]?.trim()) throw new Error("--skip needs --skip-ruling naming the operator ruling that waives those lanes"); + const named = reportLanes(readFileSync(values.report, "utf8")); + if (named === 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 skip = values.skip ? new RegExp(values.skip, "i") : null; + const lanes = named.filter((lane) => !skip?.test(lane)); + for (const lane of named.filter((lane) => skip?.test(lane))) process.stdout.write(`SKIPPED-LANE ${lane} (${values["skip-ruling"].trim()})\n`); 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`); + for (const lane of missing) process.stdout.write(`MISSING-LANE ${lane}: run it, with pre-pr-lanes.mjs run --head <HEAD> --command "${lane}"\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; } diff --git a/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.test.mjs b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.test.mjs index 3faf931e..ad563c28 100644 --- a/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.test.mjs +++ b/plugins/vibe/skills/vibe/scripts/pre-pr-lanes.test.mjs @@ -14,12 +14,14 @@ const REPORT = [ "", "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)", + " DESKTOP_E2E_SPECS='e2e/branches-grid.spec.ts e2e/it'\\''s.spec.ts' DESKTOP_E2E_PROJECTS='desktop' pnpm --filter desktop test:e2e (1 changed path(s) in e2e-test.yml's desktop_e2e filter)", + " CI runs it whole, the selector widened.", "", ].join("\n"); +const DESKTOP_E2E = "DESKTOP_E2E_SPECS='e2e/branches-grid.spec.ts e2e/it'\\''s.spec.ts' DESKTOP_E2E_PROJECTS='desktop' pnpm --filter desktop test:e2e"; -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"]); +test("every lane the report names is required, e2e lanes and lanes with the step's variables included (#9116 merged with Desktop E2E reds)", () => { + assert.deepEqual(reportLanes(REPORT), ["pnpm --filter @repo/app test", "pnpm test:desktop-renderer", DESKTOP_E2E]); 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"); }); @@ -44,8 +46,44 @@ test("the PR-open check refuses a diff naming desktop-renderer with no recorded 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.match(refused.stdout, /^MISSING-LANE DESKTOP_E2E_SPECS=.* pnpm --filter desktop test:e2e:/m); + assert.equal(run("check", "--worktree", worktree, "--report", report, "--skip", "e2e").status, 2, "a skip needs the operator ruling that waives it"); + const waived = run("check", "--worktree", worktree, "--report", report, "--skip", "test:e2e", "--skip-ruling", "deadline mode, operator, 2026-10-22, rulings.md"); + assert.match(waived.stdout, /^SKIPPED-LANE DESKTOP_E2E_SPECS=.* \(deadline mode, operator, 2026-10-22, rulings.md\)$/m); + const head = git(worktree, ["rev-parse", "HEAD"], home).trim(); + for (const lane of reportLanes(REPORT)) assert.equal(run("record", "--worktree", worktree, "--head", head, "--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"); }); + +test("a result names the commit it ran: a run refuses another HEAD or a modified tracked file (23/23 reported for an untested PR)", (t) => { + const { root, home, cleanup } = makeHome("vibe-pre-pr-lanes-head-"); + t.after(cleanup); + const worktree = path.join(root, "wt"); + git(root, ["init", "--quiet", "--initial-branch=main", worktree], home); + writeFileSync(path.join(worktree, "a.txt"), "one\n"); + git(worktree, ["add", "a.txt"], home); + git(worktree, ["commit", "--quiet", "-m", "base"], home); + const old = git(worktree, ["rev-parse", "HEAD"], home).trim(); + git(worktree, ["commit", "--quiet", "--allow-empty", "-m", "the PR head"], home); + const intended = git(worktree, ["rev-parse", "HEAD"], home).trim(); + const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8", env: { ...process.env, HOME: home } }); + git(worktree, ["checkout", "--quiet", old], home); + const stale = run("run", "--worktree", worktree, "--head", intended, "--command", "true"); + assert.equal(stale.status, 2); + assert.match(stale.stderr, /^HEAD-MISMATCH the worktree is at /); + assert.equal(run("record", "--worktree", worktree, "--head", intended, "--command", "pnpm test", "--result", "pass").status, 2, "a result from another commit is never recorded"); + git(worktree, ["checkout", "--quiet", intended], home); + writeFileSync(path.join(worktree, "a.txt"), "leftover\n"); + const dirty = run("run", "--worktree", worktree, "--head", intended.slice(0, 10), "--command", "true"); + assert.equal(dirty.status, 2); + assert.match(dirty.stderr, /^DIRTY-WORKTREE .*a\.txt/); + git(worktree, ["checkout", "--quiet", "--", "a.txt"], home); + const clean = run("run", "--worktree", worktree, "--head", intended.slice(0, 10), "--command", "true"); + assert.equal(clean.status, 0, clean.stderr); + assert.match(clean.stdout, new RegExp(`^recorded pass for true at ${intended}$`, "m")); + const moved = run("run", "--worktree", worktree, "--head", intended, "--command", `git checkout --quiet ${old}`); + assert.equal(moved.status, 1, "HEAD moving during the run fails it"); + assert.match(moved.stdout, /^HEAD-MOVED /m); +}); diff --git a/plugins/vibe/skills/vibe/scripts/review-followup.mjs b/plugins/vibe/skills/vibe/scripts/review-followup.mjs index 4c8c6f7b..a14f9041 100644 --- a/plugins/vibe/skills/vibe/scripts/review-followup.mjs +++ b/plugins/vibe/skills/vibe/scripts/review-followup.mjs @@ -5,9 +5,13 @@ // follow-up ticket for that pull request. Later rounds append criteria to the // same ticket (a new document version), never a second ticket; each thread is // then answered with the ticket's link (or why it is invalid) and resolved. +// The single round holds only during the delivery phase; once the program's +// core work is done the ruling is retired (operator, 2026-10-22) and every +// valid finding is fixed in its pull request, so `plan` needs +// `--single-review-ruling <the ruling in force>`. // // Usage: -// review-followup.mjs plan --worktree <path> --pr <n> --pr-ticket <slug> --findings <findings.json> [--found <search.json>] +// review-followup.mjs plan --worktree <path> --pr <n> --pr-ticket <slug> --findings <findings.json> --single-review-ruling "<who, date, where>" [--found <search.json>] // review-followup.mjs record --worktree <path> --pr <n> --slug <ticket slug> // // findings.json: [{"thread": "<thread url or id>", "finding": "<one checkable line>"}], the valid ones only. @@ -63,7 +67,7 @@ function readRecords(worktree) { function main(argv) { const [command, ...rest] = argv; const { values } = parseArgs({ args: rest, options: { - worktree: { type: "string" }, pr: { type: "string" }, "pr-ticket": { type: "string" }, findings: { type: "string" }, found: { type: "string" }, slug: { type: "string" }, + worktree: { type: "string" }, pr: { type: "string" }, "pr-ticket": { type: "string" }, findings: { type: "string" }, found: { type: "string" }, slug: { type: "string" }, "single-review-ruling": { type: "string" }, } }); if (!values.worktree || !/^\d+$/.test(values.pr ?? "")) { throw new Error("--worktree and --pr <number> are required"); @@ -71,6 +75,7 @@ function main(argv) { const records = readRecords(values.worktree); if (command === "plan") { if (!values["pr-ticket"] || !values.findings) throw new Error("plan needs --pr-ticket and --findings"); + if (!values["single-review-ruling"]?.trim()) throw new Error("plan needs --single-review-ruling: after core work every valid finding is fixed in its pull request, every round, with no follow-up ticket"); return planFollowup({ recorded: records[values.pr]?.slug, found: values.found ? JSON.parse(readFileSync(values.found, "utf8")) : [], diff --git a/plugins/vibe/skills/vibe/scripts/review-followup.test.mjs b/plugins/vibe/skills/vibe/scripts/review-followup.test.mjs index 42ea8e47..0e9d08cb 100644 --- a/plugins/vibe/skills/vibe/scripts/review-followup.test.mjs +++ b/plugins/vibe/skills/vibe/scripts/review-followup.test.mjs @@ -29,7 +29,11 @@ test("a second later round reuses the recorded ticket and a second ticket is ref git(root, ["init", "--quiet", "--initial-branch=main", worktree], home); const findings = path.join(root, "findings.json"); writeFileSync(findings, JSON.stringify(ROUND_TWO)); - const plan = (...extra) => runNode(SCRIPT, ["plan", "--worktree", worktree, "--pr", "9118", "--pr-ticket", "ISS-1", "--findings", findings, ...extra], home); + const base = ["plan", "--worktree", worktree, "--pr", "9118", "--pr-ticket", "ISS-1", "--findings", findings]; + const retired = runNode(SCRIPT, base, home); + assert.equal(retired.status, 1, "after core work the single round is retired: no follow-up ticket without the ruling in force"); + assert.match(retired.json.error, /--single-review-ruling/); + const plan = (...extra) => runNode(SCRIPT, [...base, "--single-review-ruling", "operator, 2026-10-22, program rulings", ...extra], home); assert.equal(plan().json.action, "create"); assert.equal(runNode(SCRIPT, ["record", "--worktree", worktree, "--pr", "9118", "--slug", "ISS-2"], home).status, 0); writeFileSync(findings, JSON.stringify(ROUND_THREE)); diff --git a/plugins/vibe/skills/vibe/scripts/shepherd.mjs b/plugins/vibe/skills/vibe/scripts/shepherd.mjs index 70230c48..c168e229 100644 --- a/plugins/vibe/skills/vibe/scripts/shepherd.mjs +++ b/plugins/vibe/skills/vibe/scripts/shepherd.mjs @@ -11,9 +11,13 @@ // fixed here; a later one is triaged, never fixed here and never a merge // blocker: a valid finding joins the pull request's one follow-up ticket // (review-followup.mjs), the thread gets a reply with that ticket's link or -// why it is invalid, and is resolved. +// why it is invalid, and is resolved. The single round holds only during the +// delivery phase: once the program's core work is done the ruling is retired +// (operator, 2026-10-22) and every valid finding is fixed in its pull request, +// every round. So `--round-one-fixed-at` needs `--single-review-ruling <the +// ruling in force>`; without it every thread is fixed here. // -// Usage: shepherd.mjs --pr <number> --repo <owner/repo> [--round-one-fixed-at <iso time of the round-one fix push>] [--interval-seconds 120] [--max-polls <n>] +// Usage: shepherd.mjs --pr <number> --repo <owner/repo> [--round-one-fixed-at <iso time> --single-review-ruling "<who, date, where>"] [--interval-seconds 120] [--max-polls <n>] // // Prints one JSON object when it stops: // {"event":"merged"} exit 0 @@ -92,12 +96,16 @@ function readPr(pr, repo, roundOneFixedAt) { async function main() { const { values } = parseArgs({ - options: { pr: { type: "string" }, repo: { type: "string" }, "interval-seconds": { type: "string" }, "max-polls": { type: "string" }, "round-one-fixed-at": { type: "string" } }, + options: { pr: { type: "string" }, repo: { type: "string" }, "interval-seconds": { type: "string" }, "max-polls": { type: "string" }, "round-one-fixed-at": { type: "string" }, "single-review-ruling": { type: "string" } }, }); if (!/^\d+$/.test(values.pr ?? "") || !/^[\w.-]+\/[\w.-]+$/.test(values.repo ?? "") || (values["round-one-fixed-at"] !== undefined && !Number.isFinite(Date.parse(values["round-one-fixed-at"])))) { process.stdout.write(`${JSON.stringify({ event: "usage", error: "--pr <number> and --repo <owner/repo> are required" })}\n`); process.exit(64); } + if (values["round-one-fixed-at"] !== undefined && !values["single-review-ruling"]?.trim()) { + process.stdout.write(`${JSON.stringify({ event: "usage", error: "--round-one-fixed-at needs --single-review-ruling: after core work every valid finding is fixed here, every round" })}\n`); + process.exit(64); + } const interval = Number(values["interval-seconds"] ?? 120); const result = await shepherd({ pr: Number(values.pr), diff --git a/plugins/vibe/skills/vibe/scripts/shepherd.test.mjs b/plugins/vibe/skills/vibe/scripts/shepherd.test.mjs index b4e9bf4d..206a21c8 100644 --- a/plugins/vibe/skills/vibe/scripts/shepherd.test.mjs +++ b/plugins/vibe/skills/vibe/scripts/shepherd.test.mjs @@ -67,7 +67,11 @@ test("the command reads the pull request through gh and exits with the event's c writeFileSync(path.join(dir, "threads.json"), JSON.stringify({ data: { repository: { pullRequest: { reviewThreads: { nodes: [ thread("review-bot", "review-bot", "2026-10-22T10:30:00Z"), ] } } } } })); - const roundTwo = spawnSync(process.execPath, [SCRIPT, "--pr", "42", "--repo", "o/r", "--interval-seconds", "0", "--max-polls", "1", "--round-one-fixed-at", "2026-10-22T10:00:00Z"], { encoding: "utf8", env }); + const roundTwoArgs = [SCRIPT, "--pr", "42", "--repo", "o/r", "--interval-seconds", "0", "--max-polls", "1", "--round-one-fixed-at", "2026-10-22T10:00:00Z"]; + const retired = spawnSync(process.execPath, roundTwoArgs, { encoding: "utf8", env }); + assert.equal(retired.status, 64, "after core work the single round is retired: a later round needs the ruling in force"); + assert.match(JSON.parse(retired.stdout).error, /--single-review-ruling/); + const roundTwo = spawnSync(process.execPath, [...roundTwoArgs, "--single-review-ruling", "operator, 2026-10-22, program rulings"], { encoding: "utf8", env }); assert.equal(roundTwo.status, EXIT["later-threads"]); assert.deepEqual(JSON.parse(roundTwo.stdout), { event: "later-threads", head: "abc", unresolved: 1 }); assert.equal(shepherdRun().status, EXIT.threads, "without a round-one fix push every thread is round one"); diff --git a/plugins/vibe/skills/vibe/scripts/tested-head.mjs b/plugins/vibe/skills/vibe/scripts/tested-head.mjs new file mode 100644 index 00000000..54ada24c --- /dev/null +++ b/plugins/vibe/skills/vibe/scripts/tested-head.mjs @@ -0,0 +1,22 @@ +// Every test result names the commit it actually ran. A worker's container +// runs once tested an old commit for about an hour: a leftover modified file +// made each checkout in that worktree fail, the runs went on at the old head, +// and "23/23 passed" was reported for a pull request that was never tested. +// So a run starts only when the worktree's HEAD is the commit it means to +// test and no tracked file is modified, and its result carries that HEAD. + +import { git } from "./session-record.mjs"; + +/** The full HEAD of `worktree` when it is `intended` (a full or abbreviated SHA) with no modified tracked file; throws otherwise. */ +export function testedHead(worktree, intended) { + if (!/^[0-9a-f]{7,40}$/i.test(intended ?? "")) throw new Error("name the commit to test as a SHA (--head <sha>)"); + const head = git(worktree, ["rev-parse", "HEAD"]); + if (!head.startsWith(intended.toLowerCase())) { + throw new Error(`HEAD-MISMATCH the worktree is at ${head.slice(0, 12)}, not ${intended}: check out the commit to test (a failed checkout leaves the old one)`); + } + const dirty = git(worktree, ["status", "--porcelain", "--untracked-files=no"]); + if (dirty) { + throw new Error(`DIRTY-WORKTREE modified tracked files would make the run test something other than ${head.slice(0, 12)}: ${dirty.split("\n").map((line) => line.replace(/^\s*\S+\s+/, "")).join(", ")}`); + } + return head; +} diff --git a/plugins/vibe/tools/python/test_vibe_1078_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1078_lessons_contract.py new file mode 100644 index 00000000..b9d1fd61 --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_1078_lessons_contract.py @@ -0,0 +1,93 @@ +"""Contract coverage for the vibe 1.0.78 release.""" + +import json +import re +from pathlib import Path + +from vibe_contract_text import flat + + +PLUGIN = Path(__file__).resolve().parents[2] +FLEET = PLUGIN / "skills" / "fleet-orchestrator" + + +def test_the_lessons_loop_measures_instruction_words_at_the_final_head() -> None: + skill = flat((FLEET / "SKILL.md").read_text()) + assert "instruction-words.mjs --base <last release> --changelog CHANGELOG.md --version <x.y.z>" in skill + assert (FLEET / "scripts" / "instruction-words.mjs").exists() + + +MONITOR = PLUGIN / "skills" / "codex-fleet-monitor" +WORK_DIR_USAGE = re.compile(r"Usage:.*<(?:orchDir|workDir)>") + + +def test_every_script_that_takes_a_work_dir_resolves_it_through_the_fleet_home() -> None: + scripts = [ + path + for folder in (FLEET / "scripts", MONITOR / "scripts") + for path in sorted(folder.iterdir()) + if path.suffix in {".mjs", ".sh"} and not path.name.endswith(".test.mjs") and WORK_DIR_USAGE.search(path.read_text()) + ] + assert len(scripts) >= 10 + for path in scripts: + text = path.read_text() + assert "work-dir.mjs" in text or "resolveWorkDir" in text, path.name + + +def test_a_new_chat_resumes_from_the_fleet_home() -> None: + skill = flat((FLEET / "SKILL.md").read_text()) + state = flat((FLEET / "references" / "state.md").read_text()) + assert "All state lives in one fleet home" in skill + assert "`board.mjs status` fails on state written outside it" in skill + for wired in ("fleet-home.mjs resume", "NO-ACTIVE-PROGRAM", "WATCHER-LIVE <pid>", "RELAUNCH <writer> <prompt>", "fleet-home.mjs check", "standing-rulings.md", "fleet-home.mjs migrate"): + assert wired in state, wired + setup = flat((FLEET / "references" / "setup.md").read_text()) + assert "REMOTE_FLEET_HOME" in setup and "LOCAL_STATE_DIR=" not in setup + + +def test_the_2026_10_22_open_lessons_are_wired() -> None: + skill = flat((FLEET / "SKILL.md").read_text()) + assert "scheduled review bots post under teammates' accounts" in skill + assert "Vibe's merge guard refuses `gh pr merge` on another author's pull request" in skill + assert "a Sonnet watcher" in skill and "starts an Opus fixer" in skill + assert "run the full e2e suites on main yourself" in skill + assert "--skip-ruling" in skill + assert "once that PR's ticket, PRD and design rulings show the change was not asked for (a requested change is accepted and cited)" in skill + assert "One review round per PR (operator ruling) holds only in the delivery phase" in skill + assert "--single-review-ruling" in flat((PLUGIN / "skills" / "handoff" / "SKILL.md").read_text()) + assert "only while an operator's single-review ruling is in force" in flat((PLUGIN / "skills" / "vibe" / "SKILL.md").read_text()) + assert "`prs-ignore.txt` or another program's `prs.txt`" in flat((MONITOR / "SKILL.md").read_text()) + hooks = json.loads((PLUGIN / "hooks" / "hooks.json").read_text()) + commands = [hook["command"] for entry in hooks["hooks"]["PreToolUse"] for hook in entry["hooks"]] + assert any("hooks/merge-guard.mjs" in command for command in commands) + + +def test_green_watch_fixes_pass_vibe_gates_and_results_name_their_commit() -> None: + skill = flat((FLEET / "SKILL.md").read_text()) + assert "a vibe writer (Claude `vibe:vibe`, Codex `$vibe`) briefed by `GREEN_WATCH_AGENT`" in skill + assert "`gh pr view <n> --json author,headRefName`, never a worker's word" in skill + assert "e2e-lock.mjs run --head <sha>" in skill + environment = flat((PLUGIN / "agents" / "vibe-environment-worker.md").read_text()) + assert "pr-preflight.mjs --title" in environment + reviewer = flat((PLUGIN / "agents" / "vibe-guardrails-reviewer.md").read_text()) + assert "whose old value `rg` still finds anywhere in the repo" in reviewer + scripts = PLUGIN / "skills" / "vibe" / "scripts" + for name in ("pre-pr-lanes.mjs", "e2e-lock.mjs"): + assert "testedHead" in (scripts / name).read_text(), name + + +def test_new_controls_and_visual_baselines_need_surface_specific_sources() -> None: + writer = flat((PLUGIN / "agents" / "vibe-change-worker.md").read_text()) + reviewer = flat((PLUGIN / "agents" / "vibe-guardrails-reviewer.md").read_text()) + verifier = flat((PLUGIN / "agents" / "vibe-verify-worker.md").read_text()) + handoff = flat((PLUGIN / "skills" / "handoff" / "SKILL.md").read_text()) + fleet = flat((FLEET / "SKILL.md").read_text()) + environment = flat((PLUGIN / "agents" / "vibe-environment-worker.md").read_text()) + assert '"preserve X" in one mode does not add X in another' in writer + assert "Control: <file>: <prop or mount>: <surface>: <ticket AC or ruling>" in writer + assert "it must name that control on that surface" in reviewer + assert '"plausibly intended" is blocking' in reviewer + assert "report every control with no source on that surface" in verifier + assert "rendered-control inventory flags anything with no sourced requirement" in handoff + assert '"plausibly intended" never passes' in fleet + assert "Baseline inventory:" in environment and "--base <inventory.baseCommit>" in environment diff --git a/plugins/vibe/tools/python/test_vibe_codex_budget_contract.py b/plugins/vibe/tools/python/test_vibe_codex_budget_contract.py index 75407258..6db97898 100644 --- a/plugins/vibe/tools/python/test_vibe_codex_budget_contract.py +++ b/plugins/vibe/tools/python/test_vibe_codex_budget_contract.py @@ -92,7 +92,7 @@ def test_handoff_recipe_runs_no_test_suite() -> None: def test_e2e_runs_one_stack_per_machine_and_only_needed_specs() -> None: checks = section(agent("vibe-verify-worker"), "## Existing checks") - assert "node ../skills/vibe/scripts/e2e-lock.mjs run <the e2e command>" in checks + assert "node ../skills/vibe/scripts/e2e-lock.mjs run --head <sha> <the e2e command>" in checks assert "only those specs, never the whole e2e suite" in checks assert "never start the stack around the lock" in checks handoff = flat(HANDOFF_SKILL.read_text())