From d861e416ebe021f45e9c55e54af778437d69eb69 Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Mon, 26 Oct 2026 12:30:30 -0500 Subject: [PATCH 1/2] fix(vibe): acknowledge and watch scope changes - Record mid-run scope changes on the board with the writer's head - Writers read board.mjs pending and ack each change by id - Flag UNACKED-SCOPE and SCOPE-NOT-STARTED; the watcher wakes on them - Preamble: no sleep polls; read pending before commits and checks - Ship vibe 1.0.83 (1.0.82 is the open one-hook-per-event branch) Testing: node --test board and snapshot suites, vibe pytest, ruff Risks: deliver --kind scope refuses a writer row with no --worktree --- CHANGELOG.md | 14 ++ plugins/vibe/.claude-plugin/plugin.json | 2 +- plugins/vibe/.codex-plugin/plugin.json | 2 +- .../vibe/skills/codex-fleet-monitor/SKILL.md | 1 + .../codex-fleet-monitor/scripts/snapshot.sh | 4 + .../scripts/snapshot.test.mjs | 15 ++ .../vibe/skills/fleet-orchestrator/SKILL.md | 10 +- .../fleet-orchestrator/references/judgment.md | 2 +- .../references/templates.md | 5 +- .../fleet-orchestrator/scripts/board.mjs | 138 ++++++++++++++++-- .../fleet-orchestrator/scripts/board.test.mjs | 73 +++++++++ .../python/test_vibe_1074_lessons_contract.py | 2 +- .../python/test_vibe_1081_lessons_contract.py | 5 - ...est_vibe_1083_scope_change_ack_contract.py | 44 ++++++ 14 files changed, 290 insertions(+), 27 deletions(-) create mode 100644 plugins/vibe/tools/python/test_vibe_1083_scope_change_ack_contract.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a21b7dab..1ba445c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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.83 + +A mid-run scope change or answer sent to a Claude writer is now acknowledged by id and watched, so a writer stuck in one long turn is caught within the hour instead of when the operator asks (ISS-13096). + +#### Added +- `board.mjs deliver --kind scope` records a mid-run change to what a writer builds, with its text and the head of the writer's worktree (`writer --worktree`). `status` prints `UNACKED-SCOPE` once a told change waits 15 minutes unacknowledged, and `SCOPE-NOT-STARTED` when, 30 minutes after it was told, the worktree is still clean at that head. +- `board.mjs pending ` lists the writer's unacknowledged scope changes and answered questions (`answered --text` stores the answer), exiting 1 while any remain; `ack` acknowledges a scope change by its id as it does an answer. +- `board.mjs watch-lines`, read by the watcher's snapshot, makes those flags and late questions watcher events, so an armed watcher wakes the orchestrator on each. + +#### Changed +- The worker preamble says messages arrive only when the turn ends, forbids `sleep` polls as well as long foreground waits, and has the writer read its pending list before each commit and each verification phase. It replaces the "check the worker inbox" line, which named no action. +- The fleet skill sends every mid-run scope change through the board and interrupts or relaunches a writer on `UNACKED-SCOPE` or `SCOPE-NOT-STARTED`, since a resend does not reach a writer mid-turn. +- Instruction text is about 2 words shorter than in 1.0.81. + ### vibe v1.0.81 The 2026-10-23 and 2026-10-24 fleet lessons now have hooks, launcher behavior and evidence gates instead of prose alone. diff --git a/plugins/vibe/.claude-plugin/plugin.json b/plugins/vibe/.claude-plugin/plugin.json index 82eff09d..4a77eda8 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.81", + "version": "1.0.83", "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 6d5cb9d6..fae00b8d 100644 --- a/plugins/vibe/.codex-plugin/plugin.json +++ b/plugins/vibe/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "vibe", - "version": "1.0.81", + "version": "1.0.83", "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/skills/codex-fleet-monitor/SKILL.md b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md index 12fab866..da3c3a72 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/SKILL.md +++ b/plugins/vibe/skills/codex-fleet-monitor/SKILL.md @@ -96,6 +96,7 @@ Start it as its own `run_in_background` call, never with `&` (a second copy exit | `codex-usage ... LIMIT-SPENDING-CREDITS` | limit reached and turns are now consuming paid credits | tell the user at once and stop sending turns unless they approve paid credits | | `codex-usage ... unknown` / `unreadable READFAIL` | the App Server answered without rate limits, or it or the remote host could not be read | check `codex` on that machine | | `requests ` / `requests ... UNREADABLE` | a session dropped (or you removed) a delegated-work request file | hand the new request to a Claude worker, then steer its result back to the session that asked | +| `board UNACKED-SCOPE`, `SCOPE-NOT-STARTED`, `UNACKED`, `QUESTION-UNANSWERED` | a fleet-orchestrator `board.json` flag | act as fleet-orchestrator says | | `open-prs ` | open PR list changed | add the new PR to `prs.txt` and start its review | | `pr ... BLOCKED(unresolved threads)` | unresolved review threads (`threads=`) block the merge even when approved with auto-merge armed | route each thread to the owning writer now; it is not a missing approval | | `pr ... STALE-PR(...)` | an open PR with unresolved threads, a conflict, a draft flag or a failed check has had no push for `STALE_PR_SECONDS` (45 minutes) | ask its writer for an ETA, unblock it, or reassign it, however busy the writer looks | diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh index 0e623ff2..26c78a43 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh @@ -53,6 +53,10 @@ if [ -n "${REMOTE_HOST:-}" ]; then out=$(remote_node ratelimit.mjs remote); echo if [ -n "${REQUESTS_DIR:-}" ]; then requests local ls -t $REQUESTS_DIR; fi if [ -n "${REMOTE_REQUESTS_DIR:-}" ]; then requests remote ssh -o ConnectTimeout=10 ${REMOTE_HOST:?set REMOTE_HOST} "ls -t $REMOTE_REQUESTS_DIR"; fi zsh $K/classify.sh $W/item-history.txt $STALL_SECONDS ${QUESTION_SECONDS:-900} < $W/live.txt +# Late questions, unacknowledged answers and scope changes, and a writer whose worktree has not moved since a scope +# change (fleet-orchestrator's board.mjs) are lines too: a pane writer once missed an answer and two scope additions +# for two hours while nothing woke its orchestrator (ISS-13096). +[ -f $W/board.json ] && node $K/../../fleet-orchestrator/scripts/board.mjs $W watch-lines 2>/dev/null # 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. 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 c33dca24..9cdc68fb 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs @@ -203,3 +203,18 @@ test('a PR another chat owns is never added or reported: prs-ignore.txt, or anot assert.doesNotMatch(mine, /^acme\/app 9149$/m, 'the other program owns it'); assert.match(mine, /^acme\/app 9058$/m); }); + +test('board lines are snapshot lines: a scope change the writer has not acknowledged wakes the watcher (ISS-13096)', TIMEOUT, () => { + const f = fixture(); + assert.doesNotMatch(f.run('ok'), /^board /m, 'no board, no board lines'); + const board = fileURLToPath(new URL('../../fleet-orchestrator/scripts/board.mjs', import.meta.url)); + const tree = join(f.root, 'tree'); + mkdirSync(tree); + const boardRun = (...args) => spawnSync(process.execPath, [board, f.work, ...args], { encoding: 'utf8' }); + assert.equal(boardRun('writer', 'w1', '--worktree', tree).status, 0); + assert.equal(boardRun('deliver', 'item-4', '--kind', 'scope', '--to', 'w1', '--text', 'match legacy Archived Projects').status, 0); + assert.equal(boardRun('told', 'item-4', 'w1', '--now', new Date(Date.now() - 20 * 60_000).toISOString()).status, 0); + assert.match(f.run('ok'), /^board UNACKED-SCOPE item-4 w1$/m); + assert.equal(boardRun('ack', 'w1', '--id', 'item-4').status, 0); + assert.doesNotMatch(f.run('ok'), /^board /m); +}); diff --git a/plugins/vibe/skills/fleet-orchestrator/SKILL.md b/plugins/vibe/skills/fleet-orchestrator/SKILL.md index 4b434117..c0ead7b8 100644 --- a/plugins/vibe/skills/fleet-orchestrator/SKILL.md +++ b/plugins/vibe/skills/fleet-orchestrator/SKILL.md @@ -56,7 +56,6 @@ The closedloop-graph MCP server is required; decide from it, not from guesses. T - Before dispatching a ticket, and in every QA-prep brief, run vibe's newest-ruling search ([requirements worker](../../agents/vibe-requirements-worker.md), Newest ruling); on a hit, stop and ask the operator. - In the same step, check every plan linked to the ticket (`list-artifact-links`, PRODUCES) with `node ../handoff/scripts/rulings-check.mjs --plan --plan-body `. Each exclusion it reports is scope the brief tells the writer to build, or you escalate; never one to skip. - Before a shared change, read its callers and importers (`code_callers`, `code_importers`). -- After any change, fix or merge, run every lane the repo's lane selector names for the diff, never a hand-picked subset, then rerun only failures. - Trace a surprising difference in a before and after comparison to the commit and ticket that introduced it before blaming the PR. - Attribute a file's origin or a first failure from local history only through `scripts/history-origin.mjs --repo --path `. @@ -70,10 +69,10 @@ The closedloop-graph MCP server is required; decide from it, not from guesses. T Run [codex-fleet-monitor](../codex-fleet-monitor/SKILL.md) (`vibe:codex-fleet-monitor`) on the same `` in the background for the whole program and act on its lines as its table says. It reads the session state files `fleet.sh` writes, listed as `local|remote ` in `sessions.txt`. -- The watcher resumes a session after a transient provider failure on its own; read its `recovery` lines when you wake. Run its supervisor on every machine that hosts Codex writers. Set `AUTO_RESTART_DAEMON=1` only when the operator agrees. +- Run the watcher's supervisor on every machine that hosts Codex writers. Set `AUTO_RESTART_DAEMON=1` only when the operator agrees. - Re-arm the watcher in the same step you read each event. Before ending any turn that handled an event, `node ../codex-fleet-monitor/scripts/watcher.mjs ` must print `watcher ok`; on `MISSING` (`board.mjs status` prints `WATCHER-MISSING`), re-arm it, never with `&`. - The armed watcher is your heartbeat (`HEARTBEAT` every 30 quiet minutes); on every wake read every teammate message first. While the operator is away, also keep a recurring schedule (loop or cron, 20 to 30 minutes) that does the same and checks `watcher.mjs`. Recovery resumes an idle Codex writer on its default once its question's deadline passes. -- Answer a writer's question within 15 minutes, echoing its id (`board.mjs answered --id `); the watcher (Codex) and `board.mjs status` (Claude writers) print `QUESTION-UNANSWERED` for late questions and `UNACKED` for answers not acknowledged within 15 minutes. +- Answer a writer's question within 15 minutes, echoing its id (`board.mjs answered --id --text `); send other mid-run changes to what it builds as `deliver --kind scope --to --text `, a message naming the id, then `told`. The watcher and `board.mjs status` flag `QUESTION-UNANSWERED`, `UNACKED` and `UNACKED-SCOPE` after 15 minutes, and `SCOPE-NOT-STARTED` for a worktree still clean at its old head 30 minutes on. - On `STALE-PR` (a PR needing its writer with no push for 45 minutes), ask the owning writer for an ETA, unblock it, or reassign it, whatever it reports being busy with. - Push for a writer only the head it declared push-ready (`--ready ` on its board row): `board.mjs push-check --head ` must print `READY` first; otherwise ask the writer and wait. - Before you or a reviewer inspect a head, check it equals the pushed PR head (`gh pr view --json headRefOid`). @@ -99,7 +98,7 @@ scripts/fleet.sh local|remote session last [--count ] - Launch each Claude writer with the [worker preamble](./references/templates.md#worker-preamble) saved to `prompts/`. - Hold an agent with TaskStop, never a message; check nothing it started still runs. - Batch asks to a writer at milestones; side work goes to read-only workers. -- A pane teammate sees messages only when its turn ends, so writers keep turns short (vibe's foreground guard refuses a foreground wait over 5 minutes in a vibe worktree). On `UNACKED`, or an idle worker that ignored a message, resend once, then interrupt after the second unanswered delivery: ask the operator to press Esc in the named pane. Keep long briefs in a file and send its path. +- A pane teammate sees messages only when its turn ends, so writers keep turns short. 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. A resend never reaches a writer mid-turn: on `UNACKED-SCOPE` or `SCOPE-NOT-STARTED` interrupt at once, or TaskStop and relaunch it with the change in its prompt. Keep long briefs in a file and send its path. - A standing duty (main's post-merge health, a PR shepherd, the watcher) always has a live owner: `board.mjs duty --owner `. Name the replacement in the same step you stop or move its owner; `helpers.mjs stopped` refuses an owner, and `board.mjs status` prints `UNOWNED-DUTY`. - Stop a helper (reviewer, verifier, QA prep, poller) once you accept its result, recorded with `scripts/helpers.mjs start|accepted|stopped `; run `helpers.mjs status` with every monitor event (`STOP`, `ORPHAN`). A helper records each process it starts with `helpers.mjs pid ` (`--group` only for one it spawned detached), and `stopped` ends them. @@ -108,8 +107,7 @@ scripts/fleet.sh local|remote session last [--count ] `scripts/board.mjs` keeps `/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, 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 ` 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 writer --runtime codex|claude --pr --repo --head --status --deps `) and its PR's ` ` 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. +- When you launch a writer, add its row (`board.mjs writer --runtime codex|claude --worktree --pr --repo --head --status --deps `) and its PR's ` ` line to `prs.txt`. Update the row on every push report. - 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 deliver --kind ruling --reference --to `. Every QA plan for that behavior cites it; one shared component beats each PR building its own. - When QA prep marks a human checkpoint ready, or rebinds results, `deliver --kind checkpoint --head --to ` and message the guide yourself with the checkpoint, its launch steps and the head. - After each message, `board.mjs told `. Run `board.mjs status` with every monitor event and act on each line: `STALE` (the board misses a pushed head), `UNTOLD`, `UNOWNED-DUTY`. A `SLOW` row is a Codex writer 30 minutes without a push on a PR with unresolved human threads: have it push and hand the branch to a Claude writer. diff --git a/plugins/vibe/skills/fleet-orchestrator/references/judgment.md b/plugins/vibe/skills/fleet-orchestrator/references/judgment.md index c7a49e73..e3d6a17b 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/judgment.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/judgment.md @@ -7,4 +7,4 @@ Habits that kept a multi-PR program on track. They apply the two principles in S - One owner per behavior across PRs. When two PRs implement the same rule differently, rule once, put it in the owning PR and make the other inherit it. When two PRs create the same file, name which merges first and tell the second to fold into it. Being on main is not evidence of being the final design: ask the owner which side is scaffold before recommending which side to drop. - Ask "what failed, and what exactly changed" of every test or fixture change before it is pushed. Setup fixes that use the repo's documented helpers pass; weakened assertions do not. - Check parity at several widths and in both flag states. A single desktop width hid regressions more than once. -- When you accept a worker's alternative to your instruction, tell that worker at once that its version stands and your instruction is withdrawn; a late-arriving instruction otherwise makes it redo the work. +- When you accept a worker's alternative to your instruction, tell that worker at once, as a scope change, that its version stands and your instruction is withdrawn; a late-arriving instruction otherwise makes it redo the work. diff --git a/plugins/vibe/skills/fleet-orchestrator/references/templates.md b/plugins/vibe/skills/fleet-orchestrator/references/templates.md index e174c3ea..1c14f579 100644 --- a/plugins/vibe/skills/fleet-orchestrator/references/templates.md +++ b/plugins/vibe/skills/fleet-orchestrator/references/templates.md @@ -31,8 +31,9 @@ Inherited scope: trace every exclusion, deferral, "obsolete" or narrowed criteri Before editing, read the repo docs that own the paths you touch (AGENTS.md files or contributor guide) or the team knowledge base. Tests: after any change, fix or merge (a conflict resolution included), run every lane the repo's lane selector names for the diff, never a hand-picked subset, then rerun only failures. Heavy checks go through ; never run windowed e2e on the operator's daily machine. -Commands expected to take more than a few minutes run in the background (never a foreground wait over 5 minutes), and you end the turn while they run, so messages reach you. Check the worker inbox between steps so orchestration rulings are applied before more work. -Questions to the orchestrator end with `If no answer by : ` and an id, recorded with `board.mjs question --id --text `; acknowledge each answer by its id (`board.mjs ack --id `) in your next status; keep working meanwhile and past it proceed on the default (never for a flag, copy, destructive, credential or scope question). A question never holds your PR: ship what is done, arm auto-merge, put the answer-dependent part in a follow-up. +Messages reach you only when your turn ends: commands expected to take more than a few minutes run in the background, never a foreground wait or a `sleep` poll, and you end the turn while they run. +Before each commit and each verification phase, run `board.mjs pending `; apply and acknowledge (`ack --id `) each change and answer it lists, naming what you applied in your next status. +Questions to the orchestrator end with `If no answer by : ` and an id, recorded with `board.mjs question --id --text `; 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. Credentials: after the first authentication failure, use CI logs or the API for the same fact, or stop and ask. Never write, export or route around credentials. Git: commit messages per repo convention; regular push only, never force-push. This covers every remote. Proof and scratch commits use a fresh `proof/-` ref; never amend after push. Review threads: reply on the thread naming the fixing SHA and resolve it only after the fix is verified in code. diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs index 2d53ebff..87e4b00a 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs @@ -4,13 +4,15 @@ // a QA guide never heard that its checkpoint was ready, and a UX ruling // reached one writer as text while another PR rendered the same behavior. // -// Usage: board.mjs writer [--pr ] [--repo ] [--head ] [--ready ] [--status ] [--deps ] [--runtime codex|claude] +// Usage: board.mjs writer [--pr ] [--repo ] [--head ] [--ready ] [--status ] [--deps ] [--runtime codex|claude] [--worktree ] // board.mjs push-check --head -// board.mjs deliver --to [--kind ruling|checkpoint|note] [--reference ] [--head ] +// board.mjs deliver --to [--kind ruling|checkpoint|note|scope] [--reference ] [--text ] [--head ] // board.mjs told // board.mjs duty --owner -// board.mjs question|answered|ack --id [--text ] +// board.mjs question|answered|ack --id [--text ] +// board.mjs pending // board.mjs status [--pushed =]... [--threads =]... +// board.mjs watch-lines // // `writer` adds or updates a writer's row (owner, PR, head, status, // dependencies). `deliver` records something each recipient must be told @@ -50,6 +52,19 @@ // 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. +// A mid-run scope change or answer to a writer is a `scope` delivery (`--text` +// or `--reference`) to board writers with a `--worktree`; it records each +// worktree's head. The writer reads `pending ` (its scope changes and +// answers not yet acknowledged, exit 1 when any) before each commit and each +// verification phase, and acknowledges each by id (`ack`). `status` prints +// `UNACKED-SCOPE m` once a told change waited 15 minutes +// unacknowledged, and `SCOPE-NOT-STARTED m` once, 30 minutes +// after it was told, the worktree still sits clean at the recorded head. +// `watch-lines` prints those and the late-question lines without minutes, for +// codex-fleet-monitor's snapshot, so the watcher wakes on each. A Claude pane +// writer ran one turn for over two hours (ISS-13096, 2026-10-26) and never +// received an answer and two scope additions; it committed and verified the old +// scope while every send reported success. // 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()` when the @@ -69,11 +84,12 @@ import { readThreads } from "../../vibe/scripts/pr-threads.mjs"; const STATE = "board.json"; const PAGE = "board.md"; -const KINDS = new Set(["ruling", "checkpoint", "note"]); +const KINDS = new Set(["ruling", "checkpoint", "note", "scope"]); const LOG_LINES = 50; const RUNTIMES = new Set(["codex", "claude"]); const SLOW_MINUTES = 30; const QUESTION_MINUTES = 15; +const SCOPE_START_MINUTES = 30; const BOTS = new RegExp(process.env.BOT_LOGIN_REGEX || "vercel|codex|github-actions|copilot|bot", "i"); /** Rows whose recorded head differs from the pushed head; a row with no known pushed head is not judged. */ @@ -112,6 +128,48 @@ export function lateQuestions(board, now) { }; } +/** A worktree's HEAD and whether it has no changes (untracked files count), or null when git cannot read it. */ +export function worktreeState(worktree) { + try { + const git = (args) => execFileSync("git", ["-C", worktree, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 }); + return { head: git(["rev-parse", "HEAD"]).trim(), clean: git(["status", "--porcelain"]).trim() === "" }; + } catch { + return null; + } +} + +/** Told scope changes unacknowledged for QUESTION_MINUTES, and those whose writer's worktree sat clean at the recorded head for SCOPE_START_MINUTES. */ +export function scopeProblems(board, now, readState = worktreeState) { + const unacked = []; + const notStarted = []; + for (const [item, delivery] of Object.entries(board.deliveries)) { + if (delivery.kind !== "scope") continue; + for (const writer of delivery.to) { + const toldAt = delivery.told[writer]; + if (!toldAt) continue; + const minutes = Math.floor((now - Date.parse(toldAt)) / 60_000); + if (!delivery.acked?.[writer] && minutes >= QUESTION_MINUTES) unacked.push({ item, writer, minutes }); + const base = delivery.base?.[writer]; + const worktree = board.writers[writer]?.worktree; + if (!base || !worktree || minutes < SCOPE_START_MINUTES) continue; + const current = readState(worktree); + if (current?.clean && current.head === base) notStarted.push({ item, writer, minutes, head: base }); + } + } + return { unacked, notStarted }; +} + +/** The scope changes and answers a writer has not acknowledged yet. */ +export function pendingFor(board, writer) { + const changes = Object.entries(board.deliveries) + .filter(([, delivery]) => delivery.kind === "scope" && delivery.to.includes(writer) && !delivery.acked?.[writer]) + .map(([item, delivery]) => ({ kind: "SCOPE", id: item, text: delivery.text ?? delivery.reference })); + const answers = Object.values(board.questions) + .filter((entry) => entry.writer === writer && entry.answeredAt) + .map((entry) => ({ kind: "ANSWERED", id: entry.id, text: entry.answer ?? `see the message answering: ${entry.text}` })); + return [...changes, ...answers]; +} + /** Required duties with no owner recorded. */ export function missingDuties(board, required) { return required.filter((duty) => !board.duties[duty]); @@ -136,6 +194,8 @@ export function render(board) { const open = untold(board).map(({ item, recipient, delivery }) => `- ${item} (${delivery.kind}) for ${recipient}${delivery.reference ? `: ${delivery.reference}` : ""}${delivery.head ? ` at ${delivery.head}` : ""}`); const duties = Object.entries(board.duties).map(([duty, { owner, at }]) => `- ${duty}: ${owner} (since ${at})`); + const scope = Object.keys(board.writers).flatMap((writer) => pendingFor(board, writer).filter((entry) => entry.kind === "SCOPE") + .map((entry) => `- ${entry.id} for ${writer}: ${entry.text}`)); return [ "# Fleet board", "", @@ -151,6 +211,10 @@ export function render(board) { "", ...(open.length > 0 ? open : ["- nothing"]), "", + "## Scope changes not acknowledged", + "", + ...(scope.length > 0 ? scope : ["- none"]), + "", "## Log", "", ...board.log.slice(-LOG_LINES).map((line) => `- ${line}`), @@ -230,6 +294,27 @@ function pushedHeads(board, texts) { return pushed; } +const ACTIONS = ["writer", "deliver", "told", "duty", "question", "answered", "ack", "pending", "push-check", "status", "watch-lines"]; + +/** A scope delivery's acknowledgements and each recipient's worktree head when it was sent; refuses a recipient status cannot watch. */ +function scopeBase(board, to, values, previous) { + if (!values.text?.trim() && !values.reference && !previous?.text && !previous?.reference) { + throw new Error("a scope change carries its content: pass --text or --reference , so the writer's pending list shows it."); + } + const base = { ...previous?.base }; + for (const writer of to) { + const worktree = board.writers[writer]?.worktree; + if (!worktree) { + throw new Error(`a scope change goes to a board writer with a worktree (board.mjs writer ${writer} --worktree ), so status can see whether it started.`); + } + if (base[writer]) continue; + const state = worktreeState(worktree); + if (state) base[writer] = state.head; + else process.stdout.write(`NO-HEAD ${writer}: git cannot read ${worktree}; only the acknowledgement is checked\n`); + } + return { base, acked: previous?.acked ?? {} }; +} + function main(argv) { const [workDir, action, ...rest] = argv; const { values, positionals } = parseArgs({ @@ -240,15 +325,25 @@ function main(argv) { to: { type: "string" }, kind: { type: "string" }, reference: { type: "string" }, pushed: { type: "string", multiple: true }, threads: { type: "string", multiple: true }, now: { type: "string" }, runtime: { type: "string" }, owner: { type: "string" }, text: { type: "string" }, ready: { type: "string" }, id: { type: "string" }, + worktree: { type: "string" }, }, }); - if (!workDir || !["writer", "deliver", "told", "duty", "question", "answered", "ack", "push-check", "status"].includes(action)) { - throw new Error("Usage: board.mjs writer|deliver|told|duty|question|answered|ack|push-check|status ..."); + if (!workDir || !ACTIONS.includes(action)) { + throw new Error(`Usage: board.mjs ${ACTIONS.join("|")} ...`); } 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(); + if (action === "watch-lines") { + const { unanswered, unacked } = lateQuestions(board, now); + const scope = scopeProblems(board, now); + for (const entry of unanswered) process.stdout.write(`board QUESTION-UNANSWERED ${entry.writer} ${entry.id}\n`); + for (const entry of unacked) process.stdout.write(`board UNACKED ${entry.writer} ${entry.id}\n`); + for (const entry of scope.unacked) process.stdout.write(`board UNACKED-SCOPE ${entry.item} ${entry.writer}\n`); + for (const entry of scope.notStarted) process.stdout.write(`board SCOPE-NOT-STARTED ${entry.item} ${entry.writer}\n`); + return 0; + } if (action === "status") { const stale = staleRows(board, pushedHeads(board, values.pushed ?? [])); for (const row of stale) process.stdout.write(`STALE ${row.name} board head ${row.head}, pushed ${row.pushed}\n`); @@ -265,13 +360,20 @@ function main(argv) { const { unanswered, unacked } = lateQuestions(board, now); for (const entry of unanswered) process.stdout.write(`QUESTION-UNANSWERED ${entry.writer} ${entry.id} ${entry.minutes}m: ${entry.text.slice(0, 160)}\n`); for (const entry of unacked) process.stdout.write(`UNACKED ${entry.writer} ${entry.id} ${entry.minutes}m: the writer has not acknowledged your answer; it is likely in a long turn, so escalate\n`); - const asked = [...unanswered, ...unacked]; + const scope = scopeProblems(board, now); + for (const entry of scope.unacked) { + process.stdout.write(`UNACKED-SCOPE ${entry.item} ${entry.writer} ${entry.minutes}m: the writer has not acknowledged this scope change; it is likely in one long turn and has not read it, so interrupt it or stop and relaunch it with the change in its prompt\n`); + } + for (const entry of scope.notStarted) { + process.stdout.write(`SCOPE-NOT-STARTED ${entry.item} ${entry.writer} ${entry.minutes}m: its worktree is clean at ${entry.head.slice(0, 10)}, the head it had when the change was sent; ask what it is doing this minute, or stop and relaunch it\n`); + } + const asked = [...unanswered, ...unacked, ...scope.unacked, ...scope.notStarted]; 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`); 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`); + 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, ${scope.unacked.length} scope unacked, ${scope.notStarted.length} scope not started${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; @@ -290,18 +392,30 @@ function main(argv) { process.stdout.write(`READY ${name} ${values.head}\n`); return 0; } + if (action === "pending") { + const pending = pendingFor(board, name); + for (const entry of pending) process.stdout.write(`${entry.kind} ${entry.id}: ${entry.text}\n`); + process.stdout.write(pending.length > 0 + ? `pending: ${pending.length}; apply each, then acknowledge it with board.mjs ack ${name} --id and say in your next status what you applied\n` + : "pending: none\n"); + return pending.length > 0 ? 1 : 0; + } if (["question", "answered", "ack"].includes(action)) { if (!/^[\w.:-]{1,40}$/.test(values.id ?? "")) { throw new Error(`${action} needs --id ; the answer echoes it and the writer acknowledges it.`); } const key = `${name}/${values.id}`; + const change = board.deliveries[values.id]; if (action === "question") { if (!values.text?.trim()) throw new Error("question needs --text ."); board.questions[key] = { writer: name, id: values.id, text: values.text.trim(), at }; + } else if (action === "ack" && !board.questions[key] && change?.kind === "scope" && change.to.includes(name)) { + change.acked = { ...change.acked, [name]: at }; } else if (!board.questions[key]) { - throw new Error(`no question ${values.id} from ${name} is recorded.`); + throw new Error(`no question or scope change ${values.id} for ${name} is recorded.`); } else if (action === "answered") { board.questions[key].answeredAt = at; + if (values.text?.trim()) board.questions[key].answer = values.text.trim(); } else { delete board.questions[key]; } @@ -321,7 +435,8 @@ function main(argv) { throw new Error("--runtime must be codex or claude."); } const row = board.writers[name] ?? {}; - const update = Object.fromEntries(Object.entries({ pr: values.pr, repo: values.repo, head: values.head, ready: values.ready, status: values.status, runtime: values.runtime }) + const worktree = values.worktree === undefined ? undefined : path.resolve(values.worktree); + const update = Object.fromEntries(Object.entries({ pr: values.pr, repo: values.repo, head: values.head, ready: values.ready, status: values.status, runtime: values.runtime, worktree }) .filter(([, value]) => value !== undefined)); const moved = values.head !== undefined && values.head !== row.head; board.writers[name] = { ...row, ...update, ...(values.deps === undefined ? {} : { deps: list(values.deps) }), ...(moved ? { headAt: at } : {}), updatedAt: at }; @@ -338,12 +453,15 @@ function main(argv) { throw new Error("a ruling is saved once as a reference file (its screenshots beside it); pass --reference ."); } const previous = board.deliveries[name]; + const scope = kind === "scope" ? scopeBase(board, to, values, previous) : {}; board.deliveries[name] = { kind, to: [...new Set([...(previous?.to ?? []), ...to])], told: previous?.told ?? {}, ...(values.reference ? { reference: values.reference } : {}), + ...(values.text?.trim() ? { text: values.text.trim() } : {}), ...(values.head ? { head: values.head } : {}), + ...scope, at, }; write(orchDir, board, `${name} (${kind}) for ${to.join(", ")}`, at); diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs index 36527e35..b7a73b51 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs @@ -221,3 +221,76 @@ test('status fails on state outside the fleet home: a flat watched dir, and a se writeFileSync(path.join(dir, 'sessions', 'prd723-vibe.remote.json'), '{}\n'); assert.equal(run([home, 'status', ...now]).status, 0); }); + +// ISS-13096 (2026-10-26): a Claude pane writer committed items 1 to 3 and sat in long verification runs for over an +// hour; the answer and two scope additions sent at 14:55 and 15:15 never reached it, and its worktree stayed clean +// at that one commit. Every send reported success and nothing flagged it until the operator asked. +const gitRepo = (t) => { + const repo = mkdtempSync(path.join(os.tmpdir(), 'fleet-board-wt-')); + t.after(() => rmSync(repo, { recursive: true, force: true })); + const git = (...args) => spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8' }); + git('init', '-q'); + writeFileSync(path.join(repo, 'filters.ts'), 'export const quickFilters = true;\n'); + git('add', '.'); + git('-c', 'user.name=t', '-c', 'user.email=t@example.com', 'commit', '-qm', 'items 1 to 3'); + return repo; +}; + +test('a scope change the writer never acknowledges is UNACKED-SCOPE, and a worktree still clean at the old head is SCOPE-NOT-STARTED (ISS-13096)', (t) => { + const dir = tempDir(t); + const worktree = gitRepo(t); + assert.equal(run([dir, 'writer', 'fix-13096', '--runtime', 'claude', '--worktree', worktree, '--now', '2026-10-26T14:49:00Z']).status, 0); + assert.equal(run([dir, 'deliver', 'item-5', '--kind', 'scope', '--to', 'fix-13096', '--text', 'restore legacy Project Detail row selection (AC-5)', '--now', '2026-10-26T15:15:00Z']).status, 0); + assert.equal(run([dir, 'told', 'item-5', 'fix-13096', '--now', '2026-10-26T15:15:30Z']).status, 0); + const at = (time) => run([dir, 'status', '--now', time]); + assert.equal(at('2026-10-26T15:29:00Z').status, 0, 'a told change has 15 minutes to be acknowledged'); + const unacked = at('2026-10-26T15:31:00Z'); + assert.equal(unacked.status, 1); + assert.match(unacked.stdout, /^UNACKED-SCOPE item-5 fix-13096 15m: the writer has not acknowledged this scope change/m); + assert.doesNotMatch(unacked.stdout, /SCOPE-NOT-STARTED/, 'thirty minutes pass before an unmoved head counts'); + const incident = at('2026-10-26T16:52:00Z'); + assert.match(incident.stdout, /^UNACKED-SCOPE item-5 fix-13096 96m/m); + assert.match(incident.stdout, /^SCOPE-NOT-STARTED item-5 fix-13096 96m: its worktree is clean at [0-9a-f]{10}, the head it had when the change was sent/m); + assert.match(incident.stdout, /1 scope unacked, 1 scope not started/); + assert.equal( + run([dir, 'watch-lines', '--now', '2026-10-26T16:52:00Z']).stdout, + 'board UNACKED-SCOPE item-5 fix-13096\nboard SCOPE-NOT-STARTED item-5 fix-13096\n', + 'watch lines carry no minutes, so the watcher wakes once when each appears', + ); + assert.equal(run([dir, 'watch-lines', '--now', '2026-10-26T17:30:00Z']).stdout, run([dir, 'watch-lines', '--now', '2026-10-26T16:52:00Z']).stdout); + + const pending = run([dir, 'pending', 'fix-13096']); + assert.equal(pending.status, 1, 'the writer reads its own pending list without waiting for the mailbox'); + assert.match(pending.stdout, /^SCOPE item-5: restore legacy Project Detail row selection \(AC-5\)$/m); + assert.equal(run([dir, 'ack', 'fix-13096', '--id', 'item-5', '--now', '2026-10-26T16:53:00Z']).status, 0); + assert.equal(run([dir, 'pending', 'fix-13096']).status, 0); + const acked = at('2026-10-26T16:54:00Z'); + assert.doesNotMatch(acked.stdout, /UNACKED-SCOPE/); + assert.match(acked.stdout, /^SCOPE-NOT-STARTED item-5 fix-13096/m, 'an acknowledgement alone does not start the work'); + writeFileSync(path.join(worktree, 'selection.ts'), 'export const rowSelection = true;\n'); + assert.equal(at('2026-10-26T16:55:00Z').status, 0, 'a change in the worktree is the work starting'); +}); + +test('a scope change needs content and a writer whose worktree status can read; a pending answer carries its text', (t) => { + const dir = tempDir(t); + assert.equal(run([dir, 'writer', 'w1']).status, 0); + const noTree = run([dir, 'deliver', 'c1', '--kind', 'scope', '--to', 'w1', '--text', 'add item 4']); + assert.equal(noTree.status, 2); + assert.match(noTree.stderr, /board writer with a worktree/); + assert.equal(run([dir, 'writer', 'w1', '--worktree', gitRepo(t)]).status, 0); + const empty = run([dir, 'deliver', 'c1', '--kind', 'scope', '--to', 'w1']); + assert.equal(empty.status, 2); + assert.match(empty.stderr, /carries its content/); + assert.equal(run([dir, 'deliver', 'c1', '--kind', 'scope', '--to', 'w1', '--text', 'add item 4']).status, 0); + assert.match(readFileSync(path.join(dir, 'board.md'), 'utf8'), /^- c1 for w1: add item 4$/m); + assert.equal(run([dir, 'ack', 'w2', '--id', 'c1']).status, 2, 'only a recipient acknowledges a scope change'); + + assert.equal(run([dir, 'question', 'w1', '--id', 'hide-default', '--text', 'Hide completed ON by default? Default: OFF']).status, 0); + assert.equal(run([dir, 'answered', 'w1', '--id', 'hide-default', '--text', 'ON, with legacy persistence']).status, 0); + const pending = run([dir, 'pending', 'w1']); + assert.match(pending.stdout, /^SCOPE c1: add item 4$/m); + assert.match(pending.stdout, /^ANSWERED hide-default: ON, with legacy persistence$/m); + assert.equal(run([dir, 'ack', 'w1', '--id', 'hide-default']).status, 0); + assert.equal(run([dir, 'ack', 'w1', '--id', 'c1']).status, 0); + assert.equal(run([dir, 'pending', 'w1']).stdout, 'pending: none\n'); +}); diff --git a/plugins/vibe/tools/python/test_vibe_1074_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1074_lessons_contract.py index a4d0cbd5..97085c7e 100644 --- a/plugins/vibe/tools/python/test_vibe_1074_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1074_lessons_contract.py @@ -138,7 +138,7 @@ def test_long_commands_do_not_hide_orchestrator_messages() -> None: templates = _text(FLEET / "references" / "templates.md") skill = _text(FLEET / "SKILL.md") assert "more than a few minutes run in the background" in templates - assert "Check the worker inbox between steps" in templates + assert "board.mjs pending " in templates assert "interrupt after the second unanswered delivery" in skill diff --git a/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py b/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py index 9a71fde6..09e67ba5 100644 --- a/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py +++ b/plugins/vibe/tools/python/test_vibe_1081_lessons_contract.py @@ -13,11 +13,6 @@ def read(path: str) -> str: return (ROOT / path).read_text() -def test_release_and_both_manifests_are_1081() -> None: - for manifest in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json"): - assert json.loads(read(manifest))["version"] == "1.0.81" - - def test_targeted_tests_are_the_lanes_test_lanes_names() -> None: verify = read("agents/vibe-verify-worker.md") assert "each named lane by the command\n`pnpm test:lanes` prints" in verify diff --git a/plugins/vibe/tools/python/test_vibe_1083_scope_change_ack_contract.py b/plugins/vibe/tools/python/test_vibe_1083_scope_change_ack_contract.py new file mode 100644 index 00000000..d8278e26 --- /dev/null +++ b/plugins/vibe/tools/python/test_vibe_1083_scope_change_ack_contract.py @@ -0,0 +1,44 @@ +"""Contracts for the vibe 1.0.83 release: mid-run scope changes are acknowledged and watched (ISS-13096).""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +FLEET = ROOT / "skills" / "fleet-orchestrator" +MONITOR = ROOT / "skills" / "codex-fleet-monitor" + + +def read(path: Path) -> str: + return path.read_text() + + +def test_release_and_both_manifests_are_1083() -> None: + for manifest in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json"): + assert json.loads(read(ROOT / manifest))["version"] == "1.0.83" + + +def test_orchestrator_sends_scope_changes_through_the_board_and_acts_on_its_flags() -> None: + skill = read(FLEET / "SKILL.md") + assert "deliver --kind scope --to --text " in skill + assert "answered --id --text " in skill + assert "`SCOPE-NOT-STARTED`" in skill and "`UNACKED-SCOPE`" in skill + assert "--worktree " in skill + + +def test_writers_read_their_pending_list_and_never_wait_inside_a_turn() -> None: + templates = read(FLEET / "references" / "templates.md") + assert "Before each commit and each verification phase, run `board.mjs pending `" in templates + assert "never a foreground wait or a `sleep` poll" in templates + assert "Check the worker inbox" not in templates + + +def test_the_watcher_wakes_on_board_lines() -> None: + snapshot = read(MONITOR / "scripts" / "snapshot.sh") + assert "fleet-orchestrator/scripts/board.mjs $W watch-lines" in snapshot + assert "`board UNACKED-SCOPE`," in read(MONITOR / "SKILL.md") + board = read(FLEET / "scripts" / "board.mjs") + for wired in ('action === "watch-lines"', 'action === "pending"', "scopeProblems(board, now)"): + assert wired in board, wired From 7909d5128c4b87a0638e7a24c5af0fa3fc2f585e Mon Sep 17 00:00:00 2001 From: Daniel Ochoa Date: Mon, 26 Oct 2026 12:41:49 -0500 Subject: [PATCH 2/2] fix(vibe): keep ticket ids out of skill scripts - Name the incident by date in board and snapshot scripts and tests - Use a plain git identity in the board test fixture Testing: node --test generic and board suites Risks: None identified --- plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh | 2 +- .../skills/codex-fleet-monitor/scripts/snapshot.test.mjs | 2 +- plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs | 2 +- .../vibe/skills/fleet-orchestrator/scripts/board.test.mjs | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh index 26c78a43..eac8fbe6 100755 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.sh @@ -55,7 +55,7 @@ if [ -n "${REMOTE_REQUESTS_DIR:-}" ]; then requests remote ssh -o ConnectTimeout zsh $K/classify.sh $W/item-history.txt $STALL_SECONDS ${QUESTION_SECONDS:-900} < $W/live.txt # Late questions, unacknowledged answers and scope changes, and a writer whose worktree has not moved since a scope # change (fleet-orchestrator's board.mjs) are lines too: a pane writer once missed an answer and two scope additions -# for two hours while nothing woke its orchestrator (ISS-13096). +# for two hours while nothing woke its orchestrator (2026-10-26). [ -f $W/board.json ] && node $K/../../fleet-orchestrator/scripts/board.mjs $W watch-lines 2>/dev/null # 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 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 9cdc68fb..c8a9557c 100644 --- a/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs +++ b/plugins/vibe/skills/codex-fleet-monitor/scripts/snapshot.test.mjs @@ -204,7 +204,7 @@ test('a PR another chat owns is never added or reported: prs-ignore.txt, or anot assert.match(mine, /^acme\/app 9058$/m); }); -test('board lines are snapshot lines: a scope change the writer has not acknowledged wakes the watcher (ISS-13096)', TIMEOUT, () => { +test('board lines are snapshot lines: a scope change the writer has not acknowledged wakes the watcher (2026-10-26)', TIMEOUT, () => { const f = fixture(); assert.doesNotMatch(f.run('ok'), /^board /m, 'no board, no board lines'); const board = fileURLToPath(new URL('../../fleet-orchestrator/scripts/board.mjs', import.meta.url)); diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs index 87e4b00a..a0c91c47 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.mjs @@ -62,7 +62,7 @@ // after it was told, the worktree still sits clean at the recorded head. // `watch-lines` prints those and the late-question lines without minutes, for // codex-fleet-monitor's snapshot, so the watcher wakes on each. A Claude pane -// writer ran one turn for over two hours (ISS-13096, 2026-10-26) and never +// writer ran one turn for over two hours (2026-10-26) and never // received an answer and two scope additions; it committed and verified the old // scope while every send reported success. // is a fleet home or a program dir (work-dir.mjs). `status` also prints diff --git a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs index b7a73b51..5e9176f4 100644 --- a/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs +++ b/plugins/vibe/skills/fleet-orchestrator/scripts/board.test.mjs @@ -222,7 +222,7 @@ test('status fails on state outside the fleet home: a flat watched dir, and a se assert.equal(run([home, 'status', ...now]).status, 0); }); -// ISS-13096 (2026-10-26): a Claude pane writer committed items 1 to 3 and sat in long verification runs for over an +// 2026-10-26: a Claude pane writer committed items 1 to 3 and sat in long verification runs for over an // hour; the answer and two scope additions sent at 14:55 and 15:15 never reached it, and its worktree stayed clean // at that one commit. Every send reported success and nothing flagged it until the operator asked. const gitRepo = (t) => { @@ -232,11 +232,11 @@ const gitRepo = (t) => { git('init', '-q'); writeFileSync(path.join(repo, 'filters.ts'), 'export const quickFilters = true;\n'); git('add', '.'); - git('-c', 'user.name=t', '-c', 'user.email=t@example.com', 'commit', '-qm', 'items 1 to 3'); + git('-c', 'user.name=t', '-c', 'user.email=t', 'commit', '-qm', 'items 1 to 3'); return repo; }; -test('a scope change the writer never acknowledges is UNACKED-SCOPE, and a worktree still clean at the old head is SCOPE-NOT-STARTED (ISS-13096)', (t) => { +test('a scope change the writer never acknowledges is UNACKED-SCOPE, and a worktree still clean at the old head is SCOPE-NOT-STARTED (2026-10-26)', (t) => { const dir = tempDir(t); const worktree = gitRepo(t); assert.equal(run([dir, 'writer', 'fix-13096', '--runtime', 'claude', '--worktree', worktree, '--now', '2026-10-26T14:49:00Z']).status, 0);