|
| 1 | +#!/usr/bin/env node |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | + |
| 4 | +/** |
| 5 | + * PM half-state sweeper (#7341 item 2) — REPORT-ONLY enumeration of the |
| 6 | + * label/assignee invariants the dispatch protocol calls "过夜半状态". |
| 7 | + * |
| 8 | + * node scripts/pm/check-half-states.mjs # sweep the live repo |
| 9 | + * node scripts/pm/check-half-states.mjs --self-test # verify the predicates offline |
| 10 | + * |
| 11 | + * ## Why report-only, and why the exit code is ALWAYS 0 on a completed sweep |
| 12 | + * |
| 13 | + * The pm-dispatch state model (.claude/skills/pm-dispatch/SKILL.md, "State |
| 14 | + * model") says the labels ARE the state machine, and its label discipline says |
| 15 | + * 「状态变更不过夜」: a label applied without its paired signal is a state no |
| 16 | + * sweep can interpret. Those half-states occur in practice — a card carried |
| 17 | + * `pm:queue` AND `pm:dispatched` simultaneously for ~14 hours (#5925's |
| 18 | + * 2026-08-09 correction comment); another sat dispatched with an assignee and |
| 19 | + * no claim for 48h+ (the #5925 stale-claim reclaim) — and today finding them |
| 20 | + * is a manual read of every card. This script is the mechanical enumerator. |
| 21 | + * |
| 22 | + * It is deliberately NOT a gate: a half-state is a fact about a live, shared |
| 23 | + * board, not about the PR that happens to run CI next — failing an unrelated |
| 24 | + * PR over board state would punish the wrong actor (the same reasoning that |
| 25 | + * keeps `check:platform-checklist` out of CI, lint.yml's own note). So a |
| 26 | + * completed sweep exits 0 whether it found 0 or 40 violations; the findings |
| 27 | + * are the output, and the consumer is a PM seat's patrol round (the standby |
| 28 | + * posture in SKILL.md documents the invocation). Only a sweep that could not |
| 29 | + * run (network, auth, bad usage) exits non-zero — per #4690, "could not read |
| 30 | + * the input" must never look like "input is clean". |
| 31 | + * |
| 32 | + * ## The invariants (each names its protocol source) |
| 33 | + * |
| 34 | + * H1 `pm:dispatched` with no assignee — dispatch marks a claim; a claim is |
| 35 | + * assign + claim comment (state model / step 4). |
| 36 | + * H2 assignee set on a pm-tracked card, but no claim comment on the thread |
| 37 | + * (a comment whose body carries a "Claim:" line) — the assignee field |
| 38 | + * alone cannot say WHICH session owns it (step 4; #4588). |
| 39 | + * H3 `pm:queue` + `pm:dispatched` both present — reads as available to the |
| 40 | + * queue view and in-flight to the lane view; neither is trustworthy |
| 41 | + * (#5925 2026-08-09 correction, the measured specimen). |
| 42 | + * H4 `pm:blocked` without a `Blocked-by:` body line — the machine half of |
| 43 | + * the label is the body line; without it the unlock sweep can never |
| 44 | + * return the card (state model, label discipline). |
| 45 | + * H5 `pm:seat` sticker whose title/assignee pair is out of sync — the |
| 46 | + * seat-sticker protocol makes 标题、assignee、正文 a same-write triple: |
| 47 | + * a title claiming 🟢 <login> must have that login as assignee; a title |
| 48 | + * claiming ⏳ vacant must have none. (Routine seats declare 🟢 Routine |
| 49 | + * and are exempt from the assignee half — bots can't be assigned.) |
| 50 | + * |
| 51 | + * The body half of H5 (the 「当前 PM」 paragraph) is NOT machine-checked here: |
| 52 | + * seat-sticker bodies are prose with no pinned grammar, and a fuzzy parser |
| 53 | + * would report phantom desyncs — the #4690 shape in mirror image. The |
| 54 | + * title/assignee pair is the mechanical half; the sweep prints the sticker |
| 55 | + * URL so the patrol reads the body itself. |
| 56 | + * |
| 57 | + * Auth: uses GITHUB_TOKEN / GH_TOKEN when present (unauthenticated works at |
| 58 | + * 60 req/h — enough for a small board, not for comment-fetching sweeps). |
| 59 | + * REST only, never GraphQL (Operational notes 3: the loop's hot path stays on |
| 60 | + * the core quota). |
| 61 | + */ |
| 62 | + |
| 63 | +import process from 'node:process'; |
| 64 | + |
| 65 | +const OWNER_REPO = process.env.PM_SWEEP_REPO ?? 'objectstack-ai/objectstack'; |
| 66 | +const API = 'https://api.github.com'; |
| 67 | +const TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''; |
| 68 | + |
| 69 | +// --------------------------------------------------------------------------- |
| 70 | +// Predicates — pure functions over the REST issue shape, so the self-test can |
| 71 | +// drive them with fixtures and the live sweep stays a thin fetch loop. |
| 72 | +// --------------------------------------------------------------------------- |
| 73 | + |
| 74 | +export function labelNames(issue) { |
| 75 | + return (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)); |
| 76 | +} |
| 77 | + |
| 78 | +export function h1DispatchedNoAssignee(issue) { |
| 79 | + const labels = labelNames(issue); |
| 80 | + return labels.includes('pm:dispatched') && (issue.assignees ?? []).length === 0; |
| 81 | +} |
| 82 | + |
| 83 | +export function h2AssigneeNoClaimComment(issue, commentBodies) { |
| 84 | + const labels = labelNames(issue); |
| 85 | + const pmTracked = labels.some((l) => l === 'pm:queue' || l === 'pm:dispatched'); |
| 86 | + if (!pmTracked || (issue.assignees ?? []).length === 0) return false; |
| 87 | + return !commentBodies.some((b) => /^\s*Claim(?:ed)?\s*[::]/mi.test(b ?? '')); |
| 88 | +} |
| 89 | + |
| 90 | +export function h3QueueAndDispatched(issue) { |
| 91 | + const labels = labelNames(issue); |
| 92 | + return labels.includes('pm:queue') && labels.includes('pm:dispatched'); |
| 93 | +} |
| 94 | + |
| 95 | +export function h4BlockedNoBlockedBy(issue) { |
| 96 | + const labels = labelNames(issue); |
| 97 | + if (!labels.includes('pm:blocked')) return false; |
| 98 | + return !/^\s*Blocked-by:\s*\S/m.test(issue.body ?? ''); |
| 99 | +} |
| 100 | + |
| 101 | +// H5 returns null (in sync), a string naming the desync, or undefined when the |
| 102 | +// title doesn't parse as a seat sticker (reported as its own finding — an |
| 103 | +// unparseable status board row is a desync of the board itself). |
| 104 | +export function h5SeatStickerDesync(issue) { |
| 105 | + const m = /^\[PM seat\]\s*(.*?)\s*—\s*(.*)$/u.exec(issue.title ?? ''); |
| 106 | + if (!m) return 'title does not match 「[PM seat] <seat> — <status>」'; |
| 107 | + const status = m[2].trim(); |
| 108 | + const assignees = (issue.assignees ?? []).map((a) => a.login); |
| 109 | + if (status.startsWith('🟢')) { |
| 110 | + const holder = status.replace('🟢', '').trim(); |
| 111 | + if (holder === 'Routine') return null; // Routine seats keep assignee empty by design |
| 112 | + if (!assignees.includes(holder)) { |
| 113 | + return `title says 🟢 ${holder} but assignees are [${assignees.join(', ') || 'none'}]`; |
| 114 | + } |
| 115 | + return null; |
| 116 | + } |
| 117 | + if (status.startsWith('⏳')) { |
| 118 | + return assignees.length > 0 |
| 119 | + ? `title says ⏳ vacant but assignees are [${assignees.join(', ')}]` |
| 120 | + : null; |
| 121 | + } |
| 122 | + if (status.startsWith('⏸️') || status.startsWith('⏸')) return null; // paused: assignee state is the maintainer's call |
| 123 | + return `unrecognized status word 「${status}」`; |
| 124 | +} |
| 125 | + |
| 126 | +// --------------------------------------------------------------------------- |
| 127 | +// Live sweep |
| 128 | +// --------------------------------------------------------------------------- |
| 129 | + |
| 130 | +async function rest(path) { |
| 131 | + const res = await fetch(`${API}${path}`, { |
| 132 | + headers: { |
| 133 | + accept: 'application/vnd.github+json', |
| 134 | + ...(TOKEN ? { authorization: `Bearer ${TOKEN}` } : {}), |
| 135 | + }, |
| 136 | + }); |
| 137 | + if (!res.ok) throw new Error(`GET ${path} -> HTTP ${res.status}`); |
| 138 | + return res.json(); |
| 139 | +} |
| 140 | + |
| 141 | +async function listIssues(label) { |
| 142 | + const out = []; |
| 143 | + for (let page = 1; page <= 10; page++) { |
| 144 | + const batch = await rest( |
| 145 | + `/repos/${OWNER_REPO}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100&page=${page}`, |
| 146 | + ); |
| 147 | + out.push(...batch.filter((i) => !i.pull_request)); |
| 148 | + if (batch.length < 100) break; |
| 149 | + } |
| 150 | + return out; |
| 151 | +} |
| 152 | + |
| 153 | +async function sweep() { |
| 154 | + const findings = []; |
| 155 | + const seen = new Map(); |
| 156 | + for (const label of ['pm:dispatched', 'pm:queue', 'pm:blocked', 'pm:seat']) { |
| 157 | + for (const issue of await listIssues(label)) seen.set(issue.number, issue); |
| 158 | + } |
| 159 | + |
| 160 | + for (const issue of seen.values()) { |
| 161 | + const labels = labelNames(issue); |
| 162 | + if (h1DispatchedNoAssignee(issue)) { |
| 163 | + findings.push([issue, 'H1', '`pm:dispatched` with no assignee']); |
| 164 | + } |
| 165 | + if (h3QueueAndDispatched(issue)) { |
| 166 | + findings.push([issue, 'H3', '`pm:queue` and `pm:dispatched` both present']); |
| 167 | + } |
| 168 | + if (h4BlockedNoBlockedBy(issue)) { |
| 169 | + findings.push([issue, 'H4', '`pm:blocked` without a `Blocked-by:` body line']); |
| 170 | + } |
| 171 | + if (labels.includes('pm:seat')) { |
| 172 | + const desync = h5SeatStickerDesync(issue); |
| 173 | + if (desync) findings.push([issue, 'H5', desync]); |
| 174 | + } else if ((issue.assignees ?? []).length > 0 && labels.some((l) => l.startsWith('pm:'))) { |
| 175 | + // H2 needs the comment thread — fetched only for candidates, and only |
| 176 | + // their first pages: a claim comment is posted at claim time, so on a |
| 177 | + // healthy card it is early in the thread; a >100-comment card with a |
| 178 | + // late claim shows up as a finding the patrol then reads by hand. |
| 179 | + const comments = await rest(`/repos/${OWNER_REPO}/issues/${issue.number}/comments?per_page=100`); |
| 180 | + if (h2AssigneeNoClaimComment(issue, comments.map((c) => c.body))) { |
| 181 | + findings.push([issue, 'H2', 'assignee set but no claim comment on the thread']); |
| 182 | + } |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + findings.sort((a, b) => a[0].number - b[0].number); |
| 187 | + for (const [issue, code, msg] of findings) { |
| 188 | + console.log(` ${code} #${issue.number} ${msg}\n ${issue.html_url}`); |
| 189 | + } |
| 190 | + console.log( |
| 191 | + `check-half-states: swept ${seen.size} open pm-labeled issue(s) in ${OWNER_REPO} — ` + |
| 192 | + `${findings.length} half-state(s) found. Report-only: findings are patrol input, not a gate verdict.`, |
| 193 | + ); |
| 194 | +} |
| 195 | + |
| 196 | +// --------------------------------------------------------------------------- |
| 197 | +// Self-test — predicates only; no network. |
| 198 | +// --------------------------------------------------------------------------- |
| 199 | + |
| 200 | +function selfTest() { |
| 201 | + const cases = []; |
| 202 | + const t = (name, actual, expected) => cases.push([name, actual, expected]); |
| 203 | + const issue = (labels, assignees = [], body = '', title = '') => ({ |
| 204 | + labels: labels.map((name) => ({ name })), |
| 205 | + assignees: assignees.map((login) => ({ login })), |
| 206 | + body, |
| 207 | + title, |
| 208 | + }); |
| 209 | + |
| 210 | + t('H1: dispatched + no assignee -> finding', h1DispatchedNoAssignee(issue(['pm:dispatched'])), true); |
| 211 | + t('H1: dispatched + assignee -> clean', h1DispatchedNoAssignee(issue(['pm:dispatched'], ['os-help'])), false); |
| 212 | + t('H2: assignee + no claim comment -> finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['looks good', 'triage: routed']), true); |
| 213 | + t('H2: assignee + claim comment -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['Claim: PM loop round 3\nSession: session_x']), false); |
| 214 | + t('H2: unassigned card is out of scope', h2AssigneeNoClaimComment(issue(['pm:queue']), []), false); |
| 215 | + t('H3: both queue labels -> finding', h3QueueAndDispatched(issue(['pm:queue', 'pm:dispatched'])), true); |
| 216 | + t('H3: dispatched alone -> clean', h3QueueAndDispatched(issue(['pm:dispatched'])), false); |
| 217 | + t('H4: blocked without body line -> finding', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream')), true); |
| 218 | + t('H4: blocked with Blocked-by line -> clean', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'Blocked-by: #123')), false); |
| 219 | + t('H4: unblocked card is out of scope', h4BlockedNoBlockedBy(issue([], [], '')), false); |
| 220 | + t('H5: 🟢 login matching assignee -> clean', h5SeatStickerDesync(issue(['pm:seat'], ['os-zhuang'], '', '[PM seat] domain:devx — 🟢 os-zhuang')), null); |
| 221 | + t('H5: 🟢 login without assignee -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] domain:devx — 🟢 os-zhuang')), 'string'); |
| 222 | + t('H5: ⏳ vacant with assignee -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], ['os-help'], '', '[PM seat] domain:cli — ⏳ vacant')), 'string'); |
| 223 | + t('H5: ⏳ vacant clean', h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] domain:cli — ⏳ vacant')), null); |
| 224 | + t('H5: Routine seat needs no assignee', h5SeatStickerDesync(issue(['pm:seat'], [], '', '[PM seat] 分诊 — 🟢 Routine')), null); |
| 225 | + t('H5: unparseable title -> finding', typeof h5SeatStickerDesync(issue(['pm:seat'], [], '', 'devx seat registry')), 'string'); |
| 226 | + |
| 227 | + let failed = 0; |
| 228 | + for (const [name, actual, expected] of cases) { |
| 229 | + const ok = actual === expected; |
| 230 | + if (!ok) failed++; |
| 231 | + console.log(` ${ok ? '✓' : '✗'} ${name}${ok ? '' : ` (got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)})`}`); |
| 232 | + } |
| 233 | + if (failed) { |
| 234 | + console.error(`✗ check-half-states self-test: ${failed} of ${cases.length} case(s) failed.`); |
| 235 | + process.exit(1); |
| 236 | + } |
| 237 | + console.log(`✓ check-half-states self-test: ${cases.length} cases pass.`); |
| 238 | +} |
| 239 | + |
| 240 | +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].split('/').pop()); |
| 241 | +if (isMain) { |
| 242 | + if (process.argv.includes('--self-test')) { |
| 243 | + selfTest(); |
| 244 | + } else { |
| 245 | + sweep().catch((err) => { |
| 246 | + // A sweep that could not run must not read as a clean board (#4690). |
| 247 | + console.error(`check-half-states: sweep failed to run — ${err.message}`); |
| 248 | + process.exit(2); |
| 249 | + }); |
| 250 | + } |
| 251 | +} |
0 commit comments