From 7078fc076420ec763d7f3e8a12dc9ea2ad10fba1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:13:38 +0000 Subject: [PATCH 1/2] tooling(pm): mechanize the closed-card residue strip as a patrol step A card GitHub closes on a merged `Fixes` pull request keeps every label, so the pm-loop state labels stay behind as claims that work is in flight. The seat has been removing them by hand, once per landing. `scripts/pm/sweep-closed-cards.mjs` takes that stroke: it lists the closed cards carrying H22's residue set (imported, never restated), establishes that the close really was a merged delivery by one of two routes -- a closing commit contained in the default branch, or a pull request whose body closes the card and merged into it -- and removes exactly the residue labels, leaving ownership, priority and outcome untouched. Each stripped card gets one comment naming the readings taken. The action set is bounded to the fresh edge by a window, because the 2026-08-31 maintainer ruling refused a backfill over the archived stock while keeping close-time hygiene as the convention. `--all-time` exists so a seat can measure that stock in a dry run; the workflow never passes it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox --- .github/workflows/half-state-patrol.yml | 72 ++ package.json | 1 + scripts/pm/sweep-closed-cards.mjs | 1273 +++++++++++++++++++++++ 3 files changed, 1346 insertions(+) create mode 100644 scripts/pm/sweep-closed-cards.mjs diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml index 46579667e3..a014506417 100644 --- a/.github/workflows/half-state-patrol.yml +++ b/.github/workflows/half-state-patrol.yml @@ -149,6 +149,10 @@ on: # place that has to name it. (The adopted objectui copy carries this row # already; upstream is catching up to its own port.) - 'scripts/invoked-as.mjs' + # The closed-card sweep this workflow also calls (#16005). Same reasoning + # as the row above, one file along: a step whose script can change without + # this trigger firing is a step whose PR-time proof is a coincidence. + - 'scripts/pm/sweep-closed-cards.mjs' - '.github/workflows/half-state-patrol.yml' # Least privilege: this job reads the repo and writes exactly one issue BODY. @@ -248,6 +252,74 @@ jobs: echo "check-half-states exited $code" cat "$RUNNER_TEMP/report.err" >&2 || true + - name: Sweep the closed cards + id: closed-cards + # #16005 — the pm-loop state labels are CLAIMS that work is in flight, + # and GitHub leaves every label in place when a merged `Fixes` pull + # request closes a card. The seat was paying a hand round trip per + # landing to remove them (eighteen identical ones in one measured + # shift). This step is that stroke, mechanized; the script's header + # carries the ruling it obeys and the window that keeps it to + # close-time hygiene rather than the backfill the 2026-08-31 maintainer + # ruling refused. + # + # ⛔ Gated on the repository NAME, unlike every other step in this file: + # this one is objectstack-only until a sibling has both its patrol + # anchor (objectui#5986) and a copy of the script. A verbatim copy of + # this workflow in a sibling repo therefore SKIPS this step rather than + # failing on a missing file. + # + # ⛔ This step never fails the job, whatever the sweep returns. The + # anchor write below is the patrol's product and must not be starved by + # a step that runs before it; the alarm rides an annotation and the run + # summary instead. Findings are not a failure condition here either — + # the same posture the sweep step above takes. + if: github.repository == 'objectstack-ai/objectstack' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Same wiring, and the same reason, as the live sweep above: the board + # this run acts on is the repo this workflow is installed in. + PM_SWEEP_REPO: ${{ github.repository }} + PROVENANCE: >- + posted by half-state-patrol [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + · trigger `${{ github.event_name }}` + # A pull_request run PROVES the step — the transport, the flags and + # the rendering on a real runner — and writes nothing, exactly as the + # anchor write below is skipped for it. That convention is this file's + # and it is load-bearing: a PR must never write to the board. + SWEEP_MODE: ${{ github.event_name == 'pull_request' && '--dry-run' || '--write' }} + run: | + set +e + node scripts/pm/sweep-closed-cards.mjs "$SWEEP_MODE" --provenance="$PROVENANCE" \ + > "$RUNNER_TEMP/closed-cards.md" 2> "$RUNNER_TEMP/closed-cards.err" + code=$? + set -e + # Captured with NO pipe in between, for the reason the step above + # states at length: piped, `$?` is the pipe's status and a red run and + # a green one read the same. + echo "exit_code=$code" >> "$GITHUB_OUTPUT" + { + echo "### Closed-card sweep — exit $code (\`$SWEEP_MODE\`)" + echo + echo '```' + cat "$RUNNER_TEMP/closed-cards.md" 2>/dev/null || echo '(no report produced)' + echo '```' + echo + echo '
stderr' + echo + echo '```' + cat "$RUNNER_TEMP/closed-cards.err" 2>/dev/null || true + echo '```' + echo + echo '
' + } >> "$GITHUB_STEP_SUMMARY" + cat "$RUNNER_TEMP/closed-cards.err" >&2 || true + if [ "$code" = "3" ]; then + echo "::error::closed-card sweep exited 3 — it could NOT read the board, so it says nothing about whether residue is accumulating. See this run's summary." + elif [ "$code" != "0" ]; then + echo "::warning::closed-card sweep exited $code — at least one card was left UNJUDGED. An unjudged card is not a clean card; see this run's summary." + fi + - name: Resolve the anchor issue # An install with no anchor configured has nowhere to land its report, # and the ONLY safe behaviour is to say so loudly (#11217). The two diff --git a/package.json b/package.json index 67b10db19b..2211c09b22 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "check:declared-population-live": "node scripts/check-declared-population-live.mjs --self-test && node scripts/check-declared-population-live.mjs", "check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test", "check:pm-clause2-carriers": "node scripts/pm/check-clause2-carriers.mjs --self-test", + "check:pm-closed-card-sweep": "node scripts/pm/sweep-closed-cards.mjs --self-test", "check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test", "check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs", "check:publish-smoke-pin": "node scripts/publish-smoke-pack.mjs --self-test", diff --git a/scripts/pm/sweep-closed-cards.mjs b/scripts/pm/sweep-closed-cards.mjs new file mode 100644 index 0000000000..cb3459eba4 --- /dev/null +++ b/scripts/pm/sweep-closed-cards.mjs @@ -0,0 +1,1273 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * sweep-closed-cards — the closed-card residue sweep (#16005). + * + * node scripts/pm/sweep-closed-cards.mjs # DRY RUN: print the plan, write nothing + * node scripts/pm/sweep-closed-cards.mjs --write # act: strip the labels, post one comment per card + * node scripts/pm/sweep-closed-cards.mjs --json # the run summary as one JSON document + * node scripts/pm/sweep-closed-cards.mjs --self-test # offline, no network at all + * + * ## What it removes, and what it never touches + * + * GitHub closes a card when a pull request carrying `Fixes #N` merges, and it + * leaves every label in place. The pm loop's state labels are CLAIMS that work + * is in flight, so on a card the platform has already closed on a merged + * delivery they are stale claims — and the seat has been paying a hand round + * trip per landing to remove them (the filing card measured eighteen identical + * ones in a single objectui shift). + * + * The label set is `PM_RESIDUE_LABELS`, IMPORTED from `check-half-states.mjs` + * rather than restated here: H22 owns that set, its docblock argues each + * inclusion and each exclusion, and a second copy would be a second answer to + * one question. ⛔ Never inline the list. Everything else on the card — + * `domain:*`, `priority:*`, type, `finding`, `needs-user-decision`, `pm:seat`, + * `pm:epic`, `pm:retriage` — is ownership or outcome, not a state claim, and is + * left exactly as found. + * + * ## ⚖️ The two rulings this sweep sits between, and how it obeys both + * + * 1. Maintainer ruling, 2026-08-31, verbatim and untranslated, recorded in + * `check-half-states.mjs`'s H22 section: 「13605 已关卡 为什么要清理。普查时 + * 不应该只看open的卡片吗,其他同意」 — with the operative reason that closed + * cards are an archive, not a state, because every census, patrol, candidate + * and mutex query forces `state:open`. A BACKFILL over the accumulated stock + * (measured there: 2,063 closed cards carrying `pm:dispatched`, back to + * 2026-08-02) is the write that ruling refused by name. + * 2. The same ruling's item ③ KEPT close-time hygiene — strip the state label in + * the same stroke as the close — as the convention, at zero incremental cost. + * The dispatch ruling on #16005 mechanizes exactly that stroke, because the + * closing stroke is GitHub's and not the seat's, so "the same stroke" was + * never actually available to the seat that owes it. + * + * ⇒ So the ACTION SET is bounded to the FRESH EDGE by `--since-hours` + * (`DEFAULT_WINDOW_HOURS`, twelve patrol runs of slack over the six-hourly + * schedule). A card closed before the floor is counted and REPORTED, never + * written and never even fetched. `--all-time` removes the floor and is the + * deliberate, opt-in spelling of the refused backfill: it exists so a seat can + * MEASURE the stock in dry run, and the workflow never passes it. + * + * ⛔ Do not "simplify" the window away. Without it the first scheduled run + * would post roughly two thousand comments onto archived cards and perform + * several thousand label writes — the refused backfill, executed four times a + * day by a machine. + * + * ## The condition to act, and the two routes that establish it + * + * A card is stripped only when BOTH hold: + * + * - `state_reason` is `completed` — `not_planned` and `duplicate` are + * outcomes that were never a delivery; and + * - its closing is a MERGED pull request, established by one of two routes. + * + * ROUTE A — the closing COMMIT. The timeline's last `closed` event carries + * `commit_id` when GitHub closed the card from a commit message. That commit is + * then confirmed to be contained in the repo's default branch. + * + * ROUTE B — the closing PULL REQUEST. On this board the seat usually closes the + * card by hand seconds after the merge, so GitHub never performs the auto-close + * and route A finds `commit_id: null` (measured 2026-09-05 over the eight most + * recently closed `pm:dispatched` cards: eight of eight). Route B reads the + * timeline's `cross-referenced` / `connected` events for pull requests whose + * BODY carries a closing keyword bound to THIS card's number, and confirms the + * pull request itself is merged into the default branch. + * + * ⚠️ Route B is deliberately narrower than "a merged PR that mentions this + * card". A cross-reference is created by any mention, and stripping on that + * relation would de-label cards a merged PR merely discussed. The relation used + * is the one GitHub's own closer uses — a closing keyword bound to the number — + * read through `closingKeywordTargets` so a body QUOTING the keyword in + * backticks does not deliver. `Part of #N` is deliberately NOT accepted here: + * it closes nothing, so it cannot be a closing. + * + * ⛔ A card with a delivering pull request that is still OPEN is LEFT, with a + * row of its own. That is H8's refusal, one layer down and for its reason: a + * card whose remaining half is in flight must not read as un-dispatched. + * + * A closed card that neither route can attribute to a merged pull request — + * closed by hand with no delivery, `not_planned`, `duplicate` — is OUT. The + * seat that closed it owes the strip; this sweep says so in its report and + * writes nothing. + * + * ## Containment: the orientation of the compare, measured + * + * `GET /repos/{owner}/{repo}/compare/{base}...{head}` reports `status` for HEAD + * relative to BASE, and getting the orientation backwards inverts the verdict + * silently. Measured against this repo on 2026-09-05: + * + * compare/main...{an older commit on main} -> status `behind`, ahead_by 0 + * compare/{that same commit}...main -> status `ahead`, ahead_by 5 + * compare/main...{an open PR's head} -> status `ahead`, ahead_by 3 + * compare/main...{main's own tip} -> status `identical` + * + * ⇒ This file asks `compare/{defaultBranch}...{sha}` and accepts `behind` or + * `identical`, AND requires `ahead_by === 0` — the invariant those two statuses + * encode (the head introduces no commit the branch lacks, i.e. it is an + * ancestor). Two readings rather than one, because the statuses are a summary + * and the count is the thing that decides. + * + * ## Exit codes + * + * 0 every judged card came out clean or stripped — INCLUDING a run that + * found nothing to do. Nothing-to-do is never an alarm. + * 1 usage. + * 2 at least one card is UNJUDGED: a REST read or write failed on it. The + * run reports which cards and why, and never skips one silently — an + * unjudged card is not a clean card (#4690). + * 3 PREREQUISITE NOT MET, imported from `check-half-states.mjs`: the + * population read itself failed, so NO card was judged and this run says + * nothing at all about the board. + * + * ## Cost + * + * Per run: one default-branch read, one listing page per residue label (the + * `since` filter keeps the windowed population to one page per label on a busy + * board — measured 2026-09-05: 6 requests for 78 in-window cards), then two + * reads per CANDIDATE card (its timeline, plus one compare or one pull-request + * read). Writes add one DELETE per label removed, one label read-back and one + * comment per stripped card. `--max-cards` caps the candidates a run will judge + * and the cap is REPORTED whenever it binds. + * + * ## Adopting this in a sibling repo + * + * The workflow step that calls this file is gated on the repository name, so a + * verbatim copy of `half-state-patrol.yml` in a sibling repo does not run it. + * A sibling that wants it copies THIS file too and drops the gate — and needs + * its own patrol anchor first (objectui#5986). Nothing here is repo-specific: + * the board is `resolveSweepRepo`'s answer and the branch is the repo's own + * default branch, read at runtime. + */ + +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { isEntrypoint } from '../invoked-as.mjs'; +import { + EXIT_PREREQUISITE_NOT_MET, + PM_RESIDUE_LABELS, + PROXY_FLAG, + closingKeywordTargets, + labelNames, + proxyRearmPlan, + resolveSweepRepo, +} from './check-half-states.mjs'; + +const SELF_PATH = fileURLToPath(import.meta.url); +const API = 'https://api.github.com'; +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; + +export const EXIT_OK = 0; +export const EXIT_USAGE = 1; +export const EXIT_UNJUDGED = 2; + +/** + * The re-exec guard, per script rather than shared with the sweeper: two + * scripts sharing one guard name means the first one's re-exec silently + * disarms the second's when they run in the same process tree. + */ +const PROXY_REARM_GUARD = 'OS_CLOSED_CARD_SWEEP_PROXY_REARMED'; + +/** + * The action window, in hours. Six-hourly patrol, twelve runs of slack: a card + * closed while the patrol was down for three days is still swept when it comes + * back, and nothing older is ever written to. + * + * ⛔ Widening this is not a tuning decision — it is the boundary the + * 2026-08-31 ruling draws between close-time hygiene (owed) and a backfill of + * the archive (refused). The header states that in full. + */ +export const DEFAULT_WINDOW_HOURS = 72; + +/** How many CANDIDATE cards one run will judge. Reported whenever it binds. */ +export const DEFAULT_MAX_CARDS = 200; + +/** The quota backstop on each label's listing — a ceiling that announces itself. */ +export const LISTING_PAGE_CEILING = 10; + +/** The machine-findable marker every comment this sweep posts carries. */ +export const COMMENT_MARKER = 'os-closed-card-sweep'; + +/** The attribution footer every comment carries, in the comment-channel form. */ +export const COMMENT_FOOTER = '_Generated by [Claude Code](https://claude.ai/code)_'; + +// --------------------------------------------------------------------------- +// Pure core — every function below is offline and is what `--self-test` pins. +// --------------------------------------------------------------------------- + +/** + * The residue labels this card actually carries, in `PM_RESIDUE_LABELS` order. + * ⛔ The set is imported, never restated: H22 owns it. + */ +export function residueLabelsOn(issue) { + const names = labelNames(issue); + return PM_RESIDUE_LABELS.filter((label) => names.includes(label)); +} + +/** The labels that must survive the strip, so the read-back has something to compare against. */ +export function survivingLabels(issue) { + const residue = new Set(residueLabelsOn(issue)); + return labelNames(issue).filter((name) => !residue.has(name)); +} + +/** + * The LAST `closed` event on the timeline. A card can be closed, reopened and + * closed again; only the closing that is still in force can have closed it. + */ +export function lastClosedEvent(timeline) { + let last = null; + for (const event of timeline ?? []) if (event?.event === 'closed') last = event; + return last; +} + +/** + * The pull requests whose bodies declare they CLOSE this card, read off the + * timeline's cross-reference and connection events. + * + * ⚠️ The timeline's embedded pull-request payload carries no `head`, so the + * branch-name channel `prDeliversCard` falls back to is unavailable here — and + * that is correct for this question anyway: a branch name closes nothing. The + * body is the only channel a closing can be read from, which is exactly the + * channel GitHub's own closer reads. + */ +export function closingPullRequestRefs(timeline, number) { + const target = String(number); + const found = new Map(); + for (const event of timeline ?? []) { + if (event?.event !== 'cross-referenced' && event?.event !== 'connected') continue; + const src = event?.source?.issue; + if (!src?.pull_request || typeof src.number !== 'number') continue; + if (!closingKeywordTargets(src.body ?? '').has(target)) continue; + found.set(src.number, { + number: src.number, + state: src.state ?? null, + mergedAt: src.pull_request.merged_at ?? null, + url: src.html_url ?? null, + }); + } + return [...found.values()].sort((a, b) => a.number - b.number); +} + +/** + * Is `sha` contained in the branch this compare was taken against? Pure over the + * compare payload; the orientation and the two readings are argued in the header. + */ +export function commitIsContained(compare) { + if (!compare || typeof compare !== 'object') return false; + const status = compare.status; + return (status === 'behind' || status === 'identical') && compare.ahead_by === 0; +} + +/** + * PHASE 1 — the offline screen. Decides whether a listed card is worth a + * network read at all, so the cost model is visible in one function. + * + * @param {object} issue a row from the closed listing + * @param {{floorMs?: number|null}} [options] `null` floor = `--all-time` + */ +export function screenCard(issue, { floorMs = null } = {}) { + if (issue?.pull_request) return { verdict: 'skip', kind: 'pull-request' }; + if (issue?.state !== 'closed') return { verdict: 'skip', kind: 'open' }; + const residue = residueLabelsOn(issue); + if (residue.length === 0) return { verdict: 'skip', kind: 'clean' }; + if (floorMs !== null) { + const closedMs = Date.parse(issue?.closed_at ?? ''); + if (!Number.isFinite(closedMs)) { + return { verdict: 'skip', kind: 'undateable', residue, detail: 'the card carries no readable `closed_at`, so the window cannot place it' }; + } + if (closedMs < floorMs) { + return { verdict: 'skip', kind: 'out-of-window', residue, detail: `closed ${issue.closed_at}, before the action floor` }; + } + } + if (issue?.state_reason !== 'completed') { + return { + verdict: 'leave', + kind: 'not-completed', + residue, + detail: `\`state_reason\` is ${issue?.state_reason === null || issue?.state_reason === undefined ? 'unset' : `\`${issue.state_reason}\``}, so this close was never a delivery`, + }; + } + return { verdict: 'candidate', residue }; +} + +/** + * PHASE 2 — the verdict, over evidence the live layer gathered. Pure: the + * self-test drives every branch below with fixtures and no network. + * + * @param {object} issue + * @param {object} evidence + * @param {string[]} evidence.residue + * @param {{sha: string, contained: boolean|null, status?: string, error?: string}|null} [evidence.closingCommit] + * @param {Array<{number: number, merged: boolean, baseRef: string|null, mergeCommitSha: string|null, state: string|null, error?: string}>} [evidence.closingPrs] + * @param {{defaultBranch?: string}} [options] + */ +export function judgeCandidate(issue, evidence = {}, { defaultBranch = 'main' } = {}) { + const residue = evidence.residue ?? residueLabelsOn(issue); + const unreadable = []; + const commit = evidence.closingCommit ?? null; + const prs = evidence.closingPrs ?? []; + + if (commit?.error) unreadable.push(`the closing commit \`${String(commit.sha).slice(0, 10)}\` could not be placed: ${commit.error}`); + for (const pr of prs) if (pr?.error) unreadable.push(`pull request #${pr.number} could not be read: ${pr.error}`); + + // H8's refusal, FIRST and ahead of both routes: a card with a delivery still + // in flight has a state claim that is not stale, whatever else closed it. + // Ordering is the whole safety property here — a route that answered before + // this test would strip the one card class this file must never strip. + const openDeliveries = prs.filter((pr) => !pr.error && !pr.merged); + const mergedDeliveries = prs.filter((pr) => !pr.error && pr.merged); + if (openDeliveries.length > 0) { + return { + verdict: 'leave', + kind: 'open-delivery', + residue, + detail: + `a pull request that declares it closes this card is not merged (${openDeliveries.map((p) => `#${p.number}`).join(', ')}) — ` + + 'the state claim is not stale while a delivery is in flight', + unreadable, + }; + } + + // ROUTE A — the closing commit, contained in the default branch. + if (commit && commit.contained === true) { + return { + verdict: 'strip', + route: 'commit', + residue, + evidence: { + commit: commit.sha, + containment: `contained in \`${defaultBranch}\` (compare status \`${commit.status ?? 'unknown'}\`)`, + branch: defaultBranch, + prs: mergedDeliveries.map((p) => p.number), + }, + unreadable, + }; + } + + // A commit that is READABLE and NOT contained is a finding of its own: the + // close happened on something that never reached the branch this repo ships. + const commitOffBranch = commit && commit.contained === false; + + // ROUTE B — a merged closing pull request, confirmed into the default branch. + // ⛔ `baseRef` must be READ, never assumed: an unfetched ref carries `null`, + // and accepting `null` here would strip on a merge whose base nobody looked at. + const onBranch = mergedDeliveries.filter((pr) => pr.fetched === true && pr.baseRef === defaultBranch); + if (onBranch.length > 0) { + return { + verdict: 'strip', + route: 'pull-request', + residue, + evidence: { + commit: onBranch.find((p) => p.mergeCommitSha)?.mergeCommitSha ?? null, + containment: `merged into \`${defaultBranch}\``, + branch: defaultBranch, + prs: onBranch.map((p) => p.number), + }, + unreadable, + }; + } + + if (unreadable.length > 0) { + return { verdict: 'unjudged', residue, detail: unreadable.join('; ') }; + } + if (mergedDeliveries.length > 0) { + return { + verdict: 'leave', + kind: 'merged-elsewhere', + residue, + detail: `every closing pull request merged into a branch other than \`${defaultBranch}\` (${mergedDeliveries.map((p) => `#${p.number} into \`${p.baseRef}\``).join(', ')})`, + }; + } + if (commitOffBranch) { + return { + verdict: 'leave', + kind: 'commit-off-branch', + residue, + detail: `the closing commit \`${String(commit.sha).slice(0, 10)}\` is not contained in \`${defaultBranch}\` (compare status \`${commit.status ?? 'unknown'}\`)`, + }; + } + return { + verdict: 'leave', + kind: 'no-closing-delivery', + residue, + detail: + 'closed with no merged pull request declaring it closes this card — closed by hand, and the seat that closed it owes the strip', + }; +} + +/** + * The comment one stripped card gets. Pure, so its every property is pinned + * offline — this text is a write onto someone else's card. + * + * ⛔ No angle-bracket-shaped fragment goes in here: GitHub's body sanitizer + * eats short ones, backticked or not, and a comment that loses its middle is a + * machine posting nonsense onto a card nobody asked it to touch. + */ +export function strippedComment({ removed, survived, evidence, provenance = '' }) { + const lines = [ + `${COMMENT_MARKER} — machine-findable marker for this generated comment.`, + '', + `Removed the pm-loop state label(s) this closed card no longer claims: ${removed.map((l) => `\`${l}\``).join(', ')}.`, + '', + ]; + if (evidence?.prs?.length) { + lines.push(`- Closing pull request: ${evidence.prs.map((n) => `#${n}`).join(', ')}, merged.`); + } + if (evidence?.commit) { + lines.push(`- Closing commit \`${String(evidence.commit).slice(0, 10)}\`, ${evidence.containment}.`); + } else { + lines.push(`- Containment: ${evidence?.containment ?? 'unknown'}.`); + } + lines.push( + survived.length > 0 + ? `- Left untouched: ${survived.map((l) => `\`${l}\``).join(', ')} — ownership, priority and outcome are not state claims.` + : '- No other label was on the card.', + '- The label set was read back after the write and matched.', + '', + 'A state label claims work is in flight. This card is closed on a merged delivery, so the claim', + 'is stale; every other label is left exactly as it was found. Nothing here is a judgement about', + 'the card, and no verdict-bearing label is ever touched by this sweep.', + '', + ); + if (provenance) lines.push(`_${provenance}_`, ''); + lines.push(COMMENT_FOOTER); + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// The listing, the window and the caps — path builders kept pure and pinned. +// --------------------------------------------------------------------------- + +/** + * One page of the closed cards carrying ONE residue label. + * + * ⚠️ One label per request, unioned locally afterwards: the listing endpoint's + * `labels` filter is an AND across the names it is given, so a six-label query + * answers a question nobody asked and answers it empty. + * + * `since` filters on `updated_at`, which for a closed card is at or after its + * `closed_at` — so every card closed inside the window is returned, and the + * exact `closed_at` test happens locally. That over-approximation is the point: + * a filter that could drop an in-window card would make a short read look like + * a clean board. + */ +export function closedListingPath(repo, label, page, sinceIso = null) { + const query = [ + 'state=closed', + `labels=${encodeURIComponent(label)}`, + ...(sinceIso ? [`since=${encodeURIComponent(sinceIso)}`, 'sort=updated', 'direction=desc'] : []), + 'per_page=100', + `page=${page}`, + ].join('&'); + return `/repos/${repo}/issues?${query}`; +} + +/** The action floor, or `null` for `--all-time`. */ +export function actionFloorMs(nowMs, windowHours) { + return windowHours === null ? null : nowMs - windowHours * 3_600_000; +} + +/** + * The run's counts and its exit code, from the judged rows alone — so the exit + * register is pinned offline and cannot drift from what the report printed. + * + * ⛔ `nothing to do` is exit 0, always. A sweep that finds a clean board and a + * sweep that finds nothing in its window are both successful runs; only an + * UNJUDGED card moves the code, because that is the one outcome where the run + * does not know what it was looking at. + */ +export function summariseRun(rows) { + const counts = { strip: 0, leave: 0, unjudged: 0, skipped: 0, failed: 0 }; + for (const row of rows ?? []) { + if (row.verdict === 'strip') counts.strip += 1; + else if (row.verdict === 'leave') counts.leave += 1; + else if (row.verdict === 'unjudged') counts.unjudged += 1; + else counts.skipped += 1; + if (row.writeFailed) counts.failed += 1; + } + const exitCode = counts.unjudged > 0 || counts.failed > 0 ? EXIT_UNJUDGED : EXIT_OK; + return { counts, exitCode }; +} + +// --------------------------------------------------------------------------- +// Live layer +// --------------------------------------------------------------------------- + +async function rest(path, { method = 'GET', body = null } = {}) { + const res = await fetch(`${API}${path}`, { + method, + headers: { + accept: 'application/vnd.github+json', + ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + if (!res.ok) { + const err = new Error(`${method} ${path} -> HTTP ${res.status}`); + err.status = res.status; + throw err; + } + if (res.status === 204) return null; + return res.json(); +} + +/** The board's own default branch — never a hardcoded `main`, so the file stays repo-agnostic. */ +async function readDefaultBranch(repo) { + const meta = await rest(`/repos/${repo}`); + return meta?.default_branch ?? 'main'; +} + +async function listClosedCardsWithResidue(repo, { sinceIso, stats }) { + const byNumber = new Map(); + for (const label of PM_RESIDUE_LABELS) { + let exhausted = false; + let page = 1; + for (; page <= LISTING_PAGE_CEILING; page++) { + const batch = await rest(closedListingPath(repo, label, page, sinceIso)); + stats.listingRequests += 1; + for (const row of Array.isArray(batch) ? batch : []) { + if (!row?.pull_request) byNumber.set(row.number, row); + } + if (!Array.isArray(batch) || batch.length < 100) { + exhausted = true; + break; + } + } + if (!exhausted) stats.truncatedLabels.push(label); + } + return [...byNumber.values()]; +} + +/** How many pull requests one card's evidence pass will read. Beyond it the card is UNJUDGED, never guessed. */ +export const PR_READS_PER_CARD = 5; + +async function gatherEvidence(repo, card, defaultBranch, stats) { + const evidence = { residue: residueLabelsOn(card), closingCommit: null, closingPrs: [] }; + const timeline = await rest(`/repos/${repo}/issues/${card.number}/timeline?per_page=100`); + stats.evidenceRequests += 1; + + const closed = lastClosedEvent(timeline); + if (closed?.commit_id) { + try { + const compare = await rest(`/repos/${repo}/compare/${defaultBranch}...${closed.commit_id}`); + stats.evidenceRequests += 1; + evidence.closingCommit = { sha: closed.commit_id, contained: commitIsContained(compare), status: compare?.status ?? null }; + } catch (err) { + evidence.closingCommit = { sha: closed.commit_id, contained: null, error: err.message }; + } + } + + const refs = closingPullRequestRefs(timeline, card.number); + // Route A already answered the question the pull-request read exists to + // answer, so it is not taken. The refs still ride along: an OPEN delivery is + // readable from the timeline payload alone and is what H8's refusal turns on. + const commitSettles = evidence.closingCommit?.contained === true; + let reads = 0; + for (const ref of refs) { + const merged = Boolean(ref.mergedAt); + if (!merged || commitSettles) { + evidence.closingPrs.push({ number: ref.number, merged, baseRef: null, mergeCommitSha: null, state: ref.state, fetched: false }); + continue; + } + if (reads >= PR_READS_PER_CARD) { + evidence.closingPrs.push({ + number: ref.number, + merged: true, + baseRef: null, + mergeCommitSha: null, + state: ref.state, + fetched: false, + error: `not read — this card declares more than ${PR_READS_PER_CARD} closing pull requests`, + }); + continue; + } + try { + const pr = await rest(`/repos/${repo}/pulls/${ref.number}`); + stats.evidenceRequests += 1; + reads += 1; + evidence.closingPrs.push({ + number: ref.number, + merged: pr?.merged === true, + baseRef: pr?.base?.ref ?? null, + mergeCommitSha: pr?.merge_commit_sha ?? null, + state: pr?.state ?? null, + fetched: true, + }); + } catch (err) { + evidence.closingPrs.push({ number: ref.number, merged: true, baseRef: null, mergeCommitSha: null, state: ref.state, fetched: false, error: err.message }); + } + } + return evidence; +} + +/** + * The write: one DELETE per label, then a comparison read-back, then one comment. + * + * The endpoint is ADDITIVE by construction — `DELETE /issues/{n}/labels/{name}` + * names exactly one label and cannot rewrite the set, so a label another actor + * adds mid-run survives. The read-back is what turns that into a reading: it + * proves the residue really went, and it NAMES a label that vanished without + * this sweep touching it. + * + * ⛔ A label that disappeared underneath us is REPORTED, never re-attached. + * Re-adding it would be this sweep overruling another actor's deliberate write + * on a card it was only ever cleared to subtract state claims from. + */ +async function stripCard(repo, card, residue, { provenance, evidence }) { + const expectedSurvivors = survivingLabels(card); + const removed = []; + for (const label of residue) { + await rest(`/repos/${repo}/issues/${card.number}/labels/${encodeURIComponent(label)}`, { method: 'DELETE' }); + removed.push(label); + } + const after = await rest(`/repos/${repo}/issues/${card.number}/labels?per_page=100`); + const afterNames = (Array.isArray(after) ? after : []).map((l) => l?.name).filter(Boolean); + const stillResidue = residue.filter((label) => afterNames.includes(label)); + const vanished = expectedSurvivors.filter((label) => !afterNames.includes(label)); + if (stillResidue.length > 0) { + return { removed, afterNames, vanished, failure: `the read-back still shows ${stillResidue.map((l) => `\`${l}\``).join(', ')} — the removal did not land` }; + } + await rest(`/repos/${repo}/issues/${card.number}/comments`, { + method: 'POST', + body: { + body: strippedComment({ + removed, + survived: afterNames, + evidence, + provenance, + }), + }, + }); + return { removed, afterNames, vanished, failure: null }; +} + +// --------------------------------------------------------------------------- +// The run +// --------------------------------------------------------------------------- + +function row(card, verdict, extra = {}) { + return { + number: card.number, + title: card.title ?? '', + url: card.html_url ?? null, + closed_at: card.closed_at ?? null, + verdict, + kind: null, + residue: [], + detail: null, + removed: [], + vanished: [], + writeFailed: false, + ...extra, + }; +} + +async function sweep(repo, options) { + const stats = { listingRequests: 0, evidenceRequests: 0, writeRequests: 0, truncatedLabels: [] }; + const defaultBranch = await readDefaultBranch(repo); + stats.listingRequests += 1; + + const floorMs = actionFloorMs(options.nowMs, options.windowHours); + const sinceIso = floorMs === null ? null : new Date(floorMs).toISOString(); + const listed = await listClosedCardsWithResidue(repo, { sinceIso, stats }); + + const rows = []; + const candidates = []; + for (const card of listed) { + const screen = screenCard(card, { floorMs }); + if (screen.verdict === 'candidate') { + candidates.push({ card, residue: screen.residue }); + continue; + } + rows.push(row(card, screen.verdict, { kind: screen.kind, residue: screen.residue ?? [], detail: screen.detail ?? null })); + } + + // Freshest first, so a bound cap defers the OLDEST candidates — the ones the + // next run will still find, rather than the ones a seat is waiting on. + candidates.sort((a, b) => (Date.parse(b.card.closed_at ?? '') || 0) - (Date.parse(a.card.closed_at ?? '') || 0)); + const judging = candidates.slice(0, options.maxCards); + const deferred = candidates.slice(options.maxCards); + for (const { card, residue } of deferred) { + rows.push(row(card, 'skip', { + kind: 'cap-deferred', + residue, + detail: `beyond this run's cap of ${options.maxCards} candidate cards — NOT judged, and the next run will see it again`, + })); + } + + for (const { card, residue } of judging) { + let evidence; + try { + evidence = await gatherEvidence(repo, card, defaultBranch, stats); + } catch (err) { + rows.push(row(card, 'unjudged', { residue, detail: `the evidence pass failed: ${err.message}` })); + continue; + } + const judged = judgeCandidate(card, evidence, { defaultBranch }); + const out = row(card, judged.verdict, { + kind: judged.kind ?? judged.route ?? null, + residue: judged.residue, + detail: judged.detail ?? null, + evidence: judged.evidence ?? null, + }); + if (judged.unreadable?.length) { + out.detail = [out.detail, `partial reads: ${judged.unreadable.join('; ')}`].filter(Boolean).join(' · '); + } + if (judged.verdict === 'strip' && options.write) { + try { + const written = await stripCard(repo, card, judged.residue, { provenance: options.provenance, evidence: judged.evidence }); + stats.writeRequests += judged.residue.length + 2; + out.removed = written.removed; + out.vanished = written.vanished; + if (written.failure) { + out.writeFailed = true; + out.detail = written.failure; + } + } catch (err) { + out.writeFailed = true; + out.detail = `the write failed: ${err.message}`; + } + } + rows.push(out); + } + + return { repo, defaultBranch, sinceIso, rows, stats, listed: listed.length, candidates: candidates.length }; +} + +function countKinds(rows, verdict) { + const out = new Map(); + for (const r of rows) { + if (r.verdict !== verdict) continue; + out.set(r.kind ?? 'other', (out.get(r.kind ?? 'other') ?? 0) + 1); + } + return [...out.entries()].map(([kind, n]) => `${n} ${kind}`).join(' · ') || 'none'; +} + +export function renderRun(result, options) { + const { counts, exitCode } = summariseRun(result.rows); + const mode = options.write ? 'WRITE' : 'DRY RUN — nothing was written'; + const window = result.sinceIso + ? `cards closed since ${result.sinceIso} (${options.windowHours}h)` + : 'ALL TIME — the window is off, and this is the shape the 2026-08-31 ruling refuses as a WRITE'; + const lines = [ + `closed-card sweep — ${result.repo} · ${mode}`, + ` window: ${window}`, + ` residue labels (imported from check-half-states.mjs): ${PM_RESIDUE_LABELS.join(', ')}`, + ` default branch: ${result.defaultBranch}`, + ` listed: ${result.listed} closed card(s) carrying residue · candidates: ${result.candidates}`, + ` requests: ${result.stats.listingRequests} listing · ${result.stats.evidenceRequests} evidence · ${result.stats.writeRequests} write`, + ` verdicts: ${counts.strip} strip · ${counts.leave} leave · ${counts.unjudged} unjudged · ${counts.skipped} not judged`, + ` not judged, by reason: ${countKinds(result.rows, 'skip')}`, + ` left, by reason: ${countKinds(result.rows, 'leave')}`, + ]; + if (result.stats.truncatedLabels.length > 0) { + lines.push( + ` ⚠️ the page ceiling (${LISTING_PAGE_CEILING}) BOUND the listing for ${result.stats.truncatedLabels.join(', ')} —`, + ' that population was read short, so this run is not a complete inventory of it.', + ); + } + const capDeferred = result.rows.filter((r) => r.kind === 'cap-deferred').length; + if (capDeferred > 0) { + lines.push(` ⚠️ the per-run cap (${options.maxCards}) BOUND this run: ${capDeferred} candidate(s) were NOT judged.`); + } + + const section = (title, rows) => { + if (rows.length === 0) return; + lines.push('', `${title} (${rows.length})`); + for (const r of rows.slice(0, options.listCap)) { + const labels = r.residue.length ? r.residue.join(',') : '-'; + const tail = r.detail ? ` — ${r.detail}` : r.evidence ? ` — ${r.evidence.prs?.length ? `closing PR ${r.evidence.prs.map((n) => `#${n}`).join(', ')}, ` : ''}${r.evidence.commit ? `commit \`${String(r.evidence.commit).slice(0, 10)}\`, ` : ''}${r.evidence.containment}` : ''; + lines.push(` #${r.number} [${labels}]${tail}`); + } + if (rows.length > options.listCap) lines.push(` … and ${rows.length - options.listCap} more (list capped for readability, not for judgement)`); + }; + + section('STRIP', result.rows.filter((r) => r.verdict === 'strip')); + section('LEFT', result.rows.filter((r) => r.verdict === 'leave')); + section('UNJUDGED — these cards were NOT read as clean', result.rows.filter((r) => r.verdict === 'unjudged' || r.writeFailed)); + + lines.push( + '', + counts.unjudged + counts.failed > 0 + ? `⚠️ ${counts.unjudged + counts.failed} card(s) unjudged. An unjudged card is not a clean card; exit ${EXIT_UNJUDGED}.` + : `✓ every judged card came out clean or stripped; exit ${EXIT_OK}.`, + ); + return { text: lines.join('\n'), exitCode }; +} + +export function renderJson(result, options) { + const { counts, exitCode } = summariseRun(result.rows); + return { + tool: 'sweep-closed-cards', + repo: result.repo, + mode: options.write ? 'write' : 'dry-run', + default_branch: result.defaultBranch, + window_hours: options.windowHours, + since: result.sinceIso, + residue_labels: PM_RESIDUE_LABELS, + listed: result.listed, + candidates: result.candidates, + cap: options.maxCards, + cap_bound: result.rows.some((r) => r.kind === 'cap-deferred'), + page_ceiling_bound: result.stats.truncatedLabels, + requests: result.stats, + counts, + exit_code: exitCode, + cards: result.rows.map((r) => ({ + number: r.number, + verdict: r.verdict, + kind: r.kind, + residue: r.residue, + removed: r.removed, + vanished: r.vanished, + detail: r.detail, + evidence: r.evidence ?? null, + url: r.url, + })), + }; +} + +/** + * The refusal printer. Its load-bearing half is the last paragraph: a run that + * could not read the board must never be legible as a run that found a clean + * one (#4690) — and on a report-only sweep that risk is sharper than on a gate. + */ +function reportPrerequisiteNotMet(err, { swept = 0 } = {}) { + console.error( + `\nsweep-closed-cards: PREREQUISITE NOT MET — ${err.message}\n\n` + + ` Fix: run this where node's fetch reaches api.github.com with a token that can read issues\n` + + ` (a GitHub Actions runner, or an agent container with ${PROXY_FLAG} — this script re-execs\n` + + ` itself with that flag when HTTPS_PROXY is set).\n\n` + + (swept === 0 + ? ' NOTHING WAS SWEPT: no card was listed and no card was judged, so this run says nothing\n' + + ' about whether the board carries residue. It is not a clean board and it is not a dirty\n' + + ' one — it is no reading at all.\n' + : ` NOTHING FURTHER WAS JUDGED: the transport failed after ${swept} card(s) had been listed.\n` + + ' An empty strip list here is not a clean board.\n') + + `\n (Exit code ${EXIT_PREREQUISITE_NOT_MET}, distinct from ${EXIT_UNJUDGED}'s "read the board, could not judge a card".\n` + + ' Capture it BEFORE any pipe: `node scripts/pm/sweep-closed-cards.mjs > /tmp/sweep.log 2>&1; echo "EXIT=$?"`.)', + ); + return EXIT_PREREQUISITE_NOT_MET; +} + +// --------------------------------------------------------------------------- +// --self-test — offline, no network, every branch above driven by fixtures. +// +// 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 +// a section that stops running names ITSELF at the floor rather than going +// quiet. The counts are a FLOOR, never an equality — adding cases is ordinary +// work and must not go red. +// --------------------------------------------------------------------------- + +const SELF_TEST_BATTERIES = Object.freeze({ + 'the residue set is imported from H22, never restated': 6, + 'the offline screen: which cards cost a network read': 11, + 'the closing evidence readers: commit, pull requests, containment': 14, + 'the verdict: every case the ruling names': 14, + 'the comment a machine writes on someone else\'s card': 9, + 'the listing path, the window and the caps': 10, + 'the exit register: nothing to do is never an alarm': 7, + 'the report: what a reader is told, and what it refuses to imply': 7, + 'the CLI: the one decision a typo must never make': 8, +}); +const SELF_TEST_BATTERY_FLOOR = 9; +const UNATTRIBUTED_BATTERY = '(unattributed)'; + +let selfTestReachedVerdict = false; + +export function selfTest() { + const batterySeen = new Map(); + let openBattery = null; + const battery = (name) => { openBattery = name; }; + const cases = []; + const t = (name, ok, detail) => { + const b = openBattery ?? UNATTRIBUTED_BATTERY; + batterySeen.set(b, (batterySeen.get(b) ?? 0) + 1); + cases.push({ name, ok: Boolean(ok), detail }); + }; + + const CARD = (over = {}) => ({ + number: 100, + title: 'a card', + state: 'closed', + state_reason: 'completed', + closed_at: '2026-09-05T12:00:00Z', + html_url: 'https://github.com/o/r/issues/100', + labels: [{ name: 'pm:dispatched' }, { name: 'domain:skills' }, { name: 'priority:p3' }], + ...over, + }); + const XREF = (number, body, mergedAt, state = 'closed') => ({ + event: 'cross-referenced', + source: { type: 'issue', issue: { number, body, state, pull_request: { merged_at: mergedAt }, html_url: `https://github.com/o/r/pull/${number}` } }, + }); + const CLOSED_EVENT = (commitId = null) => ({ event: 'closed', commit_id: commitId, created_at: '2026-09-05T12:00:00Z' }); + const NOW = Date.parse('2026-09-05T18:00:00Z'); + const FLOOR = actionFloorMs(NOW, DEFAULT_WINDOW_HOURS); + + // -- the residue set is imported from H22, never restated ------------------ + battery('the residue set is imported from H22, never restated'); + t('the set this file acts on IS H22\'s exported constant', PM_RESIDUE_LABELS.includes('pm:dispatched') && PM_RESIDUE_LABELS.includes('pm:queue')); + t('⛔ and this file declares no second copy of it — a restated set is a second answer to one question', + !/(?:const|let)\s+[A-Za-z_$][\w$]*\s*=\s*\[\s*(?:\/\/[^\n]*\n\s*)?'pm:(?:dispatched|queue)'/.test(readFileSync(SELF_PATH, 'utf8'))); + t('residue is read off the card in the imported set\'s order', residueLabelsOn(CARD({ labels: [{ name: 'pm:queue' }, { name: 'pm:dispatched' }] })).join() === 'pm:dispatched,pm:queue'); + t('a card carrying none reads empty', residueLabelsOn(CARD({ labels: [{ name: 'domain:skills' }] })).length === 0); + // The ruling's third fixture: the decision inbox is an OUTCOME, not a state claim. + t('`needs-user-decision` beside `pm:dispatched`: only the state claim is residue', + residueLabelsOn(CARD({ labels: [{ name: 'needs-user-decision' }, { name: 'pm:dispatched' }] })).join() === 'pm:dispatched'); + t('…and everything else is what the strip must leave behind', + survivingLabels(CARD({ labels: [{ name: 'needs-user-decision' }, { name: 'pm:dispatched' }] })).join() === 'needs-user-decision'); + + // -- the offline screen ---------------------------------------------------- + battery('the offline screen: which cards cost a network read'); + t('an OPEN card carrying residue is never touched, whatever else is true of it', screenCard(CARD({ state: 'open' }), { floorMs: FLOOR }).kind === 'open'); + t('…and it is skipped, not left — a left card is one this sweep judged', screenCard(CARD({ state: 'open' }), { floorMs: FLOOR }).verdict === 'skip'); + t('a closed card with no residue is clean, and costs no read', screenCard(CARD({ labels: [{ name: 'domain:skills' }] }), { floorMs: FLOOR }).kind === 'clean'); + t('the IDEMPOTENCE case: the same card re-screened after its strip reads clean', screenCard(CARD({ labels: [{ name: 'domain:skills' }, { name: 'priority:p3' }] }), { floorMs: FLOOR }).kind === 'clean'); + t('a card closed before the action floor is out of window', screenCard(CARD({ closed_at: '2026-08-02T00:00:00Z' }), { floorMs: FLOOR }).kind === 'out-of-window'); + t('…and `--all-time` puts that same archived card back in play', screenCard(CARD({ closed_at: '2026-08-02T00:00:00Z' }), { floorMs: null }).verdict === 'candidate'); + t('a card closed inside the window with residue is a candidate', screenCard(CARD(), { floorMs: FLOOR }).verdict === 'candidate'); + t('`not_planned` is LEFT and reported, never stripped', screenCard(CARD({ state_reason: 'not_planned' }), { floorMs: FLOOR }).kind === 'not-completed'); + t('`duplicate` likewise', screenCard(CARD({ state_reason: 'duplicate' }), { floorMs: FLOOR }).kind === 'not-completed'); + t('an unreadable `closed_at` is skipped as undateable, never assumed fresh', screenCard(CARD({ closed_at: null }), { floorMs: FLOOR }).kind === 'undateable'); + t('a pull request row that slipped into the listing is not a card', screenCard(CARD({ pull_request: {} }), { floorMs: FLOOR }).kind === 'pull-request'); + + // -- the closing evidence readers ----------------------------------------- + battery('the closing evidence readers: commit, pull requests, containment'); + t('the LAST closed event wins — a card can be closed, reopened and closed again', + lastClosedEvent([CLOSED_EVENT('aaa'), { event: 'reopened' }, CLOSED_EVENT('bbb')])?.commit_id === 'bbb'); + t('no closed event reads null, not a guess', lastClosedEvent([{ event: 'labeled' }]) === null); + t('a merged PR whose body closes this card is a closing reference', closingPullRequestRefs([XREF(200, 'Fixes #100', '2026-09-05T11:00:00Z')], 100).length === 1); + t('…and its merge is carried through from the timeline payload', closingPullRequestRefs([XREF(200, 'Fixes #100', '2026-09-05T11:00:00Z')], 100)[0].mergedAt !== null); + t('⛔ a PR that merely MENTIONS the card is not a closing — a cross-reference is any mention', closingPullRequestRefs([XREF(200, 'see #100 for context', '2026-09-05T11:00:00Z')], 100).length === 0); + t('⛔ `Part of #100` closes nothing, so it is not a closing either', closingPullRequestRefs([XREF(200, 'Part of #100', '2026-09-05T11:00:00Z')], 100).length === 0); + t('⛔ a body QUOTING the keyword in backticks does not deliver', closingPullRequestRefs([XREF(200, 'the body says `Fixes #100` in a code span', '2026-09-05T11:00:00Z')], 100).length === 0); + t('a closing keyword bound to a DIFFERENT card is not this card\'s closing', closingPullRequestRefs([XREF(200, 'Fixes #101', '2026-09-05T11:00:00Z')], 100).length === 0); + t('a cross-reference from an ISSUE, not a pull request, is ignored', closingPullRequestRefs([{ event: 'cross-referenced', source: { issue: { number: 300, body: 'Fixes #100' } } }], 100).length === 0); + t('two events naming one pull request yield one reference', closingPullRequestRefs([XREF(200, 'Fixes #100', null), XREF(200, 'Fixes #100', null)], 100).length === 1); + t('containment: `behind` with no commits ahead is contained', commitIsContained({ status: 'behind', ahead_by: 0 }) === true); + t('containment: `identical` is contained', commitIsContained({ status: 'identical', ahead_by: 0 }) === true); + t('⛔ `ahead` is NOT contained — the orientation is measured in this file\'s header', commitIsContained({ status: 'ahead', ahead_by: 3 }) === false); + t('⛔ nor is a `behind` that still carries commits the branch lacks', commitIsContained({ status: 'behind', ahead_by: 2 }) === false); + + // -- the verdict ----------------------------------------------------------- + battery('the verdict: every case the ruling names'); + const mergedPr = { number: 200, merged: true, baseRef: 'main', mergeCommitSha: 'abc1234567', state: 'closed', fetched: true }; + const stripByPr = judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingPrs: [mergedPr] }, { defaultBranch: 'main' }); + t('a card closed by a merged `Fixes` pull request is STRIPPED', stripByPr.verdict === 'strip'); + t('…by the pull-request route, and the route is named in the row', stripByPr.route === 'pull-request'); + t('…and only the residue labels are removed', stripByPr.residue.join() === 'pm:dispatched'); + t('…and the evidence names the pull request the strip rests on', stripByPr.evidence.prs.join() === '200'); + const byCommit = judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingCommit: { sha: 'deadbeef00', contained: true, status: 'behind' } }, { defaultBranch: 'main' }); + t('a card closed by a commit contained in the default branch is STRIPPED', byCommit.verdict === 'strip' && byCommit.route === 'commit'); + const byHand = judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingPrs: [] }, { defaultBranch: 'main' }); + t('a card closed BY HAND is left, and the row says the closing seat owes the strip', byHand.verdict === 'leave' && byHand.kind === 'no-closing-delivery'); + const offBranch = judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingCommit: { sha: 'deadbeef00', contained: false, status: 'diverged' } }, { defaultBranch: 'main' }); + t('a closing commit that never reached the default branch is LEFT and reported', offBranch.verdict === 'leave' && offBranch.kind === 'commit-off-branch'); + const openDelivery = judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingPrs: [{ number: 201, merged: false, baseRef: null, mergeCommitSha: null, state: 'open', fetched: false }] }, { defaultBranch: 'main' }); + t('⛔ a card whose delivery is still OPEN is left — H8\'s refusal, one layer down', openDelivery.verdict === 'leave' && openDelivery.kind === 'open-delivery'); + t('…and that refusal outranks BOTH routes, even a contained closing commit', + judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingCommit: { sha: 'deadbeef00', contained: true, status: 'behind' }, closingPrs: [{ number: 201, merged: false, baseRef: null, state: 'open', fetched: false }] }, { defaultBranch: 'main' }).kind === 'open-delivery'); + t('a pull request merged into some OTHER branch does not close the card here', + judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingPrs: [{ ...mergedPr, baseRef: 'release/v5' }] }, { defaultBranch: 'main' }).kind === 'merged-elsewhere'); + t('⛔ an UNFETCHED base is never accepted as the default branch', + judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingPrs: [{ ...mergedPr, baseRef: null, fetched: false }] }, { defaultBranch: 'main' }).verdict === 'leave'); + const unreadable = judgeCandidate(CARD(), { residue: ['pm:dispatched'], closingPrs: [{ number: 202, merged: true, baseRef: null, mergeCommitSha: null, state: 'closed', fetched: false, error: 'HTTP 502' }] }, { defaultBranch: 'main' }); + t('a card whose evidence could not be read is UNJUDGED, never silently skipped', unreadable.verdict === 'unjudged'); + t('…and the row carries why, so the next run\'s reader can act on it', String(unreadable.detail).includes('502')); + t('a card carrying two residue labels has both in the strip set', + judgeCandidate(CARD({ labels: [{ name: 'pm:dispatched' }, { name: 'pm:queue' }] }), { closingPrs: [mergedPr] }, { defaultBranch: 'main' }).residue.join() === 'pm:dispatched,pm:queue'); + + // -- the comment ----------------------------------------------------------- + battery('the comment a machine writes on someone else\'s card'); + const comment = strippedComment({ + removed: ['pm:dispatched'], + survived: ['domain:skills', 'priority:p3'], + evidence: { prs: [200], commit: 'abc1234567', containment: 'merged into `main`', branch: 'main' }, + provenance: 'run 42', + }); + t('it carries the machine-findable marker on its first line', comment.split('\n')[0].startsWith(COMMENT_MARKER)); + t('it names the labels removed', comment.includes('`pm:dispatched`')); + t('it names the labels left alone', comment.includes('`domain:skills`') && comment.includes('`priority:p3`')); + t('it names the closing pull request', comment.includes('#200')); + t('it names the closing commit, short', comment.includes('`abc1234567`')); + t('it says the read-back happened, because that is the claim a reader checks', comment.includes('read back')); + t('it carries the run provenance when a caller supplies one', comment.includes('run 42')); + t('it ends with the attribution footer', comment.trimEnd().endsWith(COMMENT_FOOTER)); + // The sanitizer eats short angle-bracket fragments, backticked or not, and a + // comment that loses its middle is a machine posting nonsense on a card. + t('⛔ it contains no angle-bracket-shaped fragment for the body sanitizer to eat', !/[<>]/.test(comment)); + + // -- the listing path, the window and the caps ----------------------------- + battery('the listing path, the window and the caps'); + t('the listing is scoped to CLOSED cards', closedListingPath('o/r', 'pm:dispatched', 1).includes('state=closed')); + t('the label is URL-encoded, so `pm:*` survives the colon', closedListingPath('o/r', 'pm:on-hold', 1).includes('labels=pm%3Aon-hold')); + t('one label per request — a multi-label query answers a different question', (closedListingPath('o/r', 'pm:queue', 1).match(/labels=/g) ?? []).length === 1); + t('the path is repo-relative, keeping this file repo-agnostic', closedListingPath('o/r', 'pm:queue', 2).startsWith('/repos/o/r/issues?')); + t('it carries the page it was asked for, at 100 rows', closedListingPath('o/r', 'pm:queue', 7).includes('page=7') && closedListingPath('o/r', 'pm:queue', 7).includes('per_page=100')); + t('the window is spelled as `since` + updated-descending, so a just-closed card is on page 1', + closedListingPath('o/r', 'pm:queue', 1, '2026-09-02T18:00:00Z').includes('sort=updated') && closedListingPath('o/r', 'pm:queue', 1, '2026-09-02T18:00:00Z').includes('since=')); + t('`--all-time` sends no `since` at all', !closedListingPath('o/r', 'pm:queue', 1, null).includes('since=')); + t('the action floor is the window subtracted from now', actionFloorMs(NOW, 24) === NOW - 86_400_000); + t('a null window is no floor — that is `--all-time`, and it is opt-in', actionFloorMs(NOW, null) === null); + t('the caps are real numbers, and the per-card pull-request read cap is one of them', DEFAULT_MAX_CARDS > 0 && LISTING_PAGE_CEILING > 0 && PR_READS_PER_CARD > 0); + + // -- the exit register ----------------------------------------------------- + battery('the exit register: nothing to do is never an alarm'); + t('a run that judged nothing at all exits 0', summariseRun([]).exitCode === EXIT_OK); + t('a run that found only clean cards exits 0', summariseRun([{ verdict: 'skip', kind: 'clean' }]).exitCode === EXIT_OK); + t('a run that stripped exits 0', summariseRun([{ verdict: 'strip' }]).exitCode === EXIT_OK); + t('a run that LEFT cards it judged exits 0 — leaving is a verdict, not a failure', summariseRun([{ verdict: 'leave' }]).exitCode === EXIT_OK); + t('one unjudged card moves the exit to 2', summariseRun([{ verdict: 'strip' }, { verdict: 'unjudged' }]).exitCode === EXIT_UNJUDGED); + t('a write that did not land is unjudged too — the card\'s state is now unknown', summariseRun([{ verdict: 'strip', writeFailed: true }]).exitCode === EXIT_UNJUDGED); + t('the counts a reader is given match the rows', summariseRun([{ verdict: 'strip' }, { verdict: 'leave' }, { verdict: 'skip' }]).counts.strip === 1); + + // -- the report ------------------------------------------------------------ + battery('the report: what a reader is told, and what it refuses to imply'); + const result = { + repo: 'o/r', + defaultBranch: 'main', + sinceIso: '2026-09-02T18:00:00Z', + listed: 3, + candidates: 2, + stats: { listingRequests: 7, evidenceRequests: 4, writeRequests: 0, truncatedLabels: ['pm:dispatched'] }, + rows: [ + row(CARD(), 'strip', { kind: 'pull-request', residue: ['pm:dispatched'], evidence: { prs: [200], commit: 'abc1234567', containment: 'merged into `main`', branch: 'main' } }), + row(CARD({ number: 101 }), 'leave', { kind: 'no-closing-delivery', residue: ['pm:dispatched'], detail: 'closed by hand' }), + row(CARD({ number: 102 }), 'skip', { kind: 'cap-deferred', residue: ['pm:queue'] }), + ], + }; + const opts = { write: false, windowHours: DEFAULT_WINDOW_HOURS, maxCards: DEFAULT_MAX_CARDS, listCap: 10 }; + const rendered = renderRun(result, opts); + t('a dry run says so in its first line — no reader should have to infer it', rendered.text.split('\n')[0].includes('DRY RUN')); + t('the window is stated, because the population it excludes is the ruling\'s subject', rendered.text.includes('2026-09-02T18:00:00Z')); + t('a bound page ceiling is announced — a short read must never read as a clean one', rendered.text.includes('BOUND the listing')); + t('a bound per-run cap is announced too, and says those cards were NOT judged', rendered.text.includes('BOUND this run') && rendered.text.includes('NOT judged')); + t('the stripped card is listed with the pull request the strip rests on', rendered.text.includes('#100') && rendered.text.includes('#200')); + t('the left card is listed with its reason', rendered.text.includes('closed by hand')); + const json = renderJson(result, opts); + t('the JSON summary carries the counts, the caps and every card', json.counts.strip === 1 && json.cap_bound === true && json.cards.length === 3); + + // -- the CLI --------------------------------------------------------------- + battery('the CLI: the one decision a typo must never make'); + t('the default is a DRY RUN — writing is something a caller has to ask for', parseOptions([]).options.write === false); + t('`--write` is the ask, and it is exact', parseOptions(['--write']).options.write === true); + t('`--dry-run` is a real flag, so the workflow can name the mode it is in', parseOptions(['--dry-run']).options.write === false); + t('⛔ the two mode flags together are a refusal, not a precedence rule', parseOptions(['--write', '--dry-run']).ok === false); + t('⛔ an unrecognised argument is REFUSED — a caller who thinks it passed a mode flag and did not is the failure this closes', + parseOptions(['--wirte']).ok === false); + t('…and the refusal names the arguments that do exist', String(parseOptions(['--nope']).error).includes('--since-hours')); + t('a bad window is refused rather than rounded to something safe-looking', parseOptions(['--since-hours=0']).ok === false && parseOptions(['--since-hours=x']).ok === false); + t('`--all-time` is the null window, and it parses', parseOptions(['--all-time']).options.windowHours === null); + t('a bad cap is refused too', parseOptions(['--max-cards=0']).ok === false); + + // -- the floor ------------------------------------------------------------- + const floorFailure = (message) => { cases.push({ name: message, ok: false }); }; + const declared = Object.keys(SELF_TEST_BATTERIES); + let breached = false; + if (declared.length < SELF_TEST_BATTERY_FLOOR) { + breached = true; + floorFailure(`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.`); + } + for (const [name, count] of batterySeen) { + if (declared.includes(name)) continue; + breached = true; + floorFailure(`self-test battery "${name}" registered ${count} case(s) but is not declared in SELF_TEST_BATTERIES — an assertion attributed to no declared battery is one nothing floors.`); + } + for (const name of declared) { + const count = batterySeen.get(name) ?? 0; + if (count >= SELF_TEST_BATTERIES[name]) continue; + breached = true; + floorFailure( + count === 0 + ? `self-test battery "${name}" DID NOT RUN — 0 cases registered, ${SELF_TEST_BATTERIES[name]} pinned. The verdict below would have claimed those cases hold.` + : `self-test battery "${name}" registered ${count} case(s), below its pinned floor of ${SELF_TEST_BATTERIES[name]} — cases that used to run no longer do.`, + ); + } + if (breached) { + floorFailure('A battery at or below its floor means cases STOPPED RUNNING — find what stopped registering and restore it.'); + } + + const failed = cases.filter((c) => !c.ok); + for (const c of failed) console.error(` ✗ ${c.name}${c.detail ? ` — ${c.detail}` : ''}`); + if (failed.length) { + console.error(`✗ sweep-closed-cards self-test: ${failed.length} of ${cases.length} case(s) failed.`); + return 1; + } + console.log( + `✓ sweep-closed-cards self-test: ${cases.length} cases pass across ${declared.length} batteries ` + + '(the imported residue set, the offline screen, the two closing routes with the measured compare ' + + 'orientation, every verdict the ruling names including the open-delivery refusal, the comment ' + + 'this sweep writes onto other people\'s cards, the window and both caps, and the exit register).', + ); + selfTestReachedVerdict = true; + return 0; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function rearmThroughProxy(args) { + const plan = proxyRearmPlan({ + env: process.env, + execArgv: process.execArgv, + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), + }); + if (plan.hint) { + console.error(`ℹ️ ${plan.reason}. A refusal below may be about the route, not this container.`); + return null; + } + if (!plan.rearm) return null; + if (process.env[PROXY_REARM_GUARD] === '1') return null; + console.error(`ℹ️ re-exec with ${plan.flag}: ${plan.reason}.`); + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; + const child = spawnSync(process.execPath, [plan.flag, ...quiet, SELF_PATH, ...args], { + stdio: 'inherit', + env: { ...process.env, [PROXY_REARM_GUARD]: '1' }, + }); + if (typeof child.status === 'number') return child.status; + console.error(`⚠️ could not re-exec with ${plan.flag} (${child.error?.message ?? 'no exit status'}); continuing in-process — every request will bypass the proxy.`); + return null; +} + +export function readOption(argv, name, fallback) { + const prefix = `--${name}=`; + const hit = (argv ?? []).find((a) => a.startsWith(prefix)); + return hit === undefined ? fallback : hit.slice(prefix.length); +} + +/** The flags this tool takes. An argument outside this set is a typo, and a typo is refused. */ +export const KNOWN_FLAGS = Object.freeze(['--write', '--dry-run', '--json', '--all-time', '--self-test', '--help', '-h']); +export const KNOWN_OPTIONS = Object.freeze(['since-hours', 'max-cards', 'list-cap', 'provenance']); + +/** + * Parse argv into the run's options, or refuse. Pure, so the refusals are + * pinned offline — this is the layer that decides whether a run WRITES, and a + * misread flag there is a machine writing to a board nobody asked it to. + * + * ⛔ An unrecognised argument is a REFUSAL, never a shrug. The dangerous + * direction is silent: a caller who believes it passed a mode flag and did not + * gets a run that behaves like the default, and the default is the only thing + * standing between a typo and a write. + */ +export function parseOptions(argv, { nowMs = Date.now() } = {}) { + const args = argv ?? []; + for (const arg of args) { + if (KNOWN_FLAGS.includes(arg)) continue; + const named = /^--([a-z-]+)=/.exec(arg); + if (named && KNOWN_OPTIONS.includes(named[1])) continue; + return { ok: false, error: `\`${arg}\` is not an argument this tool takes. Flags: ${KNOWN_FLAGS.join(' ')}; options: ${KNOWN_OPTIONS.map((o) => `--${o}=…`).join(' ')}.` }; + } + if (args.includes('--write') && args.includes('--dry-run')) { + return { ok: false, error: '--write and --dry-run together say two different things about the one decision that matters. Pass one.' }; + } + const allTime = args.includes('--all-time'); + const windowRaw = readOption(args, 'since-hours', String(DEFAULT_WINDOW_HOURS)); + const windowHours = allTime ? null : Number(windowRaw); + if (windowHours !== null && (!Number.isFinite(windowHours) || windowHours <= 0)) { + return { ok: false, error: `--since-hours=${windowRaw} is not a positive number of hours.` }; + } + const maxCards = Number(readOption(args, 'max-cards', String(DEFAULT_MAX_CARDS))); + if (!Number.isInteger(maxCards) || maxCards <= 0) { + return { ok: false, error: '--max-cards must be a positive whole number of cards.' }; + } + const listCap = Number(readOption(args, 'list-cap', '25')); + if (!Number.isInteger(listCap) || listCap <= 0) { + return { ok: false, error: '--list-cap must be a positive whole number of rows.' }; + } + return { + ok: true, + options: { + write: args.includes('--write'), + json: args.includes('--json'), + windowHours, + maxCards, + listCap, + provenance: readOption(args, 'provenance', ''), + nowMs, + }, + }; +} + +const USAGE = [ + 'sweep-closed-cards — strip the pm-loop state labels from cards GitHub closed on a merged delivery.', + '', + ' node scripts/pm/sweep-closed-cards.mjs [--write | --dry-run] [--json] [--since-hours=N | --all-time]', + ' [--max-cards=N] [--provenance=TEXT]', + ' node scripts/pm/sweep-closed-cards.mjs --self-test', + '', + ` default: DRY RUN over cards closed in the last ${DEFAULT_WINDOW_HOURS}h. --write is what acts.`, + ' --all-time removes the action floor. Read this file\'s header before passing it with --write:', + ' the unbounded write is the backfill the 2026-08-31 maintainer ruling refused by name.', +].join('\n'); + +async function main(argv) { + if (argv.includes('--help') || argv.includes('-h')) { + console.log(USAGE); + return EXIT_OK; + } + + const repoRes = resolveSweepRepo(process.env); + if (!repoRes.valid) { + console.error( + `sweep-closed-cards: ${repoRes.source}=${JSON.stringify(repoRes.repo)} is not a repository in ` + + '`owner`/`name` form. Refusing to fall back to a different board — a sweep of the wrong repo ' + + 'writes labels onto cards nobody asked about.', + ); + return EXIT_USAGE; + } + + const parsed = parseOptions(argv); + if (!parsed.ok) { + console.error(`sweep-closed-cards: ${parsed.error}`); + return EXIT_USAGE; + } + const options = parsed.options; + + if (options.write && options.windowHours === null) { + console.error( + '⚠️ --write with --all-time: this is the unbounded backfill the 2026-08-31 maintainer ruling\n' + + ' refused by name (this file\'s header quotes it). Proceeding, because a caller who typed both\n' + + ' flags asked for it — but the scheduled patrol never does, and a seat should not either\n' + + ' without a ruling that says so.', + ); + } + + let result; + try { + result = await sweep(repoRes.repo, options); + } catch (err) { + return reportPrerequisiteNotMet(err); + } + + if (options.json) { + const doc = renderJson(result, options); + console.log(JSON.stringify(doc, null, 2)); + return doc.exit_code; + } + const rendered = renderRun(result, options); + console.log(rendered.text); + return rendered.exitCode; +} + +if (isEntrypoint(import.meta.url)) { + if (process.argv.includes('--self-test')) { + const code = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ sweep-closed-cards self-test: selfTest() returned without reaching its verdict, so no\n' + + 'success line was printed. Exiting 0 here would report a self-test that never finished as\n' + + 'a self-test that passed.\n', + ); + process.exit(1); + } + process.exit(code); + } else { + const rearmed = rearmThroughProxy(process.argv.slice(2)); + if (rearmed !== null) process.exit(rearmed); + main(process.argv.slice(2)).then((code) => process.exit(code)); + } +} From 51bc005b9c352c0bfd5f1f3f643418dc11abee3f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 19:24:49 +0000 Subject: [PATCH 2/2] tooling(pm): run the sweep's own self-test before it writes, and refuse unknown flags `check-self-test-wired` is the gate that settles where the self-test belongs: CI runs this script, so CI must run its cases. The patrol step now runs them first and skips the sweep entirely if they fail -- this step writes to other people's cards, and a broken predicate that still runs is the one failure the next run cannot undo. The CLI gains `--dry-run` as a real flag so the workflow can name the mode it is in, and refuses any argument it does not know: a caller who believes it passed a mode flag and did not must not get a run that looks ordinary. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox --- .github/workflows/half-state-patrol.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml index a014506417..d725b5eefc 100644 --- a/.github/workflows/half-state-patrol.yml +++ b/.github/workflows/half-state-patrol.yml @@ -289,6 +289,28 @@ jobs: # and it is load-bearing: a PR must never write to the board. SWEEP_MODE: ${{ github.event_name == 'pull_request' && '--dry-run' || '--write' }} run: | + # The judge's own cases first, and the sweep only if they hold: this + # step WRITES to other people's cards, and a broken predicate that + # still runs is the one failure mode that cannot be undone by the next + # run. `check:pm-closed-card-sweep` is the same command under a dev + # -facing name; this is the invocation CI holds. + set +e + node scripts/pm/sweep-closed-cards.mjs --self-test > "$RUNNER_TEMP/closed-cards-selftest.log" 2>&1 + selftest=$? + set -e + cat "$RUNNER_TEMP/closed-cards-selftest.log" + if [ "$selftest" != "0" ]; then + echo "exit_code=$selftest" >> "$GITHUB_OUTPUT" + { + echo "### Closed-card sweep — SKIPPED: its own self-test failed (exit $selftest)" + echo + echo '```' + cat "$RUNNER_TEMP/closed-cards-selftest.log" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::closed-card sweep self-test failed (exit $selftest) — the sweep did NOT run and wrote nothing. Nothing here is a reading about the board." + exit 0 + fi set +e node scripts/pm/sweep-closed-cards.mjs "$SWEEP_MODE" --provenance="$PROVENANCE" \ > "$RUNNER_TEMP/closed-cards.md" 2> "$RUNNER_TEMP/closed-cards.err"