diff --git a/scripts/pm/check-half-states.mjs b/scripts/pm/check-half-states.mjs index b1fb20c2b8..761c5f74c9 100644 --- a/scripts/pm/check-half-states.mjs +++ b/scripts/pm/check-half-states.mjs @@ -35,7 +35,14 @@ * assign + claim comment (state model / step 4). * H2 assignee set on a pm-tracked card, but no claim comment on the thread * (a comment whose body carries a "Claim:" line) — the assignee field - * alone cannot say WHICH session owns it (step 4; #4588). + * alone cannot say WHICH session owns it (step 4; #4588). The marker is + * read with an OPTIONAL leading blockquote ">", because step 4's own + * claim template is a blockquote (SKILL.md, "> Claim: …") — the predicate + * used to reject the exact shape the skill tells every seat to write, and + * reported a correctly-claimed card as a half-state (#7488, measured on + * #6752). The strictness either side of that marker is deliberate and + * stays: the line must BEGIN with the word, so ordinary prose containing + * "claim" is not a claim comment. * H3 `pm:queue` + `pm:dispatched` both present — reads as available to the * queue view and in-flight to the lane view; neither is trustworthy * (#5925 2026-08-09 correction, the measured specimen). @@ -84,7 +91,7 @@ export function h2AssigneeNoClaimComment(issue, commentBodies) { const labels = labelNames(issue); const pmTracked = labels.some((l) => l === 'pm:queue' || l === 'pm:dispatched'); if (!pmTracked || (issue.assignees ?? []).length === 0) return false; - return !commentBodies.some((b) => /^\s*Claim(?:ed)?\s*[::]/mi.test(b ?? '')); + return !commentBodies.some((b) => /^\s*>?\s*Claim(?:ed)?\s*[::]/mi.test(b ?? '')); } export function h3QueueAndDispatched(issue) { @@ -212,6 +219,14 @@ function selfTest() { t('H2: assignee + no claim comment -> finding', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['looks good', 'triage: routed']), true); t('H2: assignee + claim comment -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), ['Claim: PM loop round 3\nSession: session_x']), false); t('H2: unassigned card is out of scope', h2AssigneeNoClaimComment(issue(['pm:queue']), []), false); + // #7488: SKILL.md step 4's claim template IS a blockquote, so the documented + // shape must read as a claim. Live specimen: #6752's "> Claim: PM loop wave 9". + 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); + t('H2: indented blockquote claim -> clean', h2AssigneeNoClaimComment(issue(['pm:dispatched'], ['os-help']), [' > Claimed: PM loop round 3']), false); + // …and the strictness the relaxation must NOT cost: the line still has to + // BEGIN with the word, blockquote or not (#7488's explicit width limit). + 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); + 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); t('H3: both queue labels -> finding', h3QueueAndDispatched(issue(['pm:queue', 'pm:dispatched'])), true); t('H3: dispatched alone -> clean', h3QueueAndDispatched(issue(['pm:dispatched'])), false); t('H4: blocked without body line -> finding', h4BlockedNoBlockedBy(issue(['pm:blocked'], [], 'waiting on upstream')), true); diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index a3d862284e..f736f360f1 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -32,7 +32,12 @@ * their sources ("watch hints"). That derivation is honest but heuristic: * * - a MATCHED check is one whose own source names a directory/file that - * covers the input path — high-signal, paste it into the dispatch prompt; + * covers the input path — high-signal, paste it into the dispatch prompt. + * It is printed as the RUNNABLE invocation (`pnpm --filter run + * check:x` for a package-scoped gate, `pnpm check:x` for a root-scoped + * one), not as the bare script name: the bare name sends a dev to the root + * `package.json`, where a package-scoped gate is absent and therefore reads + * as nonexistent (#7440); * - a check with NO discoverable path hints is listed once in the * "repo-wide / undetermined" bucket. It is NOT known to be irrelevant — * many gates read the whole tree (check:nul-bytes) or a convention rather @@ -99,6 +104,22 @@ export function extractWatchHints(scriptSource) { return [...hints]; } +/** + * Render an invocation a dev can paste and run, from the same parse the + * workflow line produced. The script NAME alone is not runnable for a + * package-scoped check: `check:doc-formula-expressions` lives in + * `@objectstack/lint`, not in the root `package.json`, so a dev who searched + * the obvious place found nothing and concluded the gate did not exist — twice, + * in independent sessions, within one hour (#7440, PR #7416 / #7417). The + * `--filter` package is the one piece of provenance this tool parsed and then + * dropped, and it is the piece needed to run the thing. + */ +export function runnableInvocation({ check, filter, direct }) { + if (direct) return `node ${check}`; // already a script path, never a pnpm script + if (filter) return `pnpm --filter ${filter} run ${check}`; + return `pnpm ${check}`; +} + /** * Does a watch hint cover an input path? Prefix either way, with globs * collapsed. A hint that collapses to a bare top-level directory name @@ -175,7 +196,7 @@ function derive(paths) { console.log('Local gates for this card (paste into the dispatch prompt):'); for (const [check, { entry, hits }] of [...matched].sort()) { const via = hits.map((h) => `${h.path} ⇢ '${h.hint}'`).join('; '); - console.log(` - ${check} [${[...entry.workflows].join(', ')}] matched via ${via}`); + console.log(` - ${runnableInvocation(entry)} [${[...entry.workflows].join(', ')}] matched via ${via}`); } } else { console.log('No check family names the given paths in its own source.'); @@ -213,6 +234,14 @@ function selfTest() { t('extracts direct node scripts/check-*.mjs', invs.some((i) => i.check === 'scripts/check-nul-bytes.mjs' && i.direct)); t('ignores non-check runs', !invs.some((i) => String(i.check).includes('build'))); + // #7440: the printed line must be runnable as-is. The three shapes come from + // the same three fixtures above, so the sample workflow and the print site + // cannot drift apart. + const inv = (name) => invs.find((i) => i.check === name); + t('prints a package-scoped check as its full --filter invocation', runnableInvocation(inv('check:authorable-surface')) === 'pnpm --filter @objectstack/spec run check:authorable-surface'); + t('prints a root-scoped check unchanged', runnableInvocation(inv('check:engine-double-contract')) === 'pnpm check:engine-double-contract'); + t('prints a direct script as a node invocation', runnableInvocation(inv('scripts/check-nul-bytes.mjs')) === 'node scripts/check-nul-bytes.mjs'); + const scripts = { 'check:foo': 'node scripts/check-foo.mjs --self-test && node scripts/check-foo.mjs' }; t('resolves script file from package.json', resolveCheckToFiles('check:foo', scripts).join() === 'scripts/check-foo.mjs'); t('unknown check resolves to nothing', resolveCheckToFiles('check:bar', scripts).length === 0);