diff --git a/scripts/pm/close-cards.mjs b/scripts/pm/close-cards.mjs index 6aa13389559..60a9725760b 100644 --- a/scripts/pm/close-cards.mjs +++ b/scripts/pm/close-cards.mjs @@ -3,14 +3,33 @@ /** * close-cards — the three-step card closure (comment · label · close), as ONE - * NAMED command (#19469). + * NAMED command (#19469), and — under the fleet-write relay — as ONE WRITE per + * card (#19824). * * ## Usage — from the REPO ROOT, as one command, with no `cd … &&` in front * - * node scripts/pm/close-cards.mjs --repo owner/name --list cards.txt --comment closing.md --reason not_planned --dry-run - * node scripts/pm/close-cards.mjs --repo owner/name --list cards.txt --comment closing.md --reason not_planned + * node scripts/pm/close-cards.mjs --repo owner/name --plan plan.txt --dry-run + * node scripts/pm/close-cards.mjs --repo owner/name --plan plan.txt * node scripts/pm/close-cards.mjs --self-test # offline, no network at all * + * ## The plan file — one card, one reason, one comment file of its own + * + * One row per card, `N|REASON|COMMENT-FILE` (`#N` or `N`; blank lines allowed; + * a relative COMMENT-FILE resolves against the plan file's own directory): + * + * 19440|not_planned|19440.md + * #19408|completed|19408.md + * + * ⛔ There is no batch-wide comment and no batch-wide reason. The maintainer's + * standing rule is never to post the same comment text on many cards in a + * burst, and each card's closing comment is its own ruling anyway — so the + * plan is REFUSED, before any card is read, when two rows name one comment + * file, or when two comment files carry the same text once whitespace is + * collapsed and every digit run is masked (a card number is not what makes a + * comment a different comment). A card named twice is refused too: two rows + * are two decisions about one card. The retired `--list` / `--comment` / + * `--reason` flags are refused by name, with this spelling. + * * ⛔ The leading `cd /path/to/objectstack && ` a seat habitually types is what * this tool exists to remove. A session's allow rule is a PREFIX match against * the command AS TYPED, so `cd … && node scripts/pm/close-cards.mjs …` matches @@ -42,19 +61,31 @@ * self-tested, and a second copy of either is a second answer to a question * this repo has settled: * - * - the comment goes through `scripts/pm/post-stamped.mjs`. Its write path is - * not exported (`writeArtefact` and `main` are module-private), so it is - * driven as a CHILD PROCESS with its documented flags — `--repo=`, - * `--comment=N`, `--file=`, `--json` — and its exit code is read BEFORE any - * pipe. Its pure half (`renderBody`, `claimKeyedLineRefusals`) IS exported, - * and the pre-flight below imports it so a body that tool would refuse is - * refused ONCE, here, rather than 90 times, one card at a time. - * - the label write goes through `runLabelWrite` from - * `scripts/pm/label-write.mjs`, in-process, with its own `parseOptions` - * building the options — so the four steps, the additive-verb order, the - * `PATCH` fallback and the read-back are the same program the CLI runs. - * - the pm-state vocabulary (`PM_EXCLUSIVE_STATE_LABELS`, `PM_STATE_CLAIM`) - * is imported from `check-half-states.mjs`. ⛔ Never restate a label set. + * - the comment: its stamp and its refusals are post-stamped's exported + * pure half (`renderBody`, `claimKeyedLineRefusals`), run by the pre-flight + * below ONCE per plan row before card one, and again on the act's own + * clock when the card is packed. On the DIRECT transport the write itself + * is `scripts/pm/post-stamped.mjs` driven as a CHILD PROCESS (its write + * path is module-private) with its documented flags — `--repo=`, + * `--comment=N`, `--file=`, `--json` — its exit code read BEFORE any pipe. + * On the RELAY the comment is post-stamped's own `relayAction`, and its + * read-back is post-stamped's `pickRelayComment` + `readBackVerdict`. + * - the labels: on the DIRECT transport the write goes through + * `runLabelWrite` from `scripts/pm/label-write.mjs`, in-process, with its + * own `parseOptions` building the options — the four steps, the + * additive-verb order, the `PATCH` fallback and the read-back are the same + * program the CLI runs. On the RELAY, `runLabelWrite` binds step ③ and + * step ④ into one call (its ③ sends its own dispatch), so the pack reuses + * its exported halves instead: ① is this tool's live card read, ② is + * `computeLabelTarget`, ③ is `relayActions` inside the card's one + * dispatch, ④ is `readBackVerdict` + `verdictIsClean` against the ② + * target after the run — and ⛔ ④ is never skipped for being packed. + * What the pack does NOT borrow is ④'s re-add of a label stripped + * underneath: that is a write, and under the relay a second one; a strip + * is REPORTED as a read-back mismatch (exit 4) and the run stops. + * - the pm-state vocabulary (`PM_EXCLUSIVE_STATE_LABELS`, `PM_STATE_CLAIM`, + * `PM_RESIDUE_LABELS`) is imported from `check-half-states.mjs`. ⛔ Never + * restate a label set. * * ## The skip matrix — the card is RE-READ live first, always * @@ -96,15 +127,17 @@ * whose timeline is still not exhausted at `TIMELINE_PAGE_CAP` pages STOPS the * run rather than deciding on what it managed to read. * - * ## The three writes, in order, and the one state that must never be left + * ## The three steps, in order, and the one state that must never be left * * Per actionable card, in this order: * - * ① comment — post-stamped, read-back its own; - * ② label — label-write `--remove `, four steps, read-back; - * ③ close — `PATCH /repos/{o}/{r}/issues/{n}` with `state: closed` and the - * `state_reason`, then READ THE RESPONSE BACK: a 200 whose body - * does not say `closed` is not a close. + * ① comment — the card's own comment file, stamped on the act's clock; + * ② label — remove the card's pm-state (`--expect-state`) and any other + * label `PM_RESIDUE_LABELS` names that it carries — the claims + * of work in flight a closed card must not keep. Identity + * stickers (`pm:seat`, `pm:epic`) are not residue and stay; + * ③ close — `state: closed` with the row's `state_reason`, then READ IT + * BACK: an answer that does not say `closed` is not a close. * * A card whose ① landed and whose ② or ③ did not is a HALF-WRITE: a closing * comment under a card that is still open and still claims `pm:queue`. The run @@ -114,26 +147,51 @@ * * ## The transport — `OS_FLEET_TRANSPORT` direct | dispatch | auto * - * A cloud seat container cannot write as the fleet directly, so each of the - * three writes takes the fleet-write relay there: the comment through - * `post-stamped.mjs` (the child resolves its own transport from the same - * environment), the label write through `runLabelWrite` (handed THIS run's - * route, so one decision serves both), and the close as ONE dispatch carrying - * `issue_patch` — then the card is READ BACK, which is the same check the - * direct `PATCH` answer gets. `auto` (the default) takes `dispatch` in a cloud - * seat container and `direct` elsewhere, and says which. A relay outcome that - * is not success on the close is a HALF-WRITE (the comment and the label - * landed): the run stops there, exit 4, naming the run. + * ONE route is resolved per run and serves every write in it. `auto` (the + * default) takes `dispatch` in a cloud seat container and `direct` elsewhere, + * and says which. + * + * DISPATCH — ONE card, ONE write. The three steps are packed into ONE + * `repository_dispatch` carrying exactly `[comment, labels_remove, + * issue_patch]`, in that order, and `write-pace.mjs` counts that dispatch as + * the card's one write (the throttle's hourly cap is unchanged; a card costs a + * third of what it did). The relay executor stops at its FIRST failing action, + * so the order alone keeps the invariant the three separate writes kept: no + * label strip and no close without the comment. ⛔ One card per dispatch, + * never two: a failure then only ever stops its own card. The packed payload + * is judged by the relay's own validator (the platform's 64KB `client_payload` + * ceiling, the body cap, the op table) — for every plan row BEFORE card one is + * read, and again for the card itself before anything is sent — so an + * oversized comment is refused with zero writes, never discovered on card 40. + * After the run, step ④ reads the BOARD back — the card (state, reason, + * labels) and the comments since the dispatch — and only a card whose three + * effects all read back, with the labels MATCHING the ② target, counts as + * closed. A run that FAILED is read back the same way to say which steps + * landed. A run that did not appear or did not complete is UNCONFIRMED (exit + * 6), ⛔ never retried and ⛔ never fallen back from — not even under `auto`: + * the pack's first action is a comment, so a direct replay of a dispatch that + * may still run is a second closing comment, written as the seat's own user. + * + * DIRECT — three writes, each by the tool that owns it: post-stamped as a + * child (handed `OS_FLEET_TRANSPORT=direct`, so it cannot pick a route of its + * own), `runLabelWrite` handed THIS run's route, then the `PATCH`. * * ## Exit codes — capture them BEFORE any pipe * - * 0 every non-skipped card landed all three writes (a run of all-skips too). - * 2 usage. Nothing was read and nothing was written. + * 0 every non-skipped card landed all three steps and read them back (a run + * of all-skips too). + * 2 usage, or a REFUSAL before any write — the plan, a comment the + * pre-flight refuses, a packed payload the relay's validator refuses. + * ⛔ ZERO writes on the card it names, and on every card after it. * 3 PREREQUISITE NOT MET — no token, no route, or a read that failed. ⛔ NOT * MEASURED for every card after the one that could not be read. - * 4 HALF-WRITE — a card is on the board in a state nobody asked for. The - * message names the card and the steps that landed. Go look at that card. + * 4 HALF-WRITE or READ-BACK MISMATCH — a card is on the board in a state + * nobody asked for. The message names the card and the steps that + * landed. Go look at that card. * 5 the platform REFUSED a write outright and nothing landed for that card. + * 6 UNCONFIRMED — the card's dispatch was accepted and its run did not + * appear, or did not complete, within the ceiling. Go READ the card and + * the run; ⛔ never re-run blind — a second dispatch is a second write. * * `node scripts/pm/close-cards.mjs … > /tmp/cc.log 2>&1; EXIT=$?; tail -40 /tmp/cc.log` */ @@ -141,27 +199,42 @@ import process from 'node:process'; import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; +import { dirname, isAbsolute, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isEntrypoint } from '../invoked-as.mjs'; import { EXIT_PREREQUISITE_NOT_MET, PM_EXCLUSIVE_STATE_LABELS, + PM_RESIDUE_LABELS, PM_STATE_CLAIM, PROXY_FLAG, proxyRearmPlan, proxyRoute, resolveSweepRepo, } from './check-half-states.mjs'; -import { fallbackText, packRequest, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; +import { EXIT_UNCONFIRMED as DISPATCH_EXIT_UNCONFIRMED, exitForResult, packRequest, resolveRoute, sendFleetWrite, unconfirmedText } from './fleet-write/dispatch.mjs'; +import { CLIENT_PAYLOAD_MAX_BYTES, TRANSPORT_ENV } from './fleet-write/ops.mjs'; import { refusalText as relayRefusalText } from './fleet-write/validate.mjs'; -import { classifyHttp, parseOptions as parseLabelWriteOptions, runLabelWrite } from './label-write.mjs'; +import { + classifyHttp, + computeLabelTarget, + readBackVerdict as labelReadBackVerdict, + parseOptions as parseLabelWriteOptions, + relayActions, + runLabelWrite, + verdictIsClean, +} from './label-write.mjs'; import { isWriteMethod, noteResponse, paceWrite, releaseWriteLease } from './write-pace.mjs'; import { EXIT_NOT_STORED as POST_STAMPED_EXIT_NOT_STORED, + PLATFORM_COMMENT_FOOTER, STAMP_TOKEN, claimKeyedLineRefusals, + readBackVerdict as commentReadBackVerdict, keyedLineRefusalText, + pickRelayComment, + relayAction, renderBody, } from './post-stamped.mjs'; @@ -178,6 +251,8 @@ export const EXIT_USAGE = 2; export const EXIT_PREREQUISITE = EXIT_PREREQUISITE_NOT_MET; export const EXIT_HALF_WRITE = 4; export const EXIT_PLATFORM_REFUSAL = 5; +/** Dispatched, outcome not confirmed — the relay's one shared exit, imported rather than restated. */ +export const EXIT_UNCONFIRMED = DISPATCH_EXIT_UNCONFIRMED; /** The `state_reason` values GitHub accepts on a close. ⛔ Never a free string: a typo closes 90 cards as the wrong kind. */ export const CLOSE_REASONS = Object.freeze(['not_planned', 'completed', 'duplicate']); @@ -185,9 +260,19 @@ export const CLOSE_REASONS = Object.freeze(['not_planned', 'completed', 'duplica /** The annotation that says a summons is outstanding — a card carrying it is never swept. */ export const RETRIAGE_LABEL = 'pm:retriage'; -/** The three writes, in the order they are spent. Named once so the log, the refusal and the self-test read one list. */ +/** The three steps, in the order they are spent. Named once so the log, the refusal and the self-test read one list. */ export const STEPS = Object.freeze(['comment', 'label', 'close']); +/** + * The relay ops ONE card's dispatch carries, in the only order it may carry + * them. The executor stops at its first failure, so this order IS the + * invariant "no strip and no close without the comment". + */ +export const PACK_ORDER = Object.freeze(['comment', 'labels_remove', 'issue_patch']); + +/** The flags the plan file retired — each is refused BY NAME with the spelling that replaced it. */ +export const RETIRED_FLAGS = Object.freeze(['--list', '--comment', '--reason']); + const DEFAULT_EXPECT_STATE = 'pm:queue'; /** @@ -200,46 +285,145 @@ export const TIMELINE_PAGE_CAP = 30; const render = (values) => (values.length ? values.map((v) => `\`${v}\``).join(', ') : 'none'); // --------------------------------------------------------------------------- -// The list file — pure, because a misread list is the whole act pointed at the -// wrong cards. +// The plan file — pure, because a misread plan is the whole act pointed at the +// wrong cards, with the wrong reasons, under the wrong comments. // --------------------------------------------------------------------------- /** - * Card numbers out of a list file: one per line, space-separated, or both, with - * or without the `#`. + * Rows out of a plan file: `N|REASON|COMMENT-FILE` per line, `#N` or `N`, blank + * lines skipped. A relative COMMENT-FILE resolves against `baseDir` — the plan + * file's own directory — so a plan and its comments travel together. * * ⛔ A `#` cannot introduce a comment here — `#19440` IS the ordinary spelling - * of a card, and a parser that dropped those lines would silently close nothing - * and report a clean run. So every token must BE a card number: anything else - * is refused by name rather than skipped. + * of a card, and a parser that dropped those lines would silently close + * nothing and report a clean run. So every non-blank line must BE a row: + * anything else is refused by line number rather than skipped. * - * Duplicates are collapsed (a card cannot be closed twice) and REPORTED, since - * a list carrying one is a list somebody built by hand from two sources. + * ⛔ A card named twice is REFUSED, never collapsed: two rows are two decisions + * (two reasons, two comments) about one card, and picking one is a ruling this + * tool does not get to make. ⛔ Two rows naming one comment file are refused + * too — that is the batch-wide comment this plan replaced, spelled twice. */ -export function parseCardList(raw) { - const tokens = String(raw ?? '') - .split(/\s+/) - .filter((t) => t.length > 0); - if (tokens.length === 0) return { ok: false, error: 'the --list file names no cards at all. ⛔ An empty list is refused, never read as "nothing to do".' }; - const bad = tokens.filter((t) => !/^#?\d+$/.test(t)); - if (bad.length > 0) { +export function parsePlan(raw, { baseDir = process.cwd() } = {}) { + const lines = String(raw ?? '').split(/\r?\n/); + const rows = []; + const errors = []; + lines.forEach((line, i) => { + const text = line.trim(); + if (text.length === 0) return; + const at = `line ${i + 1} (\`${text.length > 80 ? `${text.slice(0, 77)}…` : text}\`)`; + const fields = text.split('|').map((f) => f.trim()); + if (fields.length !== 3) { + errors.push(`${at} has ${fields.length} field(s); a row is exactly \`N|REASON|COMMENT-FILE\`. ⛔ Nothing here is a comment: \`#\` opens a card number.`); + return; + } + const [num, reason, file] = fields; + if (!/^#?\d+$/.test(num) || Number(num.replace(/^#/, '')) <= 0) { + errors.push(`${at}: \`${num}\` is not a card number (\`#19440\` or \`19440\`).`); + return; + } + if (!CLOSE_REASONS.includes(reason)) { + errors.push(`${at}: \`${reason}\` is not a close reason. GitHub takes ${render(CLOSE_REASONS)}, and nothing else.`); + return; + } + if (file.length === 0) { + errors.push(`${at} names no comment file — every card gets a closing comment of its own.`); + return; + } + rows.push({ line: i + 1, issue: Number(num.replace(/^#/, '')), reason, commentFile: isAbsolute(file) ? file : resolve(baseDir, file) }); + }); + if (errors.length > 0) { + return { ok: false, error: `the --plan file carries ${errors.length} line(s) that are not rows:\n${errors.map((e) => ` - ${e}`).join('\n')}` }; + } + if (rows.length === 0) return { ok: false, error: 'the --plan file names no cards at all. ⛔ An empty plan is refused, never read as "nothing to do".' }; + + const twice = (key) => { + const seen = new Map(); + for (const row of rows) seen.set(row[key], [...(seen.get(row[key]) ?? []), row]); + return [...seen.values()].filter((group) => group.length > 1); + }; + const cards = twice('issue'); + if (cards.length > 0) { return { ok: false, error: - `the --list file carries ${bad.length} token(s) that are not card numbers: ${render(bad.slice(0, 5))}` + - `${bad.length > 5 ? ` (and ${bad.length - 5} more)` : ''}. A list is card numbers only — one per line or ` + - 'space-separated, `#19440` or `19440`. ⛔ Nothing here is treated as a comment: `#` opens a card number.', + `the --plan file names ${cards.map((g) => `#${g[0].issue} on lines ${g.map((r) => r.line).join(', ')}`).join('; ')}. ` + + 'Two rows are two decisions about one card — ⛔ refused, never collapsed: keep the row that is the ruling.', }; } - const numbers = []; - const duplicates = []; - for (const token of tokens) { - const n = Number(token.replace(/^#/, '')); - if (!Number.isInteger(n) || n <= 0) return { ok: false, error: `\`${token}\` is not a card number.` }; - if (numbers.includes(n)) duplicates.push(n); - else numbers.push(n); + const files = twice('commentFile'); + if (files.length > 0) { + return { + ok: false, + error: + `the --plan file hands one comment file to more than one card: ${files.map((g) => `\`${g[0].commentFile}\` → ${g.map((r) => `#${r.issue}`).join(', ')}`).join('; ')}. ` + + '⛔ Never the same comment text on many cards in a burst — each card gets a comment file of its own.', + }; + } + return { ok: true, rows }; +} + +/** + * The text a burst comparison reads: whitespace collapsed and every digit run + * masked. A template whose only difference is the card number it names is the + * same comment text posted on many cards — the thing the rule forbids. + */ +export function burstKey(text) { + return String(text ?? '') + .replace(/\d+/g, '0') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Judge every row's comment before card one is read: post-stamped's refusals + * (through `preflightComment`), and the burst rule across rows. Pure. Every + * refusal is collected, so one run names every row to fix rather than the + * first. + * + * @param {{issue:number, commentFile:string, text:string}[]} rows + */ +export function judgePlanComments(rows, nowMs = Date.now()) { + const refused = []; + for (const row of rows ?? []) { + const pre = preflightComment(row.text, nowMs); + if (!pre.ok) refused.push({ issue: row.issue, commentFile: row.commentFile, kind: pre.kind, error: pre.error }); } - return { ok: true, numbers, duplicates }; + const groups = new Map(); + for (const row of rows ?? []) { + const key = burstKey(row.text); + groups.set(key, [...(groups.get(key) ?? []), row.issue]); + } + const repeated = [...groups.values()].filter((issues) => issues.length > 1); + return { ok: refused.length === 0 && repeated.length === 0, refused, repeated }; +} + +/** The refusal `judgePlanComments` produces, as the lines a seat reads. */ +export function planCommentRefusalText({ refused = [], repeated = [] } = {}) { + const lines = [`close-cards: REFUSED — the plan's comments fail the pre-flight on ${refused.length + repeated.length} count(s):`]; + for (const r of refused) lines.push(` - #${r.issue} (${r.commentFile}) [${r.kind}]:`, ...String(r.error).split('\n').map((l) => ` ${l}`)); + for (const issues of repeated) { + lines.push( + ` - ${issues.map((n) => `#${n}`).join(', ')} carry the SAME comment text (identical once whitespace is collapsed and every number masked). ` + + '⛔ The maintainer\'s standing rule: never the same comment text on many cards in a burst — write each card its own.', + ); + } + lines.push('close-cards: ⛔ NOTHING was read and NOTHING was written — every card in the plan has zero writes.'); + return lines.join('\n'); +} + +/** + * The labels ONE card's close strips: its pm-state, plus every label + * `PM_RESIDUE_LABELS` names that it carries — the claims of work in flight + * the closed-card sweep would otherwise have to clear with writes of its own. + * Identity stickers (`pm:seat`, `pm:epic`) are not residue, so they stay. + * Imported, never restated. Pure. + */ +export function stripLabels(card, expectState) { + const labels = labelNamesOf(card?.labels); + const out = [expectState]; + for (const l of labels) if (PM_RESIDUE_LABELS.includes(l) && !out.includes(l)) out.push(l); + return out; } // --------------------------------------------------------------------------- @@ -257,9 +441,7 @@ export function parseCardList(raw) { export function parseCliOptions(argv) { const opts = { repo: null, - list: null, - comment: null, - reason: null, + plan: null, expectState: DEFAULT_EXPECT_STATE, skipPrReferenced: true, dryRun: false, @@ -273,22 +455,23 @@ export function parseCliOptions(argv) { const value = () => (inline === null ? args[++i] : inline); if (flag === '--repo') opts.repo = value(); - else if (flag === '--list') opts.list = value(); - else if (flag === '--comment') opts.comment = value(); - else if (flag === '--reason') opts.reason = value(); + else if (flag === '--plan') opts.plan = value(); else if (flag === '--expect-state') opts.expectState = value(); else if (flag === '--skip-pr-referenced') opts.skipPrReferenced = true; else if (flag === '--no-skip-pr-referenced') opts.skipPrReferenced = false; else if (flag === '--dry-run') opts.dryRun = true; - else return { ok: false, error: `unrecognised option \`${flag}\`` }; + else if (RETIRED_FLAGS.includes(flag)) { + return { + ok: false, + error: + `\`${flag}\` is retired: a batch-wide comment and a batch-wide reason are gone. Pass \`--plan FILE\`, one row per card — ` + + '`N|REASON|COMMENT-FILE` — so every card carries its own reason and its own comment file. ⛔ Never the same comment text ' + + 'on many cards in a burst.', + }; + } else return { ok: false, error: `unrecognised option \`${flag}\`` }; } - if (!opts.list) return { ok: false, error: '--list FILE is required — the card numbers to sweep, one per line or space-separated' }; - if (!opts.comment) return { ok: false, error: '--comment FILE is required — the closing comment posted onto every card this run closes' }; - if (!opts.reason) return { ok: false, error: `--reason is required — one of ${render(CLOSE_REASONS)}` }; - if (!CLOSE_REASONS.includes(opts.reason)) { - return { ok: false, error: `\`${opts.reason}\` is not a close reason. GitHub takes ${render(CLOSE_REASONS)}, and nothing else.` }; - } + if (!opts.plan) return { ok: false, error: '--plan FILE is required — one row per card, `N|REASON|COMMENT-FILE`' }; if (typeof opts.expectState !== 'string' || opts.expectState.length === 0) { return { ok: false, error: '--expect-state takes the pm-state label a card must carry, e.g. `pm:queue`' }; } @@ -310,9 +493,10 @@ export function parseCliOptions(argv) { } const USAGE = [ - 'close-cards — the three-step card closure (comment · label · close) as ONE named command.', + 'close-cards — the three-step card closure (comment · label · close) as ONE named command,', + 'and under the fleet-write relay as ONE dispatch — ONE write — per card.', '', - ' node scripts/pm/close-cards.mjs --repo OWNER/NAME --list FILE --comment FILE --reason REASON', + ' node scripts/pm/close-cards.mjs --repo OWNER/NAME --plan FILE', ' [--expect-state pm:queue] [--no-skip-pr-referenced] [--dry-run]', ' node scripts/pm/close-cards.mjs --self-test', '', @@ -320,26 +504,30 @@ const USAGE = [ ' allow rule is a PREFIX match against the command as typed, so a leading `cd` matches no rule and', ' drops the whole act onto the write classifier, which is the defect this script was written for.', '', - ` --list FILE card numbers, one per line or space-separated, \`#19440\` or \`19440\`.`, - ' --comment FILE the closing comment, posted through post-stamped onto every card this run closes.', - ` It must carry \`${STAMP_TOKEN}\` — the clock each posting act reads.`, - ` --reason R ${CLOSE_REASONS.join(' | ')}.`, + ' --plan FILE one row per card: `N|REASON|COMMENT-FILE` (`#N` or `N`; a relative COMMENT-FILE', + ` resolves against the plan's directory). REASON is ${CLOSE_REASONS.join(' | ')}.`, + ` Each comment file must carry \`${STAMP_TOKEN}\` — the clock each posting act reads.`, + ' ⛔ Never the same comment text on many cards: a shared file, or two files whose text', + ' is identical once whitespace is collapsed and numbers masked, refuses the whole plan.', ' --expect-state L the pm-state label a card must carry, EXACTLY and alone (default `pm:queue`).', ' --no-skip-pr-referenced act on a card an open PR references (the skip is ON by default).', - ' --dry-run re-read every card and print the plan. Writes NOTHING, on any card.', + ' --dry-run re-read every card and print what would be sent — under the relay, each card\'s ONE', + ' payload (comment → labels_remove → issue_patch). Writes NOTHING, on any card.', '', ' Per card, re-read live first, then SKIP + log on: not open · has an assignee · carries', ` \`${RETRIAGE_LABEL}\` · pm-state is not exactly the expected label · an open PR references it.`, - ' Otherwise: post the comment, remove the state label (four-step, read back), close with the reason.', + ' Otherwise: post the comment, strip the pm-state and its residue labels, close with the row\'s reason,', + ' and read all three back — under the relay as ONE dispatch per card, never two cards in one.', '', - ` Exits: ${EXIT_OK} every non-skipped card landed all three writes · ${EXIT_USAGE} usage ·`, - ` ${EXIT_PREREQUISITE} PREREQUISITE NOT MET, nothing measured · ${EXIT_HALF_WRITE} HALF-WRITE, a card is in a state`, - ` nobody asked for — it is NAMED · ${EXIT_PLATFORM_REFUSAL} the platform refused a write. Capture the code BEFORE any pipe.`, + ` Exits: ${EXIT_OK} every non-skipped card landed and read back · ${EXIT_USAGE} usage, or refused before any write ·`, + ` ${EXIT_PREREQUISITE} PREREQUISITE NOT MET, nothing measured · ${EXIT_HALF_WRITE} HALF-WRITE / read-back mismatch, a card is`, + ` in a state nobody asked for — it is NAMED · ${EXIT_PLATFORM_REFUSAL} the platform refused a write, nothing landed ·`, + ` ${EXIT_UNCONFIRMED} UNCONFIRMED relay run, go read it. Capture the code BEFORE any pipe.`, ].join('\n'); // --------------------------------------------------------------------------- -// Pre-flight — the closing comment is judged ONCE, here, by the tool that will -// post it, rather than ninety times one card at a time. +// Pre-flight — every card's closing comment is judged before card one, by the +// tool that owns the stamp, rather than one card at a time mid-run. // --------------------------------------------------------------------------- /** @@ -350,7 +538,7 @@ const USAGE = [ * The one requirement this adds on top: `substituted >= 1`. post-stamped * accepts a body that spells no token at all (`no-token`), which is right for a * one-off artefact and wrong for a batch closure — an unstamped closing comment - * repeated across ninety cards records none of their closing times. + * records no closing time, and a batch is exactly where nobody recovers one. */ export function preflightComment(text, nowMs = Date.now()) { const rendered = renderBody(text, nowMs); @@ -360,8 +548,8 @@ export function preflightComment(text, nowMs = Date.now()) { ok: false, kind: 'unstamped-batch', error: - `close-cards: REFUSED — the --comment file spells no \`${STAMP_TOKEN}\` outside a quotation, so every card ` + - 'in this run would receive a closing comment carrying no closing time. A batch closure is exactly the act ' + + `close-cards: REFUSED — the comment file spells no \`${STAMP_TOKEN}\` outside a quotation, so its card ` + + 'would receive a closing comment carrying no closing time. A batch closure is exactly the act ' + `whose stamp cannot be recovered afterwards. Put \`${STAMP_TOKEN}\` where the closing time belongs.`, }; } @@ -412,9 +600,18 @@ export function openPrReferences(timeline) { * repo's channel table prescribes the page walk for that reason. */ export async function readTimeline(call, base, { pageSize = 100, cap = TIMELINE_PAGE_CAP } = {}) { + return walkPages(call, (page) => `${base}/timeline?per_page=${pageSize}&page=${page}`, { pageSize, cap }); +} + +/** + * The page walk both paged reads share — the three outcomes above (exhausted, + * refused by the transport, NOT FINISHED) and no fourth. `pathFor(page)` is + * the only thing a caller supplies. + */ +export async function walkPages(call, pathFor, { pageSize = 100, cap = TIMELINE_PAGE_CAP } = {}) { const events = []; for (let page = 1; page <= cap; page++) { - const res = await call(`${base}/timeline?per_page=${pageSize}&page=${page}`, {}); + const res = await call(pathFor(page), {}); const verdict = classifyHttp({ status: res.status, op: 'card-read', rateRemaining: res.rateRemaining }); if (verdict !== 'ok') return { ok: false, reason: 'transport', verdict, res, pages: page }; const rows = Array.isArray(res.json) ? res.json : []; @@ -424,6 +621,18 @@ export async function readTimeline(call, base, { pageSize = 100, cap = TIMELINE_ return { ok: false, reason: 'not-exhausted', verdict: 'prerequisite', events, pages: cap }; } +/** + * Every comment on a card updated at or after `sinceMs` — the population the + * relay's comment read-back picks from (REST `since` filters on `updated_at`, + * a superset of the `created_at` window `pickRelayComment` judges). Paged by + * number like the timeline, for the same reason. + */ +export async function readCommentsSince(call, base, sinceMs, opts = {}) { + const since = encodeURIComponent(new Date(sinceMs).toISOString()); + const pageSize = opts.pageSize ?? 100; + return walkPages(call, (page) => `${base}/comments?per_page=${pageSize}&page=${page}&since=${since}`, { pageSize, cap: opts.cap ?? TIMELINE_PAGE_CAP }); +} + /** * Why this card is left alone, or `null` when it is actionable. The order is * the order the order was written in, and the FIRST reason is the one reported: @@ -548,32 +757,81 @@ export function halfWriteText({ issue, repo, landed = [], failedStep, detail = ' } /** - * Sweep the list. `deps` is how `--self-test` drives every branch — including + * ONE card's ONE dispatch: post-stamped's own comment action, label-write's + * `relayActions` for the ② target, and the close — in `PACK_ORDER` and in no + * other shape, judged by the relay's own validator (the op table, the body + * cap, the platform's 64KB `client_payload` ceiling). Pure but for the + * request id. ⛔ One card per payload, never two: the executor stops at its + * first failure, and a second card in the same payload would be stopped by + * the first one's. + */ +export function packCard({ repo, session, issue, body, labels, reason, requestId = null, now = Date.now } = {}) { + const actions = [ + relayAction({ mode: 'comment', number: issue }, body), + ...relayActions({ issue, labels }), + { op: 'issue_patch', issue, state: 'closed', state_reason: reason }, + ]; + const ops = actions.map((a) => a.op); + if (ops.join(',') !== PACK_ORDER.join(',')) { + const why = labels?.addCalls?.length ? 'the label plan ADDS labels, which a close never does' : 'the label plan strips nothing, so the pm-state this run acts on is not on the card'; + return { ok: false, payload: null, actions, errors: [`card #${issue} packs ${render(ops)}; ONE card's dispatch is exactly ${render(PACK_ORDER)}, in that order — ${why}`] }; + } + const packed = packRequest({ repo, session, actions, requestId, now }); + return { ...packed, actions }; +} + +/** + * Which of the three steps the BOARD shows after a run — never what the run + * said. Pure. + * + * comment — a comment created since the dispatch stores the body sent + * (post-stamped's `pickRelayComment`: the platform's footer and + * newline handling tolerated, anything else not); + * label — no label the ② plan removed is still on the card; + * close — the card reads `closed` under the row's reason. + * + * `matches` is step ④'s verdict: all three landed AND label-write's + * `verdictIsClean` over its own `readBackVerdict` of the ② target. + */ +export function landedFromBoard({ card, comments, body, dispatchedAt, labels, reason } = {}) { + const hit = pickRelayComment(comments, body, dispatchedAt); + const backLabels = labelNamesOf(card?.labels); + const labelVerdict = labelReadBackVerdict({ target: labels?.target ?? [], removeCalls: labels?.removeCalls ?? [], readBack: backLabels }); + const closed = card?.state === 'closed' && card?.state_reason === reason; + const landed = STEPS.filter((s) => (s === 'comment' ? hit !== null : s === 'label' ? labelVerdict.survivedRemoval.length === 0 : closed)); + return { hit, backLabels, labelVerdict, closed, landed, matches: landed.length === STEPS.length && verdictIsClean(labelVerdict) }; +} + +/** + * Sweep the plan. `deps` is how `--self-test` drives every branch — including * the ones a live board cannot be made to produce on demand (a comment that - * lands whose label write is then refused). + * lands whose label write is then refused, a relay run that fails half-way). * - * @param {{repo:string, numbers:number[], commentFile:string, reason:string, expectState:string, skipPrReferenced:boolean, dryRun:boolean}} options - * @param {{call?:Function, postComment?:Function, labelWrite?:Function, log?:Function}} [deps] + * @param {{repo:string, rows:{issue:number, reason:string, commentFile:string, text:string}[], expectState:string, skipPrReferenced:boolean, dryRun:boolean}} options + * @param {{call?:Function, postComment?:Function, labelWrite?:Function, send?:Function, route?:object, now?:Function, log?:Function}} [deps] */ export async function runCloseCards(options, deps = {}) { const call = deps.call ?? rest; const emit = deps.log ?? ((line) => console.log(line)); + const now = deps.now ?? (() => Date.now()); const lines = []; const record = (line) => { lines.push(line); emit(line); }; - const { repo, numbers, commentFile, reason, expectState, skipPrReferenced, dryRun } = options; + const { repo, rows, expectState, skipPrReferenced, dryRun } = options; const route = deps.route ?? (await resolveRoute(process.env)); const send = deps.send ?? sendFleetWrite; const postComment = deps.postComment ?? - (async ({ issue }) => { + (async ({ issue, file }) => { const flags = childNodeFlags({ env: process.env, flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG) }); - const argv = [...flags, POST_STAMPED_PATH, `--repo=${repo}`, `--comment=${issue}`, `--file=${commentFile}`, '--json']; - const child = spawnSync(process.execPath, argv, { encoding: 'utf8', env: process.env }); + const argv = [...flags, POST_STAMPED_PATH, `--repo=${repo}`, `--comment=${issue}`, `--file=${file}`, '--json']; + // ⛔ The child is handed THIS run's route. Left to resolve its own, it could take the relay while the label + // write and the close go direct — one card written under two identities. + const child = spawnSync(process.execPath, argv, { encoding: 'utf8', env: { ...process.env, [TRANSPORT_ENV]: 'direct' } }); const exit = typeof child.status === 'number' ? child.status : 1; const doc = parsePostStampedJson(child.stdout); return { @@ -588,49 +846,73 @@ export async function runCloseCards(options, deps = {}) { const labelWrite = deps.labelWrite ?? (async ({ issue, remove }) => { - const parsed = parseLabelWriteOptions(['--repo', repo, '--issue', String(issue), '--remove', remove]); + const parsed = parseLabelWriteOptions(['--repo', repo, '--issue', String(issue), '--remove', remove.join(',')]); if (!parsed.ok) return { exit: EXIT_USAGE, detail: parsed.error }; const res = await runLabelWrite(parsed.options, { log: (line) => record(` ${line}`), route, send: deps.send }); return { exit: res.exit, detail: '' }; }); - const counts = { read: 0, skipped: 0, actionable: 0, closed: 0 }; + const counts = { read: 0, skipped: 0, actionable: 0, closed: 0, dispatched: 0 }; const skips = []; const closed = []; - const result = (exit, extra = {}) => ({ exit, repo, lines, counts, skips, closed, ...extra }); + const payloads = []; + const result = (exit, extra = {}) => ({ exit, repo, lines, counts, skips, closed, payloads, transport: route.transport ?? null, ...extra }); + const stopNote = () => `${counts.closed} card(s) closed so far, ${rows.length - counts.read} not reached — ⛔ NOT MEASURED for those, and nothing was written to them.`; record( - `close-cards: ${dryRun ? 'DRY RUN — nothing will be written. ' : ''}${repo} · ${numbers.length} card(s) · ` + - `reason \`${reason}\` · expect-state \`${expectState}\` · open-PR skip ${skipPrReferenced ? 'ON' : 'OFF'}` + + `close-cards: ${dryRun ? 'DRY RUN — nothing will be written. ' : ''}${repo} · ${rows.length} card(s) · ` + + `reasons ${render([...new Set(rows.map((r) => r.reason))])} · expect-state \`${expectState}\` · open-PR skip ${skipPrReferenced ? 'ON' : 'OFF'}` + `${skipPrReferenced ? '' : ' (⛔ declared off: a card an open PR references will be closed under it)'}`, ); if (route.error) { record(`close-cards: PREREQUISITE NOT MET — ${route.error}`); record('⛔ NOTHING was read and nothing was written.'); - return result(EXIT_PREREQUISITE, { transport: route.transport }); + return result(EXIT_PREREQUISITE); } record(`close-cards: transport ${route.transport} — ${route.reason}`); - for (const issue of numbers) { + // ── the pack's own pre-flight: every row packed ONCE, before card one is read ── + // A payload the relay's validator refuses (the 64KB ceiling, the body cap) is + // refused here with ZERO writes on every card, never discovered on card 40. + if (route.transport === 'dispatch') { + record('close-cards: ONE dispatch per card — comment → labels_remove → issue_patch — so each card is ONE write on the throttle.'); + const refused = []; + for (const row of rows) { + const pre = preflightComment(row.text, now()); + if (!pre.ok) { + refused.push(`#${row.issue} (${row.commentFile}) [${pre.kind}]: ${pre.error}`); + continue; + } + const preview = packCard({ repo, session: route.session, issue: row.issue, body: pre.rendered.body, labels: computeLabelTarget({ current: [expectState], remove: [expectState] }), reason: row.reason, now }); + if (!preview.ok) refused.push(`#${row.issue} (${row.commentFile}): ${preview.errors.join(' · ')}`); + } + if (refused.length > 0) { + record(`close-cards: REFUSED before card one — ${refused.length} row(s) cannot be packed into a dispatch the relay would accept:`); + for (const r of refused) record(` - ${r}`); + record('⛔ NOTHING was read and NOTHING was written — every card in the plan has zero writes. Shrink or fix the comment(s) named above.'); + return result(EXIT_USAGE, { refusedBeforeRun: refused }); + } + } + + for (const row of rows) { + const { issue, reason } = row; const base = `/repos/${repo}/issues/${issue}`; // ① Re-read live. ⛔ Any list snapshot is void — the seat that built the - // list is not the act that closes the card. + // plan is not the act that closes the card. This read is also label-write's + // step ① (取现集) for the pack: the set the ② target is computed from. const read = await call(base, {}); const readVerdict = classifyHttp({ status: read.status, op: 'card-read', rateRemaining: read.rateRemaining }); if (readVerdict !== 'ok') { record(`#${issue} COULD NOT READ — ${read.call} -> HTTP ${read.status}${read.detail ? ` (${read.detail})` : ''}`); - record( - `⛔ STOPPING at #${issue}. ${counts.closed} card(s) closed, ${counts.skipped} skipped, ` + - `${numbers.length - counts.read} not reached. ⛔ NOT MEASURED for those — nothing was written to them.`, - ); + record(`⛔ STOPPING at #${issue}. ${counts.skipped} skipped. ${stopNote()}`); return result(readVerdict === 'refusal' ? EXIT_PLATFORM_REFUSAL : EXIT_PREREQUISITE, { stoppedAt: issue }); } counts.read += 1; const card = read.json ?? {}; // The cheap rows first: the timeline is bought ONLY for a card that would - // otherwise be acted on, so a list of 90 mostly-skipped cards costs 90 + // otherwise be acted on, so a plan of 90 mostly-skipped cards costs 90 // requests rather than 180. let why = skipReason(card, { expectState, openPrs: [] }); if (!why && skipPrReferenced) { @@ -661,25 +943,149 @@ export async function runCloseCards(options, deps = {}) { counts.actionable += 1; + // The comment on THIS act's clock — post-stamped's rules asked again now, + // because a stamp rule reads the clock. A refusal here is ZERO writes. + const pre = preflightComment(row.text, now()); + if (!pre.ok) { + record(`#${issue} comment REFUSED before any write [${pre.kind}]:`); + for (const l of String(pre.error).split('\n')) record(` ${l}`); + record(`⛔ ZERO writes on #${issue}. Stopping — ${stopNote()}`); + return result(EXIT_USAGE, { stoppedAt: issue, landed: [] }); + } + const body = pre.rendered.body; + + // ② label-write's own arithmetic: target = current − (pm-state + residue). + const current = labelNamesOf(card.labels); + const labels = computeLabelTarget({ current, remove: stripLabels(card, expectState) }); + record(`#${issue} ① 取现集 ${render(current)} → ② target ${render(labels.target)} · strip ${render(labels.removeCalls)}`); + + if (route.transport === 'dispatch') { + // ③ ONE card, ONE dispatch, ONE write. + const packed = packCard({ repo, session: route.session, issue, body, labels, reason, now }); + if (!packed.ok) { + record(relayRefusalText(packed.errors)); + record(`⛔ ZERO writes on #${issue} — the payload was refused before it was sent. Stopping — ${stopNote()}`); + return result(EXIT_USAGE, { stoppedAt: issue, landed: [] }); + } + const size = Buffer.byteLength(JSON.stringify(packed.payload), 'utf8'); + record(`#${issue} ③ ONE dispatch, request ${packed.payload.request_id}: ${packed.actions.map((a) => a.op).join(' → ')} (${packed.actions.length} actions · ${size} of ${CLIENT_PAYLOAD_MAX_BYTES} payload bytes)`); + payloads.push(packed.payload); + if (dryRun) { + record(`#${issue} payload ${JSON.stringify(packed.payload)}`); + record(`#${issue} → WOULD CLOSE \`${reason}\` — ⛔ nothing sent`); + continue; + } + + const sentAt = now(); + counts.dispatched += 1; + const sent = await send(packed.payload, { token: TOKEN, log: (line) => record(` ${line}`) }); + const runRef = sent.run?.url ?? sent.run?.id ?? '(no run)'; + if (sent.state === 'refused') { + record(`#${issue} the relay REFUSED the dispatch — HTTP ${sent.status}${sent.detail ? ` (${sent.detail})` : ''}. Nothing ran.`); + record(`⛔ NOTHING was written to #${issue}. Stopping — ${stopNote()}`); + return result(exitForResult(sent), { stoppedAt: issue, landed: [], relay: sent }); + } + if (sent.state === 'no-run' || sent.state === 'timeout') { + record(unconfirmedText(sent, 'close-cards')); + record( + `⛔ #${issue} is UNCONFIRMED. ⛔ Not retried, and ⛔ not fallen back to direct under any ${TRANSPORT_ENV}: the pack's first ` + + `action is a comment, so replaying a dispatch that may still run is a second closing comment. Stopping — ${stopNote()}`, + ); + return result(EXIT_UNCONFIRMED, { stoppedAt: issue, relay: sent }); + } + + // ④ 回读 — the BOARD, after a run that completed either way. + const dispatchedAt = Number.isFinite(sent.dispatchedAt) ? sent.dispatchedAt : sentAt; + const back = await call(base, {}); + const backVerdict = classifyHttp({ status: back.status, op: 'card-read', rateRemaining: back.rateRemaining }); + const tail = backVerdict === 'ok' ? await readCommentsSince(call, base, dispatchedAt - 60_000) : null; + if (backVerdict !== 'ok' || !tail.ok) { + const what = + backVerdict !== 'ok' + ? `${back.call} -> HTTP ${back.status}` + : tail.reason === 'not-exhausted' + ? `the comments since the dispatch are still full pages at the ${tail.pages}-page cap` + : `${tail.res.call} -> HTTP ${tail.res.status}`; + if (sent.ok) { + record( + `#${issue} ④ read-back — COULD NOT READ (${what}). The relay run ${runRef} completed with success, and ⛔ that is NOT a ` + + 'read-back: the three effects are NOT MEASURED. Go READ the card; ⛔ do not re-run blind.', + ); + return result(EXIT_PREREQUISITE, { stoppedAt: issue, relay: sent }); + } + record(halfWriteText({ issue, repo, landed: [], failedStep: 'comment', detail: `relay run FAILED ${runRef}, and the card could not be read back (${what}) — which steps landed is NOT MEASURED; treat it as a half-write until read` })); + return result(EXIT_HALF_WRITE, { stoppedAt: issue, relay: sent }); + } + const board = landedFromBoard({ card: back.json, comments: tail.events, body, dispatchedAt, labels, reason }); + record( + `#${issue} ④ 回读 — ${back.json?.state ?? '?'}${back.json?.state_reason ? `/${back.json.state_reason}` : ''} · ${board.backLabels.length} label(s): ${render(board.backLabels)} · ` + + `comment ${board.hit ? board.hit.id : 'NOT FOUND storing the body sent'}`, + ); + + if (!sent.ok) { + if (board.landed.length === 0) { + record(`#${issue} the relay run FAILED (${runRef}) at its first action, and the board shows NONE of the three steps.`); + record(`⛔ NOTHING landed on #${issue}. Stopping — ${stopNote()}`); + return result(EXIT_PLATFORM_REFUSAL, { stoppedAt: issue, landed: [], relay: sent }); + } + record(halfWriteText({ issue, repo, landed: board.landed, failedStep: STEPS.find((s) => !board.landed.includes(s)), detail: `relay run FAILED ${runRef}; the steps above are what the BOARD shows` })); + return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: board.landed, relay: sent }); + } + + if (board.labelVerdict.concurrentAdds.length) { + record(`#${issue} ④ note — ${render(board.labelVerdict.concurrentAdds)} is on the card and was never in the target. ⛔ NOT a mismatch: another seat's additive write.`); + } + if (!board.matches) { + if (board.landed.length < STEPS.length) { + record(halfWriteText({ issue, repo, landed: board.landed, failedStep: STEPS.find((s) => !board.landed.includes(s)), detail: `the relay run ${runRef} reported success and the BOARD does not show it` })); + } else { + record( + `⛔ READ-BACK MISMATCH on ${repo}#${issue} — labels missing ${render(board.labelVerdict.missing)} (in the ② target, gone from the card: ` + + 'STRIPPED UNDERNEATH inside the run window) · labels that survived removal ' + + `${render(board.labelVerdict.survivedRemoval)}. The comment and the close landed. ⛔ Not re-added here: the re-add is a write, ` + + 'and under the relay a second one. Go READ the card and fix it deliberately.', + ); + } + return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: board.landed, relay: sent }); + } + const said = commentReadBackVerdict({ + stamp: pre.rendered.stamp, + writtenAt: board.hit.created_at, + sent: body, + stored: board.hit.body, + substituted: pre.rendered.substituted, + quoted: pre.rendered.quoted, + verbatim: pre.rendered.verbatim, + verbatimTokens: pre.rendered.verbatimTokens, + }); + for (const l of said.lines) record(` ${l.trim()}`); + record(`#${issue} ④ MATCHES — closed \`${reason}\` · labels ${render(board.backLabels)} · comment ${board.hit.id} (via the relay run ${runRef})`); + counts.closed += 1; + closed.push({ issue, reason, comment: board.hit.id }); + record(`#${issue} → closed ${reason} ${board.hit.id}`); + continue; + } + + // ── DIRECT: three writes, each by the tool that owns it ─────────────── if (dryRun) { - record(`#${issue} → WOULD CLOSE \`${reason}\` (comment · remove \`${expectState}\` · close) — ⛔ nothing written`); + record(`#${issue} → WOULD CLOSE \`${reason}\` — direct: comment · strip ${render(labels.removeCalls)} · close — ⛔ nothing written`); continue; } // ② comment - const posted = await postComment({ repo, issue, file: commentFile }); + const posted = await postComment({ repo, issue, file: row.commentFile }); if (posted.exit === POST_STAMPED_EXIT_NOT_STORED) { record(halfWriteText({ issue, repo, landed: ['comment'], failedStep: 'comment', detail: 'post-stamped exit 4 — WRITTEN BUT NOT STORED as sent' })); return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: ['comment'] }); } if (posted.exit !== 0) { record(`#${issue} comment REFUSED — post-stamped exit ${posted.exit}${posted.detail ? ` — ${posted.detail}` : ''}`); - record(`⛔ NOTHING was written to #${issue}: post-stamped refuses BEFORE the write on every exit but 4. Stopping — ${counts.closed} card(s) closed so far.`); + record(`⛔ NOTHING was written to #${issue}: post-stamped refuses BEFORE the write on every exit but 4. Stopping — ${stopNote()}`); return result(posted.exit === EXIT_PREREQUISITE ? EXIT_PREREQUISITE : EXIT_PLATFORM_REFUSAL, { stoppedAt: issue, landed: [] }); } // ③ label — the four-step write, read back by the tool that owns it. - const wrote = await labelWrite({ repo, issue, remove: expectState }); + const wrote = await labelWrite({ repo, issue, remove: labels.removeCalls }); if (wrote.exit !== 0) { record(halfWriteText({ issue, repo, landed: ['comment'], failedStep: 'label', detail: `label-write exit ${wrote.exit}${wrote.detail ? ` — ${wrote.detail}` : ''}` })); return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: ['comment'] }); @@ -687,38 +1093,8 @@ export async function runCloseCards(options, deps = {}) { // ④ close — and READ THE ANSWER BACK. A 200 whose body does not say // `closed` is not a close, and a close recorded under another reason is a - // wrong record rather than a near miss. Under the relay the close is ONE - // dispatch and the answer read back is the card itself. - let patched = null; - if (route.transport === 'dispatch') { - const packed = packRequest({ repo, session: route.session, actions: [{ op: 'issue_patch', issue, state: 'closed', state_reason: reason }] }); - if (!packed.ok) { - record(relayRefusalText(packed.errors)); - record(halfWriteText({ issue, repo, landed: ['comment', 'label'], failedStep: 'close', detail: 'the relay payload was refused before any dispatch' })); - return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: ['comment', 'label'], transport: route.transport }); - } - record(`#${issue} close (relay) — ONE dispatch, request ${packed.payload.request_id}: issue_patch closed \`${reason}\``); - const sent = await send(packed.payload, { token: TOKEN, log: (line) => record(` ${line}`) }); - if (sent.ok) { - const back = await call(base, {}); - patched = { ...back, call: `${back.call} (read back after relay run ${sent.run?.url ?? sent.run?.id ?? ''})` }; - } else if (route.requested === 'auto' && sent.state === 'no-run') { - record(` ${fallbackText(sent, 'close-cards')}`); - } else { - if (sent.state === 'no-run' || sent.state === 'timeout') record(unconfirmedText(sent, 'close-cards')); - record( - halfWriteText({ - issue, - repo, - landed: ['comment', 'label'], - failedStep: 'close', - detail: `relay ${sent.state}${sent.run?.url ? ` ${sent.run.url}` : ''}${sent.detail ? ` — ${sent.detail}` : ''}`, - }), - ); - return result(EXIT_HALF_WRITE, { stoppedAt: issue, landed: ['comment', 'label'], transport: route.transport, relay: sent }); - } - } - if (patched === null) patched = await call(base, { method: 'PATCH', body: { state: 'closed', state_reason: reason } }); + // wrong record rather than a near miss. + const patched = await call(base, { method: 'PATCH', body: { state: 'closed', state_reason: reason } }); const patchVerdict = classifyHttp({ status: patched.status, op: 'card-patch', rateRemaining: patched.rateRemaining }); if (patchVerdict !== 'ok' || patched.json?.state !== 'closed' || patched.json?.state_reason !== reason) { record( @@ -741,9 +1117,12 @@ export async function runCloseCards(options, deps = {}) { record(`#${issue} → closed ${reason} ${posted.id ?? '(no comment id returned)'}`); } + const byReason = closed.reduce((acc, c) => ({ ...acc, [c.reason]: (acc[c.reason] ?? 0) + 1 }), {}); record( `close-cards: ${dryRun ? 'DRY RUN — nothing was written. ' : ''}${counts.read} read · ` + - `${counts.actionable} actionable · ${counts.skipped} skipped${dryRun ? '' : ` · ${counts.closed} closed \`${reason}\``}`, + `${counts.actionable} actionable · ${counts.skipped} skipped` + + `${dryRun ? '' : ` · ${counts.closed} closed${counts.closed ? ` (${Object.entries(byReason).map(([r, n]) => `${r} ${n}`).join(', ')})` : ''}`}` + + `${route.transport === 'dispatch' ? ` · ${dryRun ? `${payloads.length} payload(s) packed, 0 sent` : `${counts.dispatched} dispatch(es) sent — ${counts.dispatched} write(s) on the throttle`}` : ''}`, ); return result(EXIT_OK); } @@ -752,11 +1131,12 @@ export async function runCloseCards(options, deps = {}) { // --self-test — offline, no network, every branch above driven by a STUBBED // API rather than a model of one. // -// The fake serves the three reads and the one write this tool makes, with the -// semantics that matter: a `PATCH` really mutates the card it answers, so the -// close read-back is a read of what the write did rather than an echo of what -// it asked for. A fake that echoed would pass every assertion below while the -// read-back this tool exists for went untested. +// The fake serves the reads and the one direct write this tool makes, with the +// semantics that matter: a `PATCH` really mutates the card it answers, and the +// fake relay EXECUTES a pack against the same board (in order, stopping where +// told), so every read-back is a read of what the write did rather than an +// echo of what it asked for. A fake that echoed would pass every assertion +// below while the read-back this tool exists for went untested. // // The battery ledger this self-test's floor is evaluated against: `battery()` // opens one, every assertion is attributed to the one most recently opened, and @@ -766,19 +1146,25 @@ export async function runCloseCards(options, deps = {}) { const SELF_TEST_BATTERIES = Object.freeze({ 'the CLI: what a typo must never be allowed to mean': 14, - 'the list file: `#` opens a card number, never a comment': 8, + 'the plan file: one card, one reason, one comment file of its own': 12, + 'the burst rule: the same comment text on many cards is structurally impossible': 6, 'the pre-flight: a closing comment no card should receive': 7, + 'the pre-flight refusal: a comment refused before the pack is ZERO writes on its card': 7, 'the skip matrix: every reason a card is left alone': 14, + 'the skip log: a skipped card gets no payload, and the output names why': 8, 'the open-PR reading: a cross-reference that is a PR, and open': 7, 'the timeline walk: one page is not the timeline': 8, 'the happy path: three writes per card, in order': 10, 'the half-write refusal: stop at the first card left in a state nobody asked for': 12, 'the unreadable card: a verdict taken from nothing is not taken': 7, 'the dry run: a plan that proves it wrote nothing': 7, - 'the sibling tools: driven, never re-implemented': 8, - 'the relay transport: the close as ONE dispatch read back from the card, the label write on the same route, a relay miss is a half-write': 8, + 'the sibling tools: driven, never re-implemented': 10, + 'the relay pack: ONE card, ONE dispatch — comment → labels_remove → issue_patch — ONE write on the throttle': 12, + 'the size ceiling: a payload over 64KB is refused before it is packed, zero writes': 6, + 'the read-back: step ④ reads the BOARD — comment, labels and close — and must MATCH': 9, + 'the relay miss: a refused, failed or unconfirmed run is read back, never retried, never fallen back from': 8, }); -const SELF_TEST_BATTERY_FLOOR = 12; +const SELF_TEST_BATTERY_FLOOR = 18; const UNATTRIBUTED_BATTERY = '(unattributed)'; const batteryCases = new Map(); @@ -788,6 +1174,10 @@ const battery = (name) => { }; let selfTestReachedVerdict = false; +/** The self-test's one clock: every stamp, request id and comment time reads it, so the assertions can spell them. */ +const SELF_TEST_NOW = Date.UTC(2026, 8, 23, 12, 0, 0); +const SELF_TEST_SESSION = 'session_01ABCDEFGHJKMNPQRSTVWXYZ'; + const cardJson = (card) => ({ number: card.number, state: card.state ?? 'open', @@ -796,10 +1186,14 @@ const cardJson = (card) => ({ assignees: (card.assignees ?? []).map((login) => ({ login })), }); -/** A board with the semantics this tool depends on — the `PATCH` mutates, so the read-back reads the write. */ +/** + * A board with the semantics this tool depends on — the `PATCH` mutates, so + * the read-back reads the write; the timeline and the comments are paged for + * real, and the comments honour `since`. + */ export function fakeApi(initial = {}) { const cards = new Map(); - for (const [number, card] of Object.entries(initial.cards ?? {})) cards.set(Number(number), { ...card, number: Number(number) }); + for (const [number, card] of Object.entries(initial.cards ?? {})) cards.set(Number(number), { comments: [], ...card, number: Number(number) }); const calls = []; const hooks = new Map(Object.entries(initial.hooks ?? {}).map(([k, v]) => [k, [...v]])); @@ -813,9 +1207,9 @@ export function fakeApi(initial = {}) { const call = async (path, init = {}) => { const method = init.method ?? 'GET'; - const match = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(\/timeline)?/.exec(path); + const match = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)(\/timeline|\/comments)?/.exec(path); const number = Number(match?.[1]); - const kind = match?.[2] ? 'timeline' : method === 'PATCH' ? 'patch' : 'read'; + const kind = match?.[2] === '/timeline' ? 'timeline' : match?.[2] === '/comments' ? 'comments' : method === 'PATCH' ? 'patch' : 'read'; calls.push({ kind, number, method, path, body: init.body ?? null }); const queued = hooks.get(`${kind}:${number}`) ?? hooks.get(kind); @@ -826,14 +1220,18 @@ export function fakeApi(initial = {}) { const card = cards.get(number); if (!card) return wrap(404, { message: 'Not Found' }); - if (kind === 'timeline') { - // Paged for real: a fake that answered the whole timeline to every - // request would pass the walk's assertions while the truncation the walk - // exists for went untested. + if (kind === 'timeline' || kind === 'comments') { + // Paged for real: a fake that answered the whole list to every request + // would pass the walk's assertions while the truncation the walk exists + // for went untested. const query = new URLSearchParams(path.slice(path.indexOf('?') + 1)); const size = Number(query.get('per_page') ?? 100); const page = Number(query.get('page') ?? 1); - const all = card.timeline ?? []; + let all = kind === 'timeline' ? (card.timeline ?? []) : card.comments; + if (kind === 'comments' && query.get('since')) { + const since = Date.parse(query.get('since')); + all = all.filter((c) => Date.parse(c.updated_at ?? c.created_at) >= since); + } return wrap(200, all.slice((page - 1) * size, page * size)); } if (kind === 'patch') { @@ -849,53 +1247,82 @@ export function fakeApi(initial = {}) { /** Drive the whole run offline: a fake board, a stubbed comment poster and a stubbed label write. */ const DIRECT_ROUTE = Object.freeze({ requested: 'direct', transport: 'direct', reason: 'self-test: direct', error: null, session: null }); +const dispatchRoute = (requested = 'dispatch') => ({ requested, transport: 'dispatch', reason: 'self-test: dispatch', error: null, session: SELF_TEST_SESSION }); -/** A fake relay: `success` applies the `issue_patch` to the fake card the run reads back; the other outcomes answer `sendFleetWrite`'s shapes. */ -function fakeRelay(api, outcome, sent) { +/** + * A fake relay that EXECUTES the pack against the fake board the run reads + * back from, the way the executor would on the real one: in order, stopping + * at `failure@N` with the actions before N applied. The other outcomes: + * `success`, `no-run`, `timeout`, `refused`, `lie` (success, nothing applied), + * `mutate` (the comment stored not as sent), `strip:L` (a concurrent writer + * strips L inside the run window), `add:L` (a concurrent writer adds L). + */ +function fakeRelay(api, outcome, sent, clock) { const RUN = { id: 42, url: 'https://github.test/run/42', status: 'completed', conclusion: 'success' }; + let commentId = 5000; + const apply = (a) => { + const card = api.cards.get(a.issue); + if (!card) return; + const at = new Date(clock()).toISOString(); + if (a.op === 'comment') { + const stored = outcome === 'mutate' ? `X${a.body.slice(1)}` : `${a.body}${PLATFORM_COMMENT_FOOTER}`; + card.comments.push({ id: ++commentId, body: stored, created_at: at, updated_at: at }); + } + if (a.op === 'labels_remove') card.labels = card.labels.filter((l) => !a.labels.includes(l)); + if (a.op === 'issue_patch') { + if ('state' in a) card.state = a.state; + if ('state_reason' in a) card.state_reason = a.state_reason; + } + }; return async (payload) => { sent.push(payload); - const base = { requestId: payload.request_id, startMs: 1, ceilingMs: 2, status: 204, verdict: 'ok', detail: '' }; + const base = { requestId: payload.request_id, startMs: 1, ceilingMs: 2, status: 204, verdict: 'ok', detail: '', dispatchedAt: clock() }; if (outcome === 'no-run') return { ...base, state: 'no-run', ok: false, run: null, detail: 'no run appeared' }; if (outcome === 'timeout') return { ...base, state: 'timeout', ok: false, run: { ...RUN, status: 'in_progress', conclusion: null } }; - if (outcome === 'failure') return { ...base, state: 'failure', ok: false, run: { ...RUN, conclusion: 'failure' }, detail: 'conclusion failure' }; - for (const a of payload.actions) { - const card = api.cards.get(a.issue); - if (card && a.op === 'issue_patch') { - if ('state' in a) card.state = a.state; - if ('state_reason' in a) card.state_reason = a.state_reason; - } - } + if (outcome === 'refused') return { ...base, state: 'refused', ok: false, status: 422, verdict: 'refusal', run: null, detail: 'Unprocessable Entity' }; + const failAt = /^failure@(\d+)$/.exec(outcome ?? ''); + const stop = failAt ? Number(failAt[1]) : outcome === 'lie' ? 0 : payload.actions.length; + payload.actions.slice(0, stop).forEach(apply); + const card = api.cards.get(payload.actions[0]?.issue); + if (card && outcome?.startsWith('strip:')) card.labels = card.labels.filter((l) => l !== outcome.slice('strip:'.length)); + if (card && outcome?.startsWith('add:')) card.labels = [...card.labels, outcome.slice('add:'.length)]; + if (failAt) return { ...base, state: 'failure', ok: false, run: { ...RUN, conclusion: 'failure' }, detail: 'conclusion failure' }; return { ...base, state: 'success', ok: true, run: RUN, detail: 'conclusion success' }; }; } +/** The comment a self-test row carries unless the case names its own — distinct words per card, stamped. */ +const ROW_TEXT = (issue) => `Closed #${issue} ${STAMP_TOKEN} — ruling for this card.`; +const ROW = (issue, extra = {}) => ({ issue, reason: 'not_planned', commentFile: `/tmp/plan/${issue}.md`, text: ROW_TEXT(issue), ...extra }); + async function driveOffline(options, initial = {}, stubs = {}) { const api = fakeApi(initial); const posted = []; const labelled = []; const out = []; const sent = []; + const clock = stubs.now ?? (() => SELF_TEST_NOW); const commentExits = [...(stubs.commentExits ?? [])]; const labelExits = [...(stubs.labelExits ?? [])]; + const { numbers = [], reason = 'not_planned', rows = null, ...rest } = options; const res = await runCloseCards( { repo: 'objectstack-ai/objectstack', - commentFile: '/tmp/closing.md', - reason: 'not_planned', + rows: rows ?? numbers.map((issue) => ROW(issue, { reason })), expectState: 'pm:queue', skipPrReferenced: true, dryRun: false, - ...options, + ...rest, }, { call: api.call, log: (line) => out.push(line), + now: clock, route: stubs.route ?? DIRECT_ROUTE, - send: stubs.relay ? fakeRelay(api, stubs.relay, sent) : undefined, - postComment: async ({ issue }) => { + send: stubs.relay ? fakeRelay(api, stubs.relay, sent, clock) : undefined, + postComment: async ({ issue, file }) => { const exit = commentExits.length ? commentExits.shift() : 0; - posted.push({ issue, exit }); + posted.push({ issue, file, exit }); return { exit, id: 900 + issue, url: null, stored: exit === 0, detail: exit === 0 ? '' : 'stubbed refusal' }; }, labelWrite: async ({ issue, remove }) => { @@ -908,7 +1335,13 @@ async function driveOffline(options, initial = {}, stubs = {}) { return { res, api, posted, labelled, out, sent, text: out.join('\n') }; } -const QUEUED = (number, extra = {}) => ({ [number]: { state: 'open', labels: ['pm:queue', 'tooling'], assignees: [], timeline: [], ...extra } }); +/** An in-memory file tree for `preparePlan`: a missing path throws the way `readFileSync` does. */ +const memoryFiles = (tree) => (path) => { + if (Object.hasOwn(tree, path)) return tree[path]; + throw new Error(`ENOENT: no such file, open '${path}'`); +}; + +const QUEUED = (number, extra = {}) => ({ [number]: { state: 'open', labels: ['pm:queue', 'tooling'], assignees: [], timeline: [], comments: [], ...extra } }); const CROSS_REF = (prNumber, state) => ({ event: 'cross-referenced', source: { type: 'issue', issue: { number: prNumber, state, pull_request: { url: 'x' } } } }); export async function selfTest() { @@ -920,7 +1353,7 @@ export async function selfTest() { cases.push({ name, ok, detail: ok ? '' : `${detail ? `${detail} — ` : ''}got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}` }); }; const REPO = ['--repo', 'objectstack-ai/objectstack']; - const MIN = [...REPO, '--list', 'l.txt', '--comment', 'c.md', '--reason', 'not_planned']; + const MIN = [...REPO, '--plan', 'plan.txt']; // ── the CLI ─────────────────────────────────────────────────────────────── battery('the CLI: what a typo must never be allowed to mean'); @@ -930,25 +1363,46 @@ export async function selfTest() { t('…and a dry run is OFF by default', parseCliOptions(MIN).options?.dryRun, false); t('`--no-skip-pr-referenced` turns the skip off', parseCliOptions([...MIN, '--no-skip-pr-referenced']).options?.skipPrReferenced, false); t('`--skip-pr-referenced` turns it back on', parseCliOptions([...MIN, '--no-skip-pr-referenced', '--skip-pr-referenced']).options?.skipPrReferenced, true); - t('the `--flag=value` spelling is the same option', parseCliOptions([...REPO, '--list=l.txt', '--comment=c.md', '--reason=completed']).options?.reason, 'completed'); - t('⛔ a missing --list is refused, never defaulted', parseCliOptions([...REPO, '--comment', 'c.md', '--reason', 'not_planned']).ok, false); - t('⛔ a missing --comment is refused', parseCliOptions([...REPO, '--list', 'l.txt', '--reason', 'not_planned']).ok, false); - t('⛔ a missing --reason is refused — a close with no reason is a record with none', parseCliOptions([...REPO, '--list', 'l.txt', '--comment', 'c.md']).ok, false); - t('⛔ a reason GitHub does not take is refused by name', parseCliOptions([...REPO, '--list', 'l.txt', '--comment', 'c.md', '--reason', 'wontfix']).ok, false); + t('the `--flag=value` spelling is the same option', parseCliOptions([...REPO, '--plan=p.txt']).options?.plan, 'p.txt'); + t('⛔ a missing --plan is refused, never defaulted', parseCliOptions(REPO).ok, false); + for (const flag of RETIRED_FLAGS) { + const r = parseCliOptions([...MIN, flag, 'x']); + t(`⛔ the retired \`${flag}\` is refused BY NAME, pointing at --plan — never read as a batch-wide value`, !r.ok && (r.error ?? '').includes(flag) && (r.error ?? '').includes('--plan')); + } + t('…and the refusal says why: never the same comment text on many cards', /same comment text/.test(parseCliOptions([...MIN, '--comment', 'c.md']).error ?? '')); t('⛔ an --expect-state that is not a pm state is refused, never silently matched against nothing', parseCliOptions([...MIN, '--expect-state', 'pm:queued']).ok, false); t('…and the refusal names the states there are', /pm:dispatched/.test(parseCliOptions([...MIN, '--expect-state', 'pm:queued']).error ?? '')); t('⛔ an unrecognised option is refused rather than ignored', parseCliOptions([...MIN, '--force']).ok, false); - // ── the list file ───────────────────────────────────────────────────────── - battery('the list file: `#` opens a card number, never a comment'); - t('one per line', parseCardList('19440\n19408\n').numbers, [19440, 19408]); - t('space-separated on one line', parseCardList('#19440 #19408 #19404').numbers, [19440, 19408, 19404]); - t('both spellings mixed, with blank lines', parseCardList('#19440\n\n19408 #19404\n').numbers, [19440, 19408, 19404]); - t('⛔ a `#` line is a CARD, not a comment — the whole list is not silently dropped', parseCardList('#19440').numbers, [19440]); - t('⛔ a token that is not a card number is refused by name', parseCardList('19440 not-a-card').ok, false); - t('…and the refusal quotes it', /not-a-card/.test(parseCardList('19440 not-a-card').error ?? '')); - t('⛔ an empty list is refused, never read as "nothing to do"', parseCardList(' \n').ok, false); - t('a duplicate is collapsed and REPORTED', parseCardList('#19440 19440').duplicates, [19440]); + // ── the plan file ───────────────────────────────────────────────────────── + battery('the plan file: one card, one reason, one comment file of its own'); + const P = (raw) => parsePlan(raw, { baseDir: '/plans' }); + t('one row per line, `#N` or `N`, a relative comment file resolved against the plan`s directory', P('19440|not_planned|a.md\n#19408|completed|b.md\n').rows?.map((r) => [r.issue, r.reason, r.commentFile]), [[19440, 'not_planned', '/plans/a.md'], [19408, 'completed', '/plans/b.md']]); + t('blank lines and padding around fields are fine, and the row keeps its line number', P('\n 19440 | duplicate | a.md \n\n').rows?.[0], { line: 2, issue: 19440, reason: 'duplicate', commentFile: '/plans/a.md' }); + t('an absolute comment path is kept as written', P('1|completed|/abs/c.md').rows?.[0].commentFile, '/abs/c.md'); + t('⛔ a `#` line is a ROW, never a comment — a lone card number is refused, not skipped', P('#19440').ok, false); + t('…and the refusal names the line and the row shape', /line 1 .*N\|REASON\|COMMENT-FILE/.test(P('#19440').error ?? '')); + t('⛔ a reason GitHub does not take is refused by name', [P('1|wontfix|a.md').ok, /`wontfix`/.test(P('1|wontfix|a.md').error ?? '')], [false, true]); + t('⛔ a row naming no comment file is refused', P('1|completed|').ok, false); + t('⛔ a token that is not a card number is refused', P('abc|completed|a.md').ok, false); + t('⛔ card 0 is not a card', P('0|completed|a.md').ok, false); + t('⛔ an empty plan is refused, never read as "nothing to do"', P(' \n\n').ok, false); + t('⛔ a card named twice is REFUSED, never collapsed — two rows are two decisions', [P('7|completed|a.md\n7|not_planned|b.md').ok, /#7 on lines 1, 2/.test(P('7|completed|a.md\n7|not_planned|b.md').error ?? '')], [false, true]); + t('⛔ one comment file handed to two cards is refused — the batch-wide comment, spelled twice', [P('1|completed|a.md\n2|completed|./a.md').ok, /#1, #2/.test(P('1|completed|a.md\n2|completed|./a.md').error ?? '')], [false, true]); + t('every bad line is named, not only the first', (P('x|completed|a.md\n1|wontfix|b.md').error ?? '').match(/line \d/g)?.length, 2); + + // ── the burst rule ──────────────────────────────────────────────────────── + battery('the burst rule: the same comment text on many cards is structurally impossible'); + const JNOW = Date.UTC(2026, 8, 23, 12, 0, 0); + const stamped = (s) => `${s} ${STAMP_TOKEN}`; + const rowsOf = (...texts) => texts.map((text, i) => ({ issue: 100 + i, commentFile: `/p/${i}.md`, text })); + t('distinct comments pass', judgePlanComments(rowsOf(stamped('Closed: superseded by the new plan.'), stamped('Closed: the ruling declined this.')), JNOW).ok); + t('⛔ the same text on two cards is refused, naming both', judgePlanComments(rowsOf(stamped('Closed per ruling.'), stamped('Closed per ruling.')), JNOW).repeated, [[100, 101]]); + t('⛔ …even when only whitespace differs', judgePlanComments(rowsOf(stamped('Closed per\nruling.'), stamped('Closed per ruling. ')), JNOW).ok, false); + t('⛔ …and when only a NUMBER differs — a card number is not what makes a comment a different comment', judgePlanComments(rowsOf(stamped('Closing #19440 per ruling.'), stamped('Closing #19441 per ruling.')), JNOW).ok, false); + t('the refusal names the cards, the rule, and that every card has zero writes', ((txt) => /#100, #101/.test(txt) && /never the same comment text/.test(txt) && /zero writes/.test(txt))(planCommentRefusalText(judgePlanComments(rowsOf(stamped('x'), stamped('x')), JNOW)))); + const twinFiles = memoryFiles({ '/p/plan.txt': '1|completed|a.md\n2|completed|b.md\n', '/p/a.md': stamped('Same words.'), '/p/b.md': stamped('Same words.') }); + t('end to end: a plan whose two files carry one text is refused before any request is made', preparePlan('/p/plan.txt', { readFile: twinFiles, nowMs: JNOW }).ok, false); // ── the pre-flight ──────────────────────────────────────────────────────── battery('the pre-flight: a closing comment no card should receive'); @@ -961,6 +1415,22 @@ export async function selfTest() { t('⛔ an opener post-stamped cannot render is refused HERE, once, not ninety times', preflightComment(`Closed {{WHEN}} ${STAMP_TOKEN}`, NOW).ok, false); t('…which proves the pre-flight routes through post-stamped rather than re-deriving it', preflightComment(`Closed {{WHEN}} ${STAMP_TOKEN}`, NOW).kind, 'unknown-token'); + // ── the pre-flight refusal ──────────────────────────────────────────────── + battery('the pre-flight refusal: a comment refused before the pack is ZERO writes on its card'); + { + const tree = memoryFiles({ '/p/plan.txt': '1|completed|a.md\n2|completed|b.md\n', '/p/a.md': `Card one ruling ${STAMP_TOKEN}.`, '/p/b.md': 'Card two ruling, and no stamp anywhere.' }); + const prep = preparePlan('/p/plan.txt', { readFile: tree, nowMs: NOW }); + t('a plan whose second comment carries no stamp is refused before card one is read', prep.ok, false); + t('…naming the card and the rule that refused it', /#2/.test(prep.error ?? '') && /unstamped-batch/.test(prep.error ?? '')); + t('…and saying every card in the plan has zero writes', /zero writes/.test(prep.error ?? '')); + t('a comment file that cannot be read refuses the plan too, naming the card', ((r) => [r.ok, /#2: could not read/.test(r.error ?? '')])(preparePlan('/p/plan.txt', { readFile: memoryFiles({ '/p/plan.txt': '1|completed|a.md\n2|completed|b.md\n', '/p/a.md': `x ${STAMP_TOKEN}` }), nowMs: NOW })), [false, true]); + const relayed = await driveOffline({ rows: [ROW(301, { text: 'No stamp at all.' })] }, { cards: QUEUED(301) }, { route: dispatchRoute(), relay: 'success' }); + t('under the relay the same refusal sends NO dispatch and reads no card — exit 2', [relayed.res.exit, relayed.sent.length, relayed.api.calls.length], [EXIT_USAGE, 0, 0]); + t('…and the card is untouched: open, its labels, no comment', [relayed.api.cards.get(301).state, relayed.api.cards.get(301).labels, relayed.api.cards.get(301).comments.length], ['open', ['pm:queue', 'tooling'], 0]); + const direct = await driveOffline({ rows: [ROW(302, { text: 'No stamp at all.' })] }, { cards: QUEUED(302) }); + t('on the direct transport it is ZERO writes as well: no comment, no label write, no PATCH', [direct.res.exit, direct.posted.length, direct.labelled.length, direct.api.calls.some((c) => c.kind === 'patch')], [EXIT_USAGE, 0, 0, false]); + } + // ── the skip matrix ─────────────────────────────────────────────────────── battery('the skip matrix: every reason a card is left alone'); const EXPECT = { expectState: 'pm:queue' }; @@ -979,6 +1449,32 @@ export async function selfTest() { t('the FIRST reason wins — a closed, assigned card reads as closed', /not open/.test(skipReason(cardJson({ number: 1, state: 'closed', labels: ['pm:queue'], assignees: ['x'] }), EXPECT) ?? '')); t('a different --expect-state moves the whole matrix with it', skipReason(cardJson({ number: 1, labels: ['pm:on-hold'] }), { expectState: 'pm:on-hold' }), null); + // ── the skip log ────────────────────────────────────────────────────────── + battery('the skip log: a skipped card gets no payload, and the output names why'); + { + const matrix = await driveOffline( + { numbers: [501, 502, 503, 504, 505] }, + { + cards: { + ...QUEUED(501, { assignees: ['os-zhuang'] }), + ...QUEUED(502, { labels: ['pm:queue', 'pm:retriage'] }), + ...QUEUED(503, { labels: ['pm:dispatched'] }), + ...QUEUED(504, { timeline: [CROSS_REF(9001, 'open')] }), + ...QUEUED(505), + }, + }, + { route: dispatchRoute(), relay: 'success' }, + ); + t('four skips and one close, exit 0', [matrix.res.exit, matrix.res.counts.skipped, matrix.res.counts.closed], [EXIT_OK, 4, 1]); + t('ONE payload was sent, for the one actionable card', matrix.sent.map((p) => p.actions[0].issue), [505]); + t('the assigned card is skipped naming who owns it', /#501 SKIP has an assignee \(`os-zhuang`\)/.test(matrix.text)); + t('the summoned card is skipped naming `pm:retriage`', /#502 SKIP carries `pm:retriage`/.test(matrix.text)); + t('the card in another state is skipped naming that state and the one this run acts on', /#503 SKIP pm-state is `pm:dispatched`.*not `pm:queue`/.test(matrix.text)); + t('the card an open PR references is skipped naming the PR', /#504 SKIP an open PR references it \(#9001\)/.test(matrix.text)); + t('⛔ the four skipped cards are untouched on the board', [501, 502, 503, 504].map((n) => [matrix.api.cards.get(n).state, matrix.api.cards.get(n).comments.length]), [['open', 0], ['open', 0], ['open', 0], ['open', 0]]); + t('the skips reach a caller that reads the result, with their reasons', matrix.res.skips.map((s) => [s.issue, typeof s.why]), [[501, 'string'], [502, 'string'], [503, 'string'], [504, 'string']]); + } + // ── the open-PR reading ─────────────────────────────────────────────────── battery('the open-PR reading: a cross-reference that is a PR, and open'); t('an open PR cross-reference is a hit', openPrReferences([CROSS_REF(4242, 'open')]), [4242]); @@ -1019,7 +1515,8 @@ export async function selfTest() { t('…and the board really says so, because the fake PATCH mutates', [happy.api.cards.get(11).state, happy.api.cards.get(12).state], ['closed', 'closed']); t('…with the reason the run was given', happy.api.cards.get(11).state_reason, 'not_planned'); t('the comment is posted before the label write', happy.posted.length === 2 && happy.labelled.length === 2); - t('…and the label write removes the EXPECTED state, never a guessed one', happy.labelled[0].remove, 'pm:queue'); + t('…and the label write removes the EXPECTED state, never a guessed one', happy.labelled[0].remove, ['pm:queue']); + t('…and each card`s comment comes from ITS OWN file', happy.posted.map((p) => p.file), ['/tmp/plan/11.md', '/tmp/plan/12.md']); t('the log carries one line per card, naming the comment id', /#11 → closed not_planned 911/.test(happy.text)); t('…and a summary that counts what happened', /2 read · 2 actionable · 0 skipped · 2 closed/.test(happy.text)); t('each card is RE-READ live before it is acted on', happy.api.calls.filter((c) => c.kind === 'read').length >= 2); @@ -1081,31 +1578,99 @@ export async function selfTest() { const lw = parseLabelWriteOptions(['--repo', 'objectstack-ai/objectstack', '--issue', '19325', '--remove', 'pm:queue']); t('the label step builds options label-write`s own parser accepts', lw.ok && lw.options.remove, ['pm:queue']); t('…for the card the step is on', lw.options?.issue, 19325); - - // The floor runs BEFORE the verdict, so a success line can only be printed by - // a run in which every declared battery registered its cases. - const floorProblems = []; - // ── the relay transport ────────────────────────────────────────────────── - battery('the relay transport: the close as ONE dispatch read back from the card, the label write on the same route, a relay miss is a half-write'); + const ownSource = readFileSync(SELF_PATH, 'utf8'); + t('structural: the label write is handed THIS run\'s route, so one decision serves the direct writes', ownSource.includes('runLabelWrite(parsed.options, { log: (line) => record(` ${line}`), route, send: deps.send })')); + t('structural: the direct comment child is handed THIS run\'s transport, never left to pick its own', ownSource.includes("env: { ...process.env, [TRANSPORT_ENV]: 'direct' }")); + t('the pack`s comment is post-stamped`s own relay action, and its strip is label-write`s own relayActions', ((packed) => [packed.actions[0], packed.actions[1]])(packCard({ repo: 'objectstack-ai/objectstack', session: SELF_TEST_SESSION, issue: 9, body: 'b', labels: computeLabelTarget({ current: ['pm:queue'], remove: ['pm:queue'] }), reason: 'completed' })), [relayAction({ mode: 'comment', number: 9 }, 'b'), ...relayActions({ issue: 9, labels: computeLabelTarget({ current: ['pm:queue'], remove: ['pm:queue'] }) })]); + + // ── the relay pack ──────────────────────────────────────────────────────── + battery('the relay pack: ONE card, ONE dispatch — comment → labels_remove → issue_patch — ONE write on the throttle'); + const ok = await driveOffline({ numbers: [61] }, { cards: QUEUED(61) }, { route: dispatchRoute(), relay: 'success' }); { - const SESSION = 'session_01ABCDEFGHJKMNPQRSTVWXYZ'; - const dispatchRoute = (requested = 'dispatch') => ({ requested, transport: 'dispatch', reason: 'self-test: dispatch', error: null, session: SESSION }); - const ok = await driveOffline({ numbers: [61] }, { cards: QUEUED(61) }, { route: dispatchRoute(), relay: 'success' }); - t('under dispatch the close lands through ONE relay dispatch and the card reads back closed, exit 0', [ok.res.exit, ok.res.counts.closed, ok.api.cards.get(61).state, ok.api.cards.get(61).state_reason], [EXIT_OK, 1, 'closed', 'not_planned']); - t('…the payload carries issue_patch closed with the reason, the session and the target', [ok.sent.length, ok.sent[0].session, ok.sent[0].repo, ok.sent[0].actions], [1, SESSION, 'objectstack-ai/objectstack', [{ op: 'issue_patch', issue: 61, state: 'closed', state_reason: 'not_planned' }]]); - t('…and no PATCH left this process — the reads and a read-back only', ok.api.calls.map((c) => c.kind), ['read', 'timeline', 'read']); + t('under dispatch a card closes through ONE dispatch, exit 0', [ok.res.exit, ok.res.counts.closed, ok.sent.length, ok.res.counts.dispatched], [EXIT_OK, 1, 1, 1]); + t('…carrying EXACTLY three actions, in the order comment → labels_remove → issue_patch', ok.sent[0].actions.map((a) => a.op), [...PACK_ORDER]); + t('…the comment is the card`s own text, stamped on the act`s clock', ok.sent[0].actions[0], { op: 'comment', issue: 61, body: 'Closed #61 2026-09-23T12:00Z — ruling for this card.' }); + t('…the strip is the pm-state, the close carries the row`s reason', [ok.sent[0].actions[1], ok.sent[0].actions[2]], [{ op: 'labels_remove', issue: 61, labels: ['pm:queue'] }, { op: 'issue_patch', issue: 61, state: 'closed', state_reason: 'not_planned' }]); + t('…the envelope carries the session and the target', [ok.sent[0].session, ok.sent[0].repo], [SELF_TEST_SESSION, 'objectstack-ai/objectstack']); + t('⛔ no write verb left this process — the reads, then the read-back reads', ok.api.calls.map((c) => c.kind), ['read', 'timeline', 'read', 'comments']); + t('the summary counts one dispatch — one throttle write — per closed card', /1 closed \(not_planned 1\) · 1 dispatch\(es\) sent — 1 write\(s\) on the throttle/.test(ok.text)); + const two = await driveOffline({ rows: [ROW(62), ROW(63, { reason: 'completed' })] }, { cards: { ...QUEUED(62), ...QUEUED(63) } }, { route: dispatchRoute(), relay: 'success' }); + t('⛔ never two cards in one dispatch: two cards are two payloads, one card each', two.sent.map((p) => [...new Set(p.actions.map((a) => a.issue))]), [[62], [63]]); + t('…each closed under ITS OWN row`s reason', [two.api.cards.get(62).state_reason, two.api.cards.get(63).state_reason], ['not_planned', 'completed']); + const residue = await driveOffline({ numbers: [64] }, { cards: QUEUED(64, { labels: ['pm:queue', 'pm:blocking', 'pm:epic', 'tooling'] }) }, { route: dispatchRoute(), relay: 'success' }); + t('the strip takes the pm-state AND its residue (`pm:blocking`), never an identity sticker (`pm:epic`)', [residue.sent[0].actions[1].labels, residue.api.cards.get(64).labels, residue.res.exit], [['pm:queue', 'pm:blocking'], ['pm:epic', 'tooling'], EXIT_OK]); + const dryRelay = await driveOffline({ numbers: [65, 66], dryRun: true }, { cards: { ...QUEUED(65), ...QUEUED(66, { assignees: ['x'] }) } }, { route: dispatchRoute(), relay: 'success' }); + t('a DRY RUN packs ONE payload per actionable card, exactly three actions, and SENDS none', [dryRelay.res.exit, dryRelay.sent.length, dryRelay.res.payloads.length, dryRelay.res.payloads[0]?.actions.map((a) => a.op)], [EXIT_OK, 0, 1, [...PACK_ORDER]]); + t('…printing that payload as the JSON that would be sent', dryRelay.text.includes(`#65 payload ${JSON.stringify(dryRelay.res.payloads[0])}`)); + t('…and the board is untouched', [dryRelay.api.cards.get(65).state, dryRelay.api.cards.get(65).labels, dryRelay.api.cards.get(65).comments.length], ['open', ['pm:queue', 'tooling'], 0]); t('the transport is printed on every run, direct included', ok.text.includes('transport dispatch') && (await driveOffline({ numbers: [61] }, { cards: QUEUED(61) })).text.includes('transport direct')); - const timedOut = await driveOffline({ numbers: [62, 63] }, { cards: { ...QUEUED(62), ...QUEUED(63) } }, { route: dispatchRoute(), relay: 'timeout' }); - t('a close whose run did not complete is UNCONFIRMED and a HALF-WRITE: exit 4 naming the run, the next card never read', [timedOut.res.exit, timedOut.text.includes('UNCONFIRMED') && timedOut.text.includes('https://github.test/run/42'), timedOut.res.counts.read], [EXIT_HALF_WRITE, true, 1]); - const noRunAuto = await driveOffline({ numbers: [64] }, { cards: QUEUED(64) }, { route: dispatchRoute('auto'), relay: 'no-run' }); - t('under AUTO, no run falls back to the direct PATCH — said out loud — and the card closes', [noRunAuto.res.exit, noRunAuto.text.includes('Falling back to DIRECT'), noRunAuto.api.calls.some((c) => c.kind === 'patch'), noRunAuto.api.cards.get(64).state], [EXIT_OK, true, true, 'closed']); - const failed = await driveOffline({ numbers: [65] }, { cards: QUEUED(65) }, { route: dispatchRoute('auto'), relay: 'failure' }); - t('⛔ a run that FAILED is never fallen back from, even under auto: half-write, no PATCH', [failed.res.exit, failed.api.calls.some((c) => c.kind === 'patch')], [EXIT_HALF_WRITE, false]); const badRoute = await driveOffline({ numbers: [66] }, { cards: QUEUED(66) }, { route: { requested: 'dispatch', transport: 'dispatch', reason: '', error: 'OS_FLEET_SESSION is absent', session: null } }); t('a route with an error is exit 3 before the first card is read', [badRoute.res.exit, badRoute.api.calls.length], [EXIT_PREREQUISITE, 0]); - t('structural: the label write is handed THIS run\'s route, so one decision serves both writes', readFileSync(SELF_PATH, 'utf8').includes('runLabelWrite(parsed.options, { log: (line) => record(` ${line}`), route, send: deps.send })')); + t('packCard refuses a label plan that ADDS — a close never adds a label, so the pack is not three actions', packCard({ repo: 'objectstack-ai/objectstack', session: SELF_TEST_SESSION, issue: 1, body: 'b', labels: computeLabelTarget({ current: ['pm:queue'], add: ['x'], remove: ['pm:queue'] }), reason: 'completed' }).ok, false); } + // ── the size ceiling ────────────────────────────────────────────────────── + battery('the size ceiling: a payload over 64KB is refused before it is packed, zero writes'); + { + // Under the relay's 60,000-byte body cap, and over the platform's 64KB + // `client_payload` ceiling once JSON escapes every double quote to two + // bytes. (Not a run of blank lines: `CLAIM_COMMENT_MARKER`'s two `\s*` + // backtrack cubically across newlines, so post-stamped's keyed-line check + // would take minutes on the fixture before the size rule ever ran.) + const HUGE = `Closing ruling ${STAMP_TOKEN}\n${'"'.repeat(40_000)}\nend.`; + const rendered = renderBody(HUGE, SELF_TEST_NOW); + t('the fixture is under the relay body cap and over the payload ceiling once serialised', [Buffer.byteLength(rendered.body, 'utf8') <= 60_000, Buffer.byteLength(JSON.stringify(rendered.body), 'utf8') > CLIENT_PAYLOAD_MAX_BYTES], [true, true]); + const big = await driveOffline({ rows: [ROW(401), ROW(402, { text: HUGE })] }, { cards: { ...QUEUED(401), ...QUEUED(402) } }, { route: dispatchRoute(), relay: 'success' }); + t('a plan with one over-64KB row is refused BEFORE card one: exit 2, no dispatch, not one read', [big.res.exit, big.sent.length, big.api.calls.length], [EXIT_USAGE, 0, 0]); + t('…the refusal names the row and the platform ceiling', /#402/.test(big.text) && new RegExp(String(CLIENT_PAYLOAD_MAX_BYTES)).test(big.text)); + t('…and every card is untouched — the small one too', [big.api.cards.get(401).state, big.api.cards.get(401).comments.length, big.api.cards.get(402).state], ['open', 0, 'open']); + const labels = computeLabelTarget({ current: ['pm:queue'], remove: ['pm:queue'] }); + t('packCard itself refuses it through the relay`s validator, packing nothing', ((r) => [r.ok, r.payload])(packCard({ repo: 'objectstack-ai/objectstack', session: SELF_TEST_SESSION, issue: 402, body: rendered.body, labels, reason: 'completed' })), [false, null]); + t('…and a body over the relay`s body cap is refused the same way', packCard({ repo: 'objectstack-ai/objectstack', session: SELF_TEST_SESSION, issue: 402, body: 'x'.repeat(60_001), labels, reason: 'completed' }).ok, false); + } + + // ── the read-back ───────────────────────────────────────────────────────── + battery('the read-back: step ④ reads the BOARD — comment, labels and close — and must MATCH'); + { + t('④ reads the card back AND the comments since the dispatch', [ok.api.calls.filter((c) => c.kind === 'comments').length, /since=2026-09-23T11%3A59%3A00/.test(ok.api.calls.find((c) => c.kind === 'comments')?.path ?? '')], [1, true]); + t('④ MATCHES is printed only once all three effects read back from the board', /#61 ④ MATCHES — closed `not_planned`/.test(ok.text)); + t('…the comment is FOUND on the board through the platform`s footer, and its id is what the run reports', ok.res.closed[0]?.comment, 5001); + t('…with post-stamped`s own stamp verdict: drift 0, one clock', /drift 0/.test(ok.text)); + const lied = await driveOffline({ numbers: [71] }, { cards: QUEUED(71) }, { route: dispatchRoute(), relay: 'lie' }); + t('⛔ a run that SAYS success while the board shows nothing is a HALF-WRITE alarm, never a close', [lied.res.exit, lied.res.counts.closed], [EXIT_HALF_WRITE, 0]); + const mutated = await driveOffline({ numbers: [72] }, { cards: QUEUED(72) }, { route: dispatchRoute(), relay: 'mutate' }); + t('⛔ a comment the platform did not store as sent is NOT landed — exit 4, naming what did land', [mutated.res.exit, /LANDED: `label`, `close` · OUTSTANDING: `comment`/.test(mutated.text)], [EXIT_HALF_WRITE, true]); + const stripped = await driveOffline({ numbers: [73, 74] }, { cards: { ...QUEUED(73), ...QUEUED(74) } }, { route: dispatchRoute(), relay: 'strip:tooling' }); + t('⛔ a label stripped underneath inside the run window is a READ-BACK MISMATCH (exit 4), the next card never read', [stripped.res.exit, /READ-BACK MISMATCH/.test(stripped.text), /labels missing `tooling`/.test(stripped.text), stripped.res.counts.read], [EXIT_HALF_WRITE, true, true, 1]); + t('…and ⛔ it is NOT re-added: no write of any kind left for it — the one dispatch was the only one', [stripped.sent.length, stripped.api.calls.some((c) => c.method !== 'GET')], [1, false]); + const added = await driveOffline({ numbers: [75] }, { cards: QUEUED(75) }, { route: dispatchRoute(), relay: 'add:hotfix' }); + t('another seat`s additive label is a NOTE, never a mismatch — the card still closes', [added.res.exit, /④ note — `hotfix`/.test(added.text)], [EXIT_OK, true]); + const unread = await driveOffline({ numbers: [76] }, { cards: QUEUED(76), hooks: { 'comments:76': [{ status: 502, json: { message: 'Bad gateway' } }] } }, { route: dispatchRoute(), relay: 'success' }); + t('⛔ a read-back that cannot be read after a successful run is NOT MEASURED (exit 3), never counted as a close', [unread.res.exit, unread.res.counts.closed, /NOT MEASURED/.test(unread.text)], [EXIT_PREREQUISITE, 0, true]); + } + + // ── the relay miss ──────────────────────────────────────────────────────── + battery('the relay miss: a refused, failed or unconfirmed run is read back, never retried, never fallen back from'); + { + const refused = await driveOffline({ numbers: [81, 82] }, { cards: { ...QUEUED(81), ...QUEUED(82) } }, { route: dispatchRoute(), relay: 'refused' }); + t('a dispatch the platform refuses is NOTHING written: exit 5, the next card never read', [refused.res.exit, refused.res.counts.read, refused.api.cards.get(81).state, refused.api.cards.get(81).comments.length], [EXIT_PLATFORM_REFUSAL, 1, 'open', 0]); + const fail0 = await driveOffline({ numbers: [83] }, { cards: QUEUED(83) }, { route: dispatchRoute(), relay: 'failure@0' }); + t('a run that FAILED at its first action (the comment) is read back: nothing landed, exit 5', [fail0.res.exit, /NOTHING landed/.test(fail0.text)], [EXIT_PLATFORM_REFUSAL, true]); + const fail1 = await driveOffline({ numbers: [84] }, { cards: QUEUED(84) }, { route: dispatchRoute(), relay: 'failure@1' }); + t('a run that failed at the strip is a HALF-WRITE, the comment named as landed FROM THE BOARD', [fail1.res.exit, /LANDED: `comment` · OUTSTANDING: `label`, `close`/.test(fail1.text)], [EXIT_HALF_WRITE, true]); + const fail2 = await driveOffline({ numbers: [85] }, { cards: QUEUED(85) }, { route: dispatchRoute(), relay: 'failure@2' }); + t('…and at the close, naming the comment and the strip', [fail2.res.exit, /LANDED: `comment`, `label` · OUTSTANDING: `close`/.test(fail2.text)], [EXIT_HALF_WRITE, true]); + const timedOut = await driveOffline({ numbers: [86, 87] }, { cards: { ...QUEUED(86), ...QUEUED(87) } }, { route: dispatchRoute(), relay: 'timeout' }); + t('a run that did not complete is UNCONFIRMED: exit 6 naming the run, the next card never read', [timedOut.res.exit, timedOut.text.includes('UNCONFIRMED') && timedOut.text.includes('https://github.test/run/42'), timedOut.res.counts.read], [EXIT_UNCONFIRMED, true, 1]); + const noRunAuto = await driveOffline({ numbers: [88] }, { cards: QUEUED(88) }, { route: dispatchRoute('auto'), relay: 'no-run' }); + t('⛔ under AUTO a run that never appeared is UNCONFIRMED too — no direct fall-back: no comment, no label, no PATCH', [noRunAuto.res.exit, noRunAuto.posted.length, noRunAuto.labelled.length, noRunAuto.api.calls.some((c) => c.kind === 'patch')], [EXIT_UNCONFIRMED, 0, 0, false]); + t('…and it says why it will not fall back', /not fallen back to direct/.test(noRunAuto.text)); + t('⛔ nothing is ever re-sent: one card, one dispatch, whatever the outcome', [refused, fail0, fail1, fail2, timedOut, noRunAuto].map((r) => r.sent.length), [1, 1, 1, 1, 1, 1]); + } + + // The floor runs BEFORE the verdict, so a success line can only be printed by + // a run in which every declared battery registered its cases. + const floorProblems = []; const declared = Object.keys(SELF_TEST_BATTERIES); if (declared.length < SELF_TEST_BATTERY_FLOOR) { floorProblems.push(`SELF_TEST_BATTERIES declares ${declared.length} batteries, below the pinned ${SELF_TEST_BATTERY_FLOOR} — a battery deleted from the roster takes its own floor with it.`); @@ -1175,6 +1740,39 @@ function rearmThroughProxy(args) { return null; } +/** + * The plan, read and judged before any request: the rows (`parsePlan`), each + * row's comment file read ONCE (the text judged is the text packed), and the + * comments judged together (`judgePlanComments`). `readFile` is injected so the + * self-test drives every refusal offline. Returns `{ ok, rows }` or + * `{ ok: false, error }` — and a refusal here means ZERO writes on every card. + */ +export function preparePlan(planPath, { readFile = (p) => readFileSync(p, 'utf8'), nowMs = Date.now() } = {}) { + let raw; + try { + raw = readFile(planPath); + } catch (err) { + return { ok: false, error: `close-cards: could not read --plan ${planPath} (${err.message}).` }; + } + const parsed = parsePlan(raw, { baseDir: dirname(resolve(planPath)) }); + if (!parsed.ok) return { ok: false, error: `close-cards: ${parsed.error}` }; + const rows = []; + const unread = []; + for (const row of parsed.rows) { + try { + rows.push({ ...row, text: readFile(row.commentFile) }); + } catch (err) { + unread.push(`#${row.issue}: could not read ${row.commentFile} (${err.message})`); + } + } + if (unread.length > 0) { + return { ok: false, error: `close-cards: ${unread.length} comment file(s) could not be read:\n${unread.map((u) => ` - ${u}`).join('\n')}\nclose-cards: ⛔ NOTHING was read and NOTHING was written.` }; + } + const judged = judgePlanComments(rows, nowMs); + if (!judged.ok) return { ok: false, error: planCommentRefusalText(judged), judged }; + return { ok: true, rows }; +} + export async function main(argv) { if (argv.includes('--help') || argv.includes('-h')) { console.log(USAGE); @@ -1190,35 +1788,12 @@ export async function main(argv) { } const options = parsed.options; - let listRaw; - try { - listRaw = readFileSync(options.list, 'utf8'); - } catch (err) { - console.error(`close-cards: could not read --list ${options.list} (${err.message}).`); - return EXIT_USAGE; - } - const list = parseCardList(listRaw); - if (!list.ok) { - console.error(`close-cards: ${list.error}`); - return EXIT_USAGE; - } - if (list.duplicates.length > 0) { - console.error(`close-cards: note — the --list names ${list.duplicates.map((n) => `#${n}`).join(', ')} more than once; each card is swept exactly once.`); - } - - let commentRaw; - try { - commentRaw = readFileSync(options.comment, 'utf8'); - } catch (err) { - console.error(`close-cards: could not read --comment ${options.comment} (${err.message}).`); - return EXIT_USAGE; - } - // ⛔ Judged ONCE, before card one: a body post-stamped would refuse must not - // be discovered on card 40, with 39 closing comments already on the board. - const pre = preflightComment(commentRaw); - if (!pre.ok) { - console.error(pre.error); - console.error('close-cards: ⛔ NOTHING was written — the closing comment is refused before the first card is read.'); + // ⛔ Judged ONCE, before card one: a plan row or a comment post-stamped would + // refuse must not be discovered on card 40, with 39 closing comments already + // on the board. + const plan = preparePlan(options.plan); + if (!plan.ok) { + console.error(plan.error); return EXIT_USAGE; } @@ -1237,9 +1812,7 @@ export async function main(argv) { const result = await runCloseCards( { repo: options.repo, - numbers: list.numbers, - commentFile: options.comment, - reason: options.reason, + rows: plan.rows, expectState: options.expectState, skipPrReferenced: options.skipPrReferenced, dryRun: options.dryRun,