Skip to content

Commit 34c01a5

Browse files
authored
fix(pm): read the documented blockquote claim shape, and print runnable gate invocations (#7551)
check-half-states.mjs (#7488): the H2 predicate tested /^\s*Claim(?:ed)?\s*[:...]/mi, and \s does not match ">", so a claim written in the shape SKILL.md step 4 tells every seat to write ("> Claim: ...") never matched and its card was reported as a half-state. The predicate now accepts an optional leading blockquote marker. The strictness either side of it is unchanged and pinned: the line must still BEGIN with the word, so ordinary prose containing "claim" is not a claim comment. dispatch-gates.mjs (#7440): the print site emitted the bare script name, dropping the --filter package the parser already captured. A dev searching the root package.json for a package-scoped gate finds nothing and concludes the gate does not exist — two independent devs did exactly that within one hour (PR #7416, #7417) with check:doc-formula-expressions, which lives in @objectstack/lint. The matched-gate lines now print the runnable invocation; root-scoped checks print "pnpm check:x" unchanged.
1 parent 3c416a1 commit 34c01a5

2 files changed

Lines changed: 48 additions & 4 deletions

File tree

scripts/pm/check-half-states.mjs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,14 @@
3535
* assign + claim comment (state model / step 4).
3636
* H2 assignee set on a pm-tracked card, but no claim comment on the thread
3737
* (a comment whose body carries a "Claim:" line) — the assignee field
38-
* alone cannot say WHICH session owns it (step 4; #4588).
38+
* alone cannot say WHICH session owns it (step 4; #4588). The marker is
39+
* read with an OPTIONAL leading blockquote ">", because step 4's own
40+
* claim template is a blockquote (SKILL.md, "> Claim: …") — the predicate
41+
* used to reject the exact shape the skill tells every seat to write, and
42+
* reported a correctly-claimed card as a half-state (#7488, measured on
43+
* #6752). The strictness either side of that marker is deliberate and
44+
* stays: the line must BEGIN with the word, so ordinary prose containing
45+
* "claim" is not a claim comment.
3946
* H3 `pm:queue` + `pm:dispatched` both present — reads as available to the
4047
* queue view and in-flight to the lane view; neither is trustworthy
4148
* (#5925 2026-08-09 correction, the measured specimen).
@@ -84,7 +91,7 @@ export function h2AssigneeNoClaimComment(issue, commentBodies) {
8491
const labels = labelNames(issue);
8592
const pmTracked = labels.some((l) => l === 'pm:queue' || l === 'pm:dispatched');
8693
if (!pmTracked || (issue.assignees ?? []).length === 0) return false;
87-
return !commentBodies.some((b) => /^\s*Claim(?:ed)?\s*[::]/mi.test(b ?? ''));
94+
return !commentBodies.some((b) => /^\s*>?\s*Claim(?:ed)?\s*[::]/mi.test(b ?? ''));
8895
}
8996

9097
export function h3QueueAndDispatched(issue) {
@@ -212,6 +219,14 @@ function selfTest() {
212219
t('H2: assignee + no claim comment -> finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['looks good', 'triage: routed']), true);
213220
t('H2: assignee + claim comment -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['Claim: PM loop round 3\nSession: session_x']), false);
214221
t('H2: unassigned card is out of scope', h2AssigneeNoClaimComment(issue(['pm:queue']), []), false);
222+
// #7488: SKILL.md step 4's claim template IS a blockquote, so the documented
223+
// shape must read as a claim. Live specimen: #6752's "> Claim: PM loop wave 9".
224+
t('H2: blockquote claim comment (the documented shape) -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['> Claim: PM loop wave 9 (seat #6019)\n> Session: `session_x`\n> Branch: `claude/issue-6752-x`']), false);
225+
t('H2: indented blockquote claim -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), [' > Claimed: PM loop round 3']), false);
226+
// …and the strictness the relaxation must NOT cost: the line still has to
227+
// BEGIN with the word, blockquote or not (#7488's explicit width limit).
228+
t('H2: prose containing the word claim -> still a finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['Nobody will claim: this card is ready\nthe seat did not claim it']), true);
229+
t('H2: blockquoted prose containing claim -> still a finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['> the next seat should claim: only after the ruling lands']), true);
215230
t('H3: both queue labels -> finding', h3QueueAndDispatched(issue(['pm:queue', 'pm:dispatched'])), true);
216231
t('H3: dispatched alone -> clean', h3QueueAndDispatched(issue(['pm:dispatched'])), false);
217232
t('H4: blocked without body line -> finding', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream')), true);

scripts/pm/dispatch-gates.mjs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@
3232
* their sources ("watch hints"). That derivation is honest but heuristic:
3333
*
3434
* - a MATCHED check is one whose own source names a directory/file that
35-
* covers the input path — high-signal, paste it into the dispatch prompt;
35+
* covers the input path — high-signal, paste it into the dispatch prompt.
36+
* It is printed as the RUNNABLE invocation (`pnpm --filter <pkg> run
37+
* check:x` for a package-scoped gate, `pnpm check:x` for a root-scoped
38+
* one), not as the bare script name: the bare name sends a dev to the root
39+
* `package.json`, where a package-scoped gate is absent and therefore reads
40+
* as nonexistent (#7440);
3641
* - a check with NO discoverable path hints is listed once in the
3742
* "repo-wide / undetermined" bucket. It is NOT known to be irrelevant —
3843
* many gates read the whole tree (check:nul-bytes) or a convention rather
@@ -99,6 +104,22 @@ export function extractWatchHints(scriptSource) {
99104
return [...hints];
100105
}
101106

107+
/**
108+
* Render an invocation a dev can paste and run, from the same parse the
109+
* workflow line produced. The script NAME alone is not runnable for a
110+
* package-scoped check: `check:doc-formula-expressions` lives in
111+
* `@objectstack/lint`, not in the root `package.json`, so a dev who searched
112+
* the obvious place found nothing and concluded the gate did not exist — twice,
113+
* in independent sessions, within one hour (#7440, PR #7416 / #7417). The
114+
* `--filter` package is the one piece of provenance this tool parsed and then
115+
* dropped, and it is the piece needed to run the thing.
116+
*/
117+
export function runnableInvocation({ check, filter, direct }) {
118+
if (direct) return `node ${check}`; // already a script path, never a pnpm script
119+
if (filter) return `pnpm --filter ${filter} run ${check}`;
120+
return `pnpm ${check}`;
121+
}
122+
102123
/**
103124
* Does a watch hint cover an input path? Prefix either way, with globs
104125
* collapsed. A hint that collapses to a bare top-level directory name
@@ -175,7 +196,7 @@ function derive(paths) {
175196
console.log('Local gates for this card (paste into the dispatch prompt):');
176197
for (const [check, { entry, hits }] of [...matched].sort()) {
177198
const via = hits.map((h) => `${h.path} ⇢ '${h.hint}'`).join('; ');
178-
console.log(` - ${check} [${[...entry.workflows].join(', ')}] matched via ${via}`);
199+
console.log(` - ${runnableInvocation(entry)} [${[...entry.workflows].join(', ')}] matched via ${via}`);
179200
}
180201
} else {
181202
console.log('No check family names the given paths in its own source.');
@@ -213,6 +234,14 @@ function selfTest() {
213234
t('extracts direct node scripts/check-*.mjs', invs.some((i) => i.check === 'scripts/check-nul-bytes.mjs' && i.direct));
214235
t('ignores non-check runs', !invs.some((i) => String(i.check).includes('build')));
215236

237+
// #7440: the printed line must be runnable as-is. The three shapes come from
238+
// the same three fixtures above, so the sample workflow and the print site
239+
// cannot drift apart.
240+
const inv = (name) => invs.find((i) => i.check === name);
241+
t('prints a package-scoped check as its full --filter invocation', runnableInvocation(inv('check:authorable-surface')) === 'pnpm --filter @objectstack/spec run check:authorable-surface');
242+
t('prints a root-scoped check unchanged', runnableInvocation(inv('check:engine-double-contract')) === 'pnpm check:engine-double-contract');
243+
t('prints a direct script as a node invocation', runnableInvocation(inv('scripts/check-nul-bytes.mjs')) === 'node scripts/check-nul-bytes.mjs');
244+
216245
const scripts = { 'check:foo': 'node scripts/check-foo.mjs --self-test && node scripts/check-foo.mjs' };
217246
t('resolves script file from package.json', resolveCheckToFiles('check:foo', scripts).join() === 'scripts/check-foo.mjs');
218247
t('unknown check resolves to nothing', resolveCheckToFiles('check:bar', scripts).length === 0);

0 commit comments

Comments
 (0)