Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions scripts/pm/check-half-states.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 31 additions & 2 deletions scripts/pm/dispatch-gates.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pkg> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -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);
Expand Down
Loading