Skip to content

Commit ea495be

Browse files
committed
feat(scripts): pm patrol tooling — half-state sweeper, dispatch-gates deriver, SKILL line ratchet (#7341 PR C)
Three zero-dependency scripts under scripts/pm/, each with --self-test: - check-half-states.mjs: report-only enumeration of the label/assignee half-states the dispatch protocol forbids (pm:dispatched without assignee, assignee without claim comment, pm:queue+pm:dispatched, pm:blocked without Blocked-by:, seat-sticker title/assignee desync). Deliberately not a gate: a completed sweep exits 0 regardless of findings; only an unrunnable sweep is non-zero. - dispatch-gates.mjs: maps a card's file surface to the check:* families that watch it, derived at runtime from .github/workflows/*.yml + package.json + the check scripts' own path literals — no hardcoded list (the #6492/#6865 lesson). - check-skill-line-ratchet.mjs: shrink-only ceiling (3050) on .claude/skills/pm-dispatch/SKILL.md, wired into lint.yml as check:pm-skill-ratchet; lower freely, raise only with a maintainer ruling quoted in the raising PR. SKILL.md gains two one-line pointers: the standby-patrol section names the half-state sweeper; step 5's gate-sourcing block names dispatch-gates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QrT21cGQfFVDWtVJysRtXo
1 parent c187173 commit ea495be

6 files changed

Lines changed: 636 additions & 1 deletion

File tree

.claude/skills/pm-dispatch/SKILL.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1727,7 +1727,8 @@ an issue comment with the os-dev-report marker, then as your final message.
17271727
里)。所以派发流程里写**取数命令**,不写清单本身:
17281728

17291729
```bash
1730-
grep -rn 'pnpm.*check:' .github/workflows/*.yml # 门禁清单当场取数
1730+
grep -rn 'pnpm.*check:' .github/workflows/*.yml # 门禁清单当场取数
1731+
node scripts/pm/dispatch-gates.mjs <改动路径> [<路径>…] # 文件面 → 该跑的门禁族,现场推导可贴
17311732
```
17321733

17331734
**取数的是 PM,不是 dev** —— 产出是「本卡该跑的**那几族**」,填进模板那一行。
@@ -2905,6 +2906,10 @@ Stop the loop and report when any of these hits:
29052906
4. 决策箱提醒 —— 仅在轮次报告中列出待决清单,⛔ 不 nag 维护者;
29062907
5. 跨车道备忘跟进(转席单、`Blocked-by:` 链的对侧动静)。
29072908

2909+
半状态巡查的机械辅助:`node scripts/pm/check-half-states.mjs` —— report-only 枚举
2910+
label/assignee 半状态(H1–H5,清单在脚本头),任何座位手动跑;⛔ 不是门禁,产出是
2911+
巡查输入。
2912+
29082913
退场只有两个入口:维护者的交接令(走座位贴协议的交接收尾清单),或座位被
29092914
惰性回收。
29102915

.github/workflows/lint.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ jobs:
150150
- name: Stack-collection enumerations answerable to stack.zod.ts
151151
run: pnpm check:stack-collection-maps
152152

153+
# pm-dispatch SKILL.md line ratchet (#7341 / #5925 item 7): the PM skill
154+
# is read in full by every seat session and every Routine fire, so its
155+
# size is a per-round tax on the whole fleet. The #7341 extraction moved
156+
# long incident narratives to .claude/skills/pm-dispatch/references/
157+
# (append-only case law, loaded on demand); this shrink-only ceiling
158+
# keeps them from growing back. Ceiling discipline — lower freely, raise
159+
# only with a maintainer ruling quoted in the raising PR — is stated in
160+
# the script's header, which is authoritative.
161+
- name: PM skill line ratchet
162+
run: pnpm check:pm-skill-ratchet
163+
153164
# Docs/skills authoring guard (#2035 / ADR-0059): TS code blocks in
154165
# Markdown/MDX are not type-checked or ESLinted, so skills/ and
155166
# content/docs/ can drift back to teaching the bare `: Page = {}` literal

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"check:skill-frame-freshness": "node scripts/check-skill-frame-freshness.mjs --self-test && node scripts/check-skill-frame-freshness.mjs",
4444
"check:skill-compatibility": "node scripts/check-skill-compatibility-version.mjs --self-test && node scripts/check-skill-compatibility-version.mjs",
4545
"check:agent-model-declared": "node scripts/check-agent-model-declared.mjs --self-test && node scripts/check-agent-model-declared.mjs",
46+
"check:pm-skill-ratchet": "node scripts/pm/check-skill-line-ratchet.mjs --self-test && node scripts/pm/check-skill-line-ratchet.mjs",
4647
"check:adr-anchors": "node scripts/check-adr-anchors.mjs --self-test && node scripts/check-adr-anchors.mjs",
4748
"check:adr-links": "node scripts/check-adr-links.mjs --self-test && node scripts/check-adr-links.mjs",
4849
"check:adr-merge-approval": "node scripts/check-adr-merge-approval.mjs --self-test && node scripts/check-adr-merge-approval.mjs",

scripts/pm/check-half-states.mjs

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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

Comments
 (0)