@dev-loops/core 1.0.2-pre.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,36 +1,22 @@
1
1
  /**
2
2
  * Deterministic issue refinement-artifact detection.
3
3
  *
4
- * Implements the bounded refinement check required by the draft gate per
5
- * issue #532, reshaped by #1951 to honor "matrix on the issue, checklist on
6
- * the PR" WITHOUT duplicate issue-side checklists: a draft PR cannot leave
7
- * draft unless the linked issue carries the authoritative semantic AC→DoD
8
- * mapping MATRIX (a real two-column table mapping each acceptance-criterion
9
- * outcome to its required completion evidence) plus an explicit Non-goals
10
- * section — or a linked refinement doc that is a complete artifact on its own
11
- * (the doc carries the matrix). Interactive issue-side Acceptance criteria /
12
- * Definition of done CHECKLISTS are NO LONGER required merely to satisfy
13
- * detection (#1951 AC1): the matrix is the authoritative issue artifact, and
14
- * the PR carries the derived self-contained list-form AC/DoD checklists
15
- * (`derivePrChecklistsFromIssueMatrix`; the PR body is validated by
4
+ * The authoritative refined-issue artifact is the semantic AC→DoD mapping
5
+ * MATRIX (a two-column table mapping each acceptance-criterion outcome to its
6
+ * required completion evidence) plus an explicit Non-goals section — or a
7
+ * linked refinement doc that is a complete artifact on its own (the doc carries
8
+ * the matrix). Interactive issue-side AC/DoD checklists are NOT a substitute
9
+ * for the matrix; the PR carries the derived self-contained list-form
10
+ * checklists (`derivePrChecklistsFromIssueMatrix`; the PR body is validated by
16
11
  * `validateTrackerBackedPrBodySpec`, never this predicate).
17
12
  *
18
13
  * Detection validates the structural PRESENCE and SHAPE of the mapping table,
19
- * not its semantic truthfulness (that stays a reviewer responsibility). An
20
- * issue whose AC content, DoD content, and Non-goals are present but whose
21
- * mapping table is absent, empty, malformed, or identifier-only fails closed
22
- * (#1951 AC2) with the matching finding (`missing_ac_dod_matrix`,
23
- * `malformed_ac_dod_matrix`, `missing_explicit_non_goals`, or
24
- * `missing_refinement_artifact`); prose-only issues (Problem / Root Cause /
25
- * Fix) cause the draft gate to post `verdict=blocked` with the
26
- * `missing_refinement_artifact` finding.
27
- *
28
- * Migration (#1951 AC7/D7): existing checklist-bearing issues stay readable —
29
- * the parser still extracts their AC/DoD checklist content — but a body that
30
- * carries only checklists and no mapping matrix now fails closed with
31
- * `missing_ac_dod_matrix` and is re-grilled (loop-grill synthesizes the
32
- * matrix) rather than being silently grandfathered. No compatibility alias is
33
- * retained.
14
+ * not its semantic truthfulness (a reviewer responsibility). A matrix that is
15
+ * absent, empty, malformed, or identifier-only fails closed with the matching
16
+ * finding (`missing_ac_dod_matrix`, `malformed_ac_dod_matrix`,
17
+ * `missing_explicit_non_goals`, or `missing_refinement_artifact`). A body
18
+ * carrying only checklists and no matrix fails closed and is re-grilled; no
19
+ * compatibility alias is retained.
34
20
  */
35
21
  import { existsSync } from "node:fs";
36
22
  import path from "node:path";
@@ -57,11 +43,9 @@ export const REFINEMENT_SOURCE = Object.freeze({
57
43
 
58
44
  const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
59
45
 
60
- // REFINEMENT_ARTIFACT_SOURCES: the refinement floor vocabulary (#1951). The
61
- // floor is the authoritative AC→DoD mapping MATRIX plus an explicit Non-goals
62
- // section (a linked refinement doc remains a complete artifact on its own) —
63
- // this list is the shape of a COMPLETE artifact, not a menu where any one
64
- // entry suffices.
46
+ // REFINEMENT_ARTIFACT_SOURCES: the shape of a COMPLETE refinement artifact,
47
+ // not a menu where any one entry suffices. A linked refinement doc
48
+ // remains a complete artifact on its own.
65
49
  export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
66
50
  "AC→DoD mapping matrix (a two-column table)",
67
51
  "explicit Non-goals section",
@@ -69,30 +53,25 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
69
53
  ]);
70
54
 
71
55
  /**
72
- * #1866: finding reported when the issue body carries a refinement artifact
73
- * (AC/DoD checklist or a resolvable linked doc) but no explicit Non-goals
74
- * section. Mirrors the PR-path narrative-invariant code
75
- * (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.code`) so both spec surfaces
76
- * name the missing invariant identically.
56
+ * Finding: refinement artifact present but no explicit Non-goals section.
57
+ * Mirrors the PR-path code (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.code`)
58
+ * so both spec surfaces name the missing invariant identically.
77
59
  */
78
60
  export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
79
61
 
80
62
  /**
81
- * #1951: finding reported when the issue body carries refinement content (AC
82
- * content, DoD content, and/or a Non-goals section) but NO authoritative
83
- * AC→DoD mapping matrix table. Under "matrix on the issue, checklist on the
84
- * PR" the mapping table is the authoritative issue artifact; interactive
85
- * issue-side AC/DoD checklists are not a substitute for it. Fails closed so
86
- * the issue is re-grilled to add the matrix.
63
+ * Finding: refinement content present but NO authoritative AC→DoD mapping
64
+ * matrix table. The mapping table is the authoritative issue artifact;
65
+ * issue-side checklists are not a substitute. Fails closed so the issue is
66
+ * re-grilled to add the matrix.
87
67
  */
88
68
  export const MISSING_AC_DOD_MATRIX_FINDING = "missing_ac_dod_matrix";
89
69
 
90
70
  /**
91
- * #1951: finding reported when the issue body carries an AC→DoD mapping table
92
- * but it is empty (header/separator only, no data rows) or identifier-only /
93
- * tautological (cells such as `AC1 → D1` with no concrete criterion or
94
- * completion-evidence prose). Structural shape validation only — semantic
95
- * truthfulness of the mapping stays a reviewer responsibility.
71
+ * Finding: an AC→DoD mapping table is present but empty (header/separator only)
72
+ * or identifier-only/tautological (cells such as `AC1 → D1` with no concrete
73
+ * criterion or evidence prose). Structural shape validation only — semantic
74
+ * truthfulness stays a reviewer responsibility.
96
75
  */
97
76
  export const MALFORMED_AC_DOD_MATRIX_FINDING = "malformed_ac_dod_matrix";
98
77
 
@@ -104,47 +83,35 @@ export const MALFORMED_AC_DOD_MATRIX_FINDING = "malformed_ac_dod_matrix";
104
83
  * - one DoD-style section (DoD or Definition of Done)
105
84
  */
106
85
  const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
107
- // #1877 round-6: index 0 is the exact-canonical ANCHOR family —
108
- // `^acceptance criteria\b` — so a decorated-variant canonical heading
109
- // (`## Acceptance criteria (v2)`, `## Definition of done — core`) still lands
110
- // in the exact bucket rather than matching NO pattern at all (the alias
111
- // families anchor on the `AC`/`DoD` abbreviations and never fire for the
112
- // spelled-out phrase). The anchor stays distinct from the alias family below
113
- // (`/^ac\b/`), so the precedence contract is unchanged: a spelled-out
114
- // canonical heading always outranks an abbreviation-shaped alias heading.
86
+ // Index 0 is the exact-canonical ANCHOR (`^acceptance criteria\b`); the rest
87
+ // are aliases. A decorated-variant canonical heading (`## Acceptance criteria
88
+ // (v2)`) still lands in the exact bucket rather than matching no pattern. The
89
+ // anchor stays distinct from the `/^ac\b/` alias, so a spelled-out canonical
90
+ // heading always outranks an abbreviation-shaped alias heading.
115
91
  /^acceptance criteria\b.*$/i,
116
92
  /^ac\b.*$/i,
117
93
  ]);
118
94
 
119
95
  const DOD_SECTION_PATTERNS = Object.freeze([
120
- // Same anchor-family widening as the AC family (see above). #1877 round-7:
121
- // the ALIAS arms are widened symmetrically with the AC family too — a
122
- // decorated-variant alias heading (`## DoD (v2)`, `## Done — core`) must land
123
- // in the alias bucket, not in NO bucket (a `$`-anchored alias silently
124
- // disarms the PR-side DoD read and false-blocks the issue side).
96
+ // Same anchor-family widening as the AC family. A decorated-variant alias
97
+ // heading (`## DoD (v2)`) must land in the alias bucket, not in no bucket: a
98
+ // `$`-anchored alias silently disarms the PR-side DoD read and false-blocks
99
+ // the issue side.
125
100
  /^definition of done\b.*$/i,
126
101
  /^done\b.*$/i,
127
102
  /^dod\b.*$/i,
128
103
  ]);
129
104
 
130
105
  /**
131
- * Normalize a heading name before section-pattern matching (#1877 round-6
132
- * parser hardening): GitHub authors legitimately write decorated canonical
133
- * headings — `## **Acceptance criteria**`, `## Acceptance criteria:`,
134
- * `## Acceptance criteria ##` — and the raw ATX capture (`match[2]`)
135
- * fails every pattern family on them, silently disarming the deterministic
136
- * AC/DoD reads (PR-side extractor fail-open; issue-side false
137
- * missing_refinement_artifact). Strip the harmless decoration once, at the
138
- * parse boundary, so exact-vs-alias precedence stays intact: a normalized
139
- * `Acceptance criteria` still matches the exact pattern, a decorated alias
140
- * still matches its alias family. Strips: surrounding emphasis runs of any
141
- * of `*`/`_` (bold `**`/`__` and single-char italic `*`/`_` alike, #1877
142
- * round-7), surrounding backtick runs, trailing `:` and surrounding
143
- * whitespace.
144
- * Deliberately NOT touched: interior text (a real `AC (v2) - final` name keeps
145
- * its interior), leading `#` (ATX markers never reach `match[2]`), and any
146
- * decoration a section pattern itself could rely on (none does — every family
147
- * anchors at the name's start).
106
+ * Normalize a heading name before section-pattern matching. Strip
107
+ * harmless decoration once, at the parse boundary, so decorated canonical
108
+ * headings (`## **Acceptance criteria**`, `## Acceptance criteria:`) still
109
+ * match their pattern family instead of silently disarming the AC/DoD reads.
110
+ * Exact-vs-alias precedence stays intact: a normalized `Acceptance criteria`
111
+ * still matches the exact pattern, a decorated alias still matches its alias
112
+ * family. Strips surrounding emphasis/backtick runs (bold and single-char
113
+ * italic), trailing `:`, closing ATX `#`, and surrounding whitespace.
114
+ * NOT touched: interior text, and leading `#` (never reaches `match[2]`).
148
115
  */
149
116
  function normalizeHeadingName(name) {
150
117
  if (typeof name !== "string") return name;
@@ -152,30 +119,23 @@ function normalizeHeadingName(name) {
152
119
  // trailing decoration first: closing `##` ATX-style, colons, whitespace
153
120
  .replace(/\s*:*\s*$/u, "")
154
121
  .replace(/\s*#+\s*$/u, "")
155
- // surrounding emphasis/backtick runs (any length, must pair; #1877
156
- // round-7: a run may be single-char italic `*`/`_` as well as bold
157
- // `**`/`__`, so `## *Acceptance criteria*` and `## _Definition of done_`
158
- // normalize exactly like their bold forms)
122
+ // surrounding emphasis/backtick runs (any length, must pair; a run may be
123
+ // single-char italic `*`/`_` as well as bold `**`/`__`)
159
124
  .replace(/^[*_`]+/u, "")
160
125
  .replace(/[*_`]+$/u, "")
161
126
  .trim();
162
127
  }
163
128
 
164
- // #1877 alias-precedence: exact canonical headings (the first pattern in each
165
- // family) must outrank loose aliases (`/^ac\b/`, `/^dod\b/`) so a matrix-shaped
166
- // heading the refined-issue contract itself produces (`## AC/DoD matrix`,
167
- // `## AC → DoD mapping`) can never hijack the canonical section read. Split
168
- // each pattern family into [exact, aliases] by convention: pattern index 0
169
- // is the exact canonical match, the rest are aliases.
129
+ // Exact canonical headings (pattern index 0 in each family) must outrank loose
130
+ // aliases (`/^ac\b/`, `/^dod\b/`) so a matrix-shaped heading (`## AC/DoD
131
+ // matrix`) can never hijack the canonical section read. Index 0 is the exact
132
+ // canonical match, the rest are aliases.
170
133
  const EXACT_PATTERN_INDEX = 0;
171
134
 
172
135
  /**
173
- * Resolve the sections matching a heading-pattern family with exact-first
174
- * precedence (#1877): the first section matching the EXACT canonical pattern
175
- * (index 0) wins over any earlier section that only matched a loose alias
176
- * (e.g. `## AC/DoD matrix` before `## Acceptance criteria`). When no exact
177
- * match exists, the first alias match is returned (alias-only bodies keep
178
- * working). Returns null when no section matches at all.
136
+ * Resolve the section matching a heading-pattern family with exact-first
137
+ * precedence: the first EXACT canonical match (index 0) wins over any
138
+ * earlier alias-only match. Falls back to the first alias match, else null.
179
139
  */
180
140
  function findSectionByPatterns(sections, patterns) {
181
141
  const exact = patterns[EXACT_PATTERN_INDEX];
@@ -195,10 +155,9 @@ function findSectionByPatterns(sections, patterns) {
195
155
  }
196
156
 
197
157
  /**
198
- * Collect ALL sections matching a heading-pattern family, exact-first ordered
199
- * (exact canonical matches before alias-only matches). Shared with
200
- * `findSectionByPatterns`'s precedence semantics so single-section consumers
201
- * and union consumers (#1877 PR-body unchecked-box extraction) cannot drift.
158
+ * Collect ALL sections matching a heading-pattern family, exact-first ordered.
159
+ * Shares `findSectionByPatterns`'s precedence semantics so single-section and
160
+ * union consumers (PR-body unchecked-box extraction) cannot drift.
202
161
  */
203
162
  function findAllSectionsByPatterns(sections, patterns) {
204
163
  const exact = patterns[EXACT_PATTERN_INDEX];
@@ -220,28 +179,19 @@ function findAllSectionsByPatterns(sections, patterns) {
220
179
  }
221
180
 
222
181
  /**
223
- * Flatten a section (heading record) into a body string that extends past
224
- * `###` sub-headings (#1877): a section's checklist may nest items under
225
- * deeper sub-headings (`### edge cases` inside `## Acceptance criteria`), so
226
- * join the section and every following section of a DEEPER heading level up
227
- * to the next same-or-shallower heading. `parseMarkdownSections` terminates a
228
- * section's `bodyLines` at ANY heading, which is correct for heading
229
- * matching but hides unchecked boxes from consumers that must see ALL of a
230
- * canonical section's boxes.
182
+ * Flatten a section into a body string that extends past `###` sub-headings
183
+ * by joining the section and every following DEEPER-level section up to the
184
+ * next same-or-shallower heading, so nested checklist items stay visible to
185
+ * consumers that must see ALL of a canonical section's boxes.
231
186
  */
232
187
  function flattenSectionDeep(sections, startIndex) {
233
188
  const start = sections[startIndex];
234
- // #1877 round-6 heading-name re-injection fix: the raw sub-heading NAME is
235
- // NEVER re-injected into the text the checklist parser re-parses. A name is
236
- // a different input class from checklist body text: a fence-opening name
237
- // (`### ``` `) used to corrupt the parser's fence state and eat every real
238
- // box after it (fail-open), and a checkbox-shaped name (`### - [ ] fake`)
239
- // used to be counted as a phantom unchecked item (spurious fail-closed).
240
- // Only already-classified bodyLines are joined — a heading can never match
241
- // any line-level grammar, and real boxes under sub-headings stay visible
242
- // because their bodyLines still join normally. (Keeping a marker line is
243
- // unnecessary: parseChecklistItems never needed the heading boundary to
244
- // track fence state — body lines carry their own fences.)
189
+ // Anti-spoof: the raw sub-heading NAME is NEVER re-injected into the text the
190
+ // checklist parser re-parses. A fence-opening name (`### ``` `) would corrupt
191
+ // fence state and eat real boxes (fail-open); a checkbox-shaped name
192
+ // (`### - [ ] fake`) would count as a phantom unchecked item (fail-closed).
193
+ // Only already-classified bodyLines are joined; real boxes under sub-headings
194
+ // stay visible because their bodyLines still join normally.
245
195
  const parts = [start.bodyLines.join("\n")];
246
196
  for (let i = startIndex + 1; i < sections.length; i += 1) {
247
197
  if (sections[i].level <= start.level) break;
@@ -260,7 +210,7 @@ function flattenSectionDeep(sections, startIndex) {
260
210
  * CommonMark: an N-marker fence (``` or ~~~) closes only on a line of >= N
261
211
  * markers of the SAME char with no info string. This is the single source of
262
212
  * truth shared by parseMarkdownSections (headings) and extractChecklistItems
263
- * (checkboxes) so the two anti-spoof layers cannot drift (issue #1025).
213
+ * (checkboxes) so the two anti-spoof layers cannot drift.
264
214
  */
265
215
  function stepFence(fence, line) {
266
216
  const openMatch = /^\s*(`{3,}|~{3,})/u.exec(line);
@@ -287,7 +237,7 @@ function stepFence(fence, line) {
287
237
  *
288
238
  * Headings inside a fenced code span (``` or ~~~) are NOT treated as headings —
289
239
  * otherwise a body could spoof the refinement/spec gate with real-looking
290
- * headings that carry no real spec (gate integrity, issue #1025).
240
+ * headings that carry no real spec (gate integrity).
291
241
  */
292
242
  export function parseMarkdownSections(body) {
293
243
  if (typeof body !== "string" || body.length === 0) {
@@ -313,11 +263,9 @@ export function parseMarkdownSections(body) {
313
263
  }
314
264
  current = {
315
265
  level: match[1].length,
316
- // #1877 round-6: normalize the captured name so decorated canonical
317
- // headings (`## **Acceptance criteria**`) match the section patterns.
318
- // The RAW name is never re-parsed as body text (flattenSectionDeep no
319
- // longer re-injects it), so normalization is the only consumer of the
320
- // capture — the raw form is not retained (no consumer reads it).
266
+ // Normalize the captured name so decorated canonical headings
267
+ // (`## **Acceptance criteria**`) match the section patterns. The raw
268
+ // form is never re-parsed as body text, so it is not retained.
321
269
  name: normalizeHeadingName(match[2]),
322
270
  bodyLines: [],
323
271
  };
@@ -337,27 +285,21 @@ export function parseMarkdownSections(body) {
337
285
 
338
286
 
339
287
  /**
340
- * Parse bullet/checkbox items from a section body into item states. Each
341
- * checkbox item — any GFM/CommonMark task-list marker: `-`/`*`/`+` bullets,
342
- * ordered `N.`/`N)`, and blockquote-nested `> - [ ]` (#1877 round-6 grammar
343
- * widening; parity with tick-verified-checkboxes.mjs's `[-*+]`) — becomes
344
- * `{ text, checked }` (`checked` true only for a ticked `[x]`/`[X]` marker,
345
- * read from the captured marker group, never a whole-line re-test); a
346
- * top-level plain bullet (`- text`, dash at column 0 so nested/indented
347
- * sub-bullets are not counted)
348
- * becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
349
- * checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
350
- * not counted, so a section of only unfilled placeholders reports as unrefined.
351
- * Code-fenced lines are skipped (same fence logic as parseMarkdownSections,
352
- * issue #1025) so a body cannot spoof the AC/DoD gate with code-fenced
353
- * checkboxes.
288
+ * Parse bullet/checkbox items from a section body into item states. A checkbox
289
+ * item — any GFM/CommonMark task-list marker (`-`/`*`/`+` bullets, ordered
290
+ * `N.`/`N)`, blockquote-nested `> - [ ]`; parity with
291
+ * tick-verified-checkboxes.mjs) — becomes `{ text, checked }`, where `checked`
292
+ * is true only for a ticked `[x]`/`[X]` marker read from the captured marker
293
+ * group, never a whole-line re-test. A top-level plain bullet (`- text`, dash
294
+ * at column 0) becomes `{ text, checked: null }` — it has no checkbox. Empty
295
+ * placeholders (`- [ ]` with no text) are skipped. Code-fenced lines are
296
+ * skipped (same fence logic as parseMarkdownSections) so a body cannot spoof
297
+ * the AC/DoD gate with code-fenced checkboxes.
354
298
  *
355
- * Shared by `extractChecklistItems` (text-only) and the unticked-AC check
356
- * (`extractUncheckedChecklistItems`) so the two never drift on what counts as
357
- * a checklist item or on the checkbox-state read (#1621). Only ever called on
358
- * the body of an already-recognized AC/DoD section (see
359
- * `detectIssueRefinementArtifact`), so counting plain bullets is scoped to
360
- * those sections and never affects prose sections.
299
+ * Shared by `extractChecklistItems` and `extractUncheckedChecklistItems` so the
300
+ * two never drift on what counts as an item or on the checkbox-state read.
301
+ * Only called on an already-recognized AC/DoD section, so counting
302
+ * plain bullets never affects prose sections.
361
303
  */
362
304
  function parseChecklistItems(sectionBody) {
363
305
  if (typeof sectionBody !== "string" || sectionBody.length === 0) {
@@ -374,20 +316,15 @@ function parseChecklistItems(sectionBody) {
374
316
  if (step.insideFence) {
375
317
  continue;
376
318
  }
377
- // Checklist item: GFM/CommonMark task-list markers (#1877 round-6 parser
378
- // hardening): `-`/`*`/`+` bullets, ordered `N.`/`N)`, and blockquote-nested
379
- // `> - [ ]` — the forms GitHub itself renders as interactive checkboxes.
380
- // Grammar parity with tick-verified-checkboxes.mjs's CHECKBOX_RE (same
381
- // #1877 round-1 widening): both accept bullets, ordered markers, and
382
- // blockquote-nested forms, so every form this extractor surfaces as
383
- // unchecked is flippable by the tick tool. Consume ANY checkbox-marker line
384
- // here; push only when it carries text, so empty placeholders (`- [ ]`) are
385
- // skipped rather than counted. #1877 round-7: the tick state comes from the
386
- // CAPTURED marker group of this single match — never a second whole-line
387
- // re-test. An unanchored `/\[(?:[xX])\]/u.test(line)` reads an UNCHECKED box
388
- // whose label text merely mentions `[x]` (e.g. `- [ ] verify [x] flags`) as
389
- // checked, silently disarming the deterministic block — the exact
390
- // fail-open class the marker-anchored pre-#1877 read could not produce.
319
+ // Checklist item: GFM/CommonMark task-list markers (`-`/`*`/`+` bullets,
320
+ // ordered `N.`/`N)`, blockquote-nested `> - [ ]`) — grammar parity with
321
+ // tick-verified-checkboxes.mjs so every form surfaced as unchecked is
322
+ // flippable by the tick tool. Push only when the line carries text, so
323
+ // empty placeholders are skipped. The tick state comes from the CAPTURED
324
+ // marker group, never a second whole-line re-test: an unanchored
325
+ // `/\[[xX]\]/.test(line)` would read an unchecked box whose label merely
326
+ // mentions `[x]` (`- [ ] verify [x] flags`) as checked, a fail-open the
327
+ // marker-anchored read cannot produce.
391
328
  const checkboxMatch =
392
329
  /^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+\[([ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
393
330
  if (checkboxMatch) {
@@ -421,7 +358,7 @@ function parseChecklistItems(sectionBody) {
421
358
  * are skipped. Returns the trimmed item text for each matching line; the
422
359
  * checkbox state is not preserved (use `extractUncheckedChecklistItems` for
423
360
  * that). Thin wrapper over `parseChecklistItems` so the text-only contract
424
- * stays byte-identical to its pre-#1621 shape.
361
+ * stays byte-identical to its original shape.
425
362
  */
426
363
  export function extractChecklistItems(sectionBody) {
427
364
  return parseChecklistItems(sectionBody).map((item) => item.text);
@@ -431,7 +368,7 @@ export function extractChecklistItems(sectionBody) {
431
368
  * Extract the text of UNCHECKED checkbox items (`- [ ]`) from a section body.
432
369
  * A ticked box (`- [x]`/`- [X]`) and a plain bullet (no checkbox) are both
433
370
  * excluded — only an actual unticked checkbox is an "unticked AC item"
434
- * (#1621, ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
371
+ * (ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
435
372
  * Thin wrapper over `parseChecklistItems` so the unticked read never drifts
436
373
  * from `extractChecklistItems` on what counts as a checklist item.
437
374
  */
@@ -442,7 +379,7 @@ export function extractUncheckedChecklistItems(sectionBody) {
442
379
  }
443
380
 
444
381
  // ---------------------------------------------------------------------------
445
- // AC→DoD mapping matrix detection (#1951)
382
+ // AC→DoD mapping matrix detection
446
383
  // ---------------------------------------------------------------------------
447
384
  // The authoritative refined-issue artifact is a semantic AC→DoD mapping table:
448
385
  // a GFM pipe table whose rows map each acceptance-criterion outcome to its
@@ -461,14 +398,12 @@ const MATRIX_SECTION_PATTERNS = Object.freeze([
461
398
  ]);
462
399
 
463
400
  // Header column families: col0 names the criterion side, col1 the evidence
464
- // side. Used to recognize an unheaded (not under a matrix heading) but clearly
465
- // criterion→evidence table anywhere in the body. Kept STRONG on purpose (#1951
466
- // draft_gate correctness review): a generic status table like `| Outcome |
467
- // Done |` must NOT be mistaken for the refinement matrix — only headers that
468
- // explicitly name acceptance criteria AND completion evidence / DoD qualify
401
+ // side. Recognizes an unheaded but clearly criterion→evidence table anywhere in
402
+ // the body. Kept STRONG on purpose: a generic status table like
403
+ // `| Outcome | Done |` must NOT be mistaken for the refinement matrix — only
404
+ // headers naming acceptance criteria AND completion evidence / DoD qualify
469
405
  // without a matrix heading. A matrix under a weaker header still qualifies via
470
- // its `## AC / DoD matrix` heading (MATRIX_SECTION_PATTERNS), which is what the
471
- // loop-grill synthesis and the epic procedure both write.
406
+ // its `## AC / DoD matrix` heading (MATRIX_SECTION_PATTERNS).
472
407
  const MATRIX_CRITERION_HEADER = /\b(criteri\w*|acceptance|ac)\b/i;
473
408
  const MATRIX_EVIDENCE_HEADER = /\b(evidence|dod|definition of done)\b/i;
474
409
 
@@ -498,17 +433,33 @@ function cellProseWordCount(cell) {
498
433
  }
499
434
 
500
435
  // A matrix data row is semantic when BOTH mapped cells carry at least one real
501
- // prose word (a letter-run of >=3 chars, excluding the `dod` token). This
502
- // rejects the identifier-only/tautological rows the contract names — `AC1 | D1`,
503
- // `AC1 → D1`, `DoD`, and empty cells (all 0 prose words) — WITHOUT false-
504
- // rejecting a legitimately terse-but-real mapping (e.g. `Feature works |
505
- // Regression test added`). The threshold is deliberately >=1, not >=2: the goal
506
- // is to reject bare identifiers, not to mandate a minimum verbosity (#1951
507
- // draft_gate/Copilot review).
436
+ // prose word. This rejects identifier-only/tautological rows (`AC1 | D1`,
437
+ // `AC1 → D1`, `DoD`, empty cells) WITHOUT false-rejecting a terse-but-real
438
+ // mapping (`Feature works | Regression test added`). The threshold is
439
+ // deliberately >=1, not >=2: reject bare identifiers, do not mandate a minimum
440
+ // verbosity.
508
441
  function rowIsSemantic(criterion, evidence) {
509
442
  return cellProseWordCount(criterion) >= 1 && cellProseWordCount(evidence) >= 1;
510
443
  }
511
444
 
445
+ /**
446
+ * Resolve which columns hold the criterion and evidence by HEADER NAME, so a
447
+ * leading index column (`#`, `No`, `Idx`, empty, …) shifts the mapped columns
448
+ * off positions 0/1 without breaking detection. Returns `{ criterionCol,
449
+ * evidenceCol }` when both header families are named in distinct columns.
450
+ * Falls back to positions 0/1 for a matrix-heading table whose columns are not
451
+ * explicitly named (the pre-index-aware behavior); returns null otherwise.
452
+ */
453
+ function resolveMatrixColumns(headerCells, underHeading) {
454
+ const criterionCol = headerCells.findIndex((h) => MATRIX_CRITERION_HEADER.test(h ?? ""));
455
+ const evidenceCol = headerCells.findIndex((h) => MATRIX_EVIDENCE_HEADER.test(h ?? ""));
456
+ if (criterionCol >= 0 && evidenceCol >= 0 && criterionCol !== evidenceCol) {
457
+ return { criterionCol, evidenceCol };
458
+ }
459
+ if (underHeading) return { criterionCol: 0, evidenceCol: 1 };
460
+ return null;
461
+ }
462
+
512
463
  /**
513
464
  * Parse every GFM pipe table in a Markdown body (skipping fenced code spans via
514
465
  * the shared `stepFence`). Returns an array of
@@ -575,26 +526,27 @@ function parseMarkdownTables(body) {
575
526
  */
576
527
  export function detectAcDodMatrix(body = "") {
577
528
  const tables = parseMarkdownTables(body);
578
- const candidates = tables.filter((t) => {
579
- if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) return false;
529
+ const candidates = [];
530
+ for (const t of tables) {
531
+ if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) continue;
580
532
  const underHeading = typeof t.heading === "string" &&
581
533
  MATRIX_SECTION_PATTERNS.some((p) => p.test(t.heading));
582
- const headerNamesMap =
583
- MATRIX_CRITERION_HEADER.test(t.headerCells[0] ?? "") &&
584
- MATRIX_EVIDENCE_HEADER.test(t.headerCells[1] ?? "");
585
- return underHeading || headerNamesMap;
586
- });
534
+ const cols = resolveMatrixColumns(t.headerCells, underHeading);
535
+ if (cols) candidates.push({ ...t, ...cols });
536
+ }
587
537
  if (candidates.length === 0) {
588
538
  return { found: false, valid: false, rowCount: 0, rows: [], reason: "No AC→DoD mapping matrix table found." };
589
539
  }
590
540
  // Prefer the first candidate that has >=1 semantic row; otherwise report the
591
541
  // first candidate as malformed.
592
542
  for (const table of candidates) {
543
+ const { criterionCol, evidenceCol } = table;
544
+ const minCells = Math.max(criterionCol, evidenceCol) + 1;
593
545
  const semanticRows = [];
594
546
  for (const cells of table.rows) {
595
- if (cells.length < 2) continue;
596
- const criterion = cells[0] ?? "";
597
- const evidence = cells[1] ?? "";
547
+ if (cells.length < minCells) continue;
548
+ const criterion = cells[criterionCol] ?? "";
549
+ const evidence = cells[evidenceCol] ?? "";
598
550
  if (rowIsSemantic(criterion, evidence)) {
599
551
  semanticRows.push({ criterion, evidence });
600
552
  }
@@ -623,7 +575,7 @@ export function detectAcDodMatrix(body = "") {
623
575
 
624
576
  /**
625
577
  * Project an issue's AC→DoD mapping matrix into self-contained list-form PR
626
- * checklists (#1951 AC4): the PR carries list-form Acceptance criteria and
578
+ * checklists: the PR carries list-form Acceptance criteria and
627
579
  * Definition of done checkboxes derived from the matrix — never a matrix/table,
628
580
  * never checkboxes inside table cells. Accepts a pre-parsed `matrix` (from
629
581
  * {@link detectAcDodMatrix}) or a raw `body` to parse. Fails closed on a
@@ -697,36 +649,21 @@ export function detectLinkedRefinementDoc(body) {
697
649
  /**
698
650
  * Detect the refinement artifact on a parsed issue body.
699
651
  *
700
- * #1866: the tracker-backed refinement floor is the artifact (AC checklist,
701
- * DoD checklist, or a resolvable linked refinement doc) AND an explicit,
702
- * non-empty Non-goals section — the loop-grill / artifact-authority contract
703
- * requires Non-goals on a refined issue body, so the deterministic check
704
- * enforces it (fail-closed) with the distinct finding
705
- * `MISSING_EXPLICIT_NON_GOALS_FINDING`. The non-goals matcher is shared with
706
- * `validatePrBodySpec` (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns`),
707
- * so the two spec surfaces cannot drift on what counts as an explicit
708
- * Non-goals section. `hasACs` keeps its caller-facing meaning: true only when
709
- * the FULL check passes (#1951: a valid AC→DoD mapping matrix plus an explicit
710
- * Non-goals section, or a resolvable linked refinement doc plus Non-goals), so
711
- * every `.hasACs` consumer (enqueue gate, draft gate, parked-items discovery,
712
- * gate context) fails closed with no call-site change. `acItems`/`dodItems`
713
- * stay populated for downstream consumers: from the issue's own checklist
714
- * sections when present, otherwise projected from the matrix rows.
715
- *
716
- * `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
717
- * verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
718
- * `existsSync`). Enforcement-point callers (enqueue gate, draft-gate
719
- * linked-issue path) supply it; a linked doc found in the body then satisfies
720
- * the artifact check only when the callback returns true. When the callback is
721
- * not supplied the predicate stays pure/no-I/O and behavior is unchanged, and
722
- * the `linkedDoc` result carries no `resolves` field. When supplied and the
723
- * doc does not resolve, `linkedDoc.resolves === false` and the linked doc does
724
- * not satisfy the artifact check (other artifact sources still count).
652
+ * The floor is a valid AC→DoD mapping matrix (or a resolvable linked refinement
653
+ * doc) AND an explicit, non-empty Non-goals section. A missing Non-goals
654
+ * section fails closed with the distinct finding
655
+ * `MISSING_EXPLICIT_NON_GOALS_FINDING`; its matcher is shared with
656
+ * `validatePrBodySpec` so the two spec surfaces cannot drift. `hasACs` is true
657
+ * only when the FULL check passes, so every `.hasACs` consumer fails closed with
658
+ * no call-site change. `acItems`/`dodItems` stay populated for downstream
659
+ * consumers: from the issue's own checklist sections when present, otherwise
660
+ * projected from the matrix rows.
725
661
  *
726
- * Result-shape note: on a `missing_explicit_non_goals` result, `source` keeps
727
- * the detected artifact origin (e.g. `issue-body-ac`) so callers/reporting can
728
- * still see what artifact exists; `hasACs` is false because the full
729
- * refinement check did not pass.
662
+ * `resolveLinkedDoc` (optional): a `(path) => boolean` callback verifying that a
663
+ * linked `tmp/refinement/*.md` doc actually resolves (e.g. `existsSync`). When
664
+ * not supplied the predicate stays pure/no-I/O and `linkedDoc` carries no
665
+ * `resolves` field; when supplied and the doc does not resolve, the linked doc
666
+ * does not satisfy the check (other artifact sources still count).
730
667
  *
731
668
  * @param {object} input
732
669
  * @param {string} [input.body] Raw issue body Markdown.
@@ -769,7 +706,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
769
706
  const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
770
707
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
771
708
 
772
- // CONSUMER-CONTRACT BOUNDARY (#1877, intentional asymmetry): the issue-side
709
+ // CONSUMER-CONTRACT BOUNDARY (intentional asymmetry): the issue-side
773
710
  // reads above are strict — ONE exact-first section, NO deep flattening —
774
711
  // while extractPrBodyUncheckedChecklistItems (PR side) unions ALL matching
775
712
  // sections and deep-flattens past ### sub-headings. The issue side is a
@@ -784,7 +721,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
784
721
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
785
722
  // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
786
723
  // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
787
- // must refuse on (#1621). Only actual unticked checkboxes count; a ticked
724
+ // must refuse on. Only actual unticked checkboxes count; a ticked
788
725
  // box and a plain bullet (no checkbox) are both excluded.
789
726
  const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
790
727
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
@@ -796,7 +733,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
796
733
  linkedDoc = { ...linkedDoc, resolves: linkedDocResolves };
797
734
  }
798
735
 
799
- // #1866: explicit Non-goals section required on a refined tracker-backed
736
+ // explicit Non-goals section required on a refined tracker-backed
800
737
  // issue body — same matcher the PR-body spec path uses, so the two cannot
801
738
  // drift. A heading-only or fenced-only section does not count
802
739
  // (sectionHasBody anti-spoof).
@@ -804,7 +741,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
804
741
  findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
805
742
  );
806
743
 
807
- // #1951: the authoritative issue artifact is the AC→DoD mapping MATRIX, not
744
+ // the authoritative issue artifact is the AC→DoD mapping MATRIX, not
808
745
  // duplicate interactive issue-side checklists. Detect its presence + shape.
809
746
  const matrix = detectAcDodMatrix(body);
810
747
 
@@ -851,7 +788,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
851
788
  };
852
789
  }
853
790
 
854
- // Matrix present but empty/malformed/identifier-only: fail closed (#1951 AC2).
791
+ // Matrix present but empty/malformed/identifier-only: fail closed.
855
792
  if (matrix.found && !matrix.valid) {
856
793
  return {
857
794
  ...base,
@@ -893,8 +830,8 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
893
830
  }
894
831
 
895
832
  // Matrix absent. If the body carries AC/DoD checklist content it is a
896
- // checklist-bearing issue missing the authoritative matrix (#1951 AC2 /
897
- // migration): fail closed on the missing matrix so it is re-grilled. A body
833
+ // checklist-bearing issue missing the authoritative matrix during
834
+ // migration: fail closed on the missing matrix so it is re-grilled. A body
898
835
  // with no matrix and no AC/DoD content (prose-only, or only a Non-goals
899
836
  // section / an unresolved linked-doc mention) stays the pre-existing
900
837
  // missing_refinement_artifact.
@@ -925,7 +862,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
925
862
  }
926
863
 
927
864
  /**
928
- * PR-body-as-spec invariant sections (issue #1025, lightweight path).
865
+ * PR-body-as-spec invariant sections (lightweight path).
929
866
  *
930
867
  * When a lightweight session uses the PR description itself as the
931
868
  * spec-of-record (no committed phase/plan doc), the PR body must still carry
@@ -960,14 +897,13 @@ export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
960
897
  /**
961
898
  * GitHub's accepted closing-keyword issue references (close/closes/closed,
962
899
  * fix/fixes/fixed, resolve/resolves/resolved), case-insensitive, followed by
963
- * `#N` or the cross-repo `owner/repo#N` form. Mirrors the linkage the
964
- * lightweight path (#1025) requires the PR body to carry (issue #1181: five
965
- * lightweight PRs merged without this and none auto-closed their issue).
900
+ * `#N` or the cross-repo `owner/repo#N` form. Required linkage on the PR body:
901
+ * five lightweight PRs merged without this and none auto-closed their issue.
966
902
  */
967
903
  const CLOSING_ISSUE_REFERENCE_PATTERN =
968
904
  /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
969
905
 
970
- function extractClosingIssueNumbers(body) {
906
+ export function extractClosingIssueNumbers(body) {
971
907
  // Same fence-skip as sectionHasBody: a `Closes #N` line quoted inside a
972
908
  // ```fenced``` example (e.g. a PR-template sample) must not spoof the gate.
973
909
  let fence = null;
@@ -1000,7 +936,7 @@ function extractClosingIssueNumbers(body) {
1000
936
  function sectionHasBody(section) {
1001
937
  // A real body needs >=1 non-whitespace line OUTSIDE any fenced code span —
1002
938
  // a section whose only content is a ```fenced``` block is treated as empty so
1003
- // it cannot spoof the narrative-invariant gate (issue #1025, same stepFence as
939
+ // it cannot spoof the narrative-invariant gate (same stepFence as
1004
940
  // parseMarkdownSections + extractChecklistItems).
1005
941
  if (!section) return false;
1006
942
  let fence = null;
@@ -1017,40 +953,29 @@ function sectionHasBody(section) {
1017
953
  * Validate that a PR body carries every invariant required to serve as the
1018
954
  * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
1019
955
  * testable Acceptance criteria (>=1 checklist item), Definition of done
1020
- * (>=1 checklist item), Open questions/risks, and — unless explicit
1021
- * issue-less mode is requested — a GitHub closing-keyword issue reference
1022
- * (`Closes #N` and GitHub's other accepted forms — the lightweight path's
1023
- * `Closes #N` linkage, issue #1181). Reuses the generic markdown logic
1024
- * (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
1025
- * there is no parallel validator. Fails closed: every missing invariant is
1026
- * reported under its distinct `missing_*` code. Pure; no side effects.
956
+ * (>=1 checklist item), Open questions/risks, and — unless issue-less mode is
957
+ * requested — a GitHub closing-keyword issue reference. Reuses the generic
958
+ * markdown logic so there is no parallel validator. Fails closed: every missing
959
+ * invariant is reported under its distinct `missing_*` code. Pure; no I/O.
1027
960
  *
1028
- * Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
1029
- * stay unconditional, but the closing-issue linkage flips from REQUIRED to
1030
- * FORBIDDEN — the PR is the sole artifact, so it MUST NOT carry a closing
1031
- * reference to an issue that doesn't back it. A present reference in this
1032
- * mode fails closed under `unexpected_closing_issue_reference`, distinct
1033
- * from `missing_closing_issue_reference` (tracker-backed mode, the default)
1034
- * so callers can tell "no issue expected" apart from "issue expected but
1035
- * absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
1036
- * pick exactly one mode (tracker-backed, with or without a specific
1037
- * expected issue) or issue-less — never both.
961
+ * Issue-less mode (`issueLess: true`): the closing-issue linkage flips from
962
+ * REQUIRED to FORBIDDEN (the PR is the sole artifact), failing closed under
963
+ * `unexpected_closing_issue_reference` — distinct from
964
+ * `missing_closing_issue_reference` (tracker-backed, the default).
965
+ * `expectedIssue` and `issueLess` are mutually exclusive; callers pick exactly
966
+ * one mode.
1038
967
  *
1039
- * `requireOpenQuestions` (default `true`, issue #1863): the lightweight
1040
- * PR-body-as-spec contract (this function's original scope) requires an Open
1041
- * questions/risks section; the ordinary tracker-backed PR-description
1042
- * contract (skills/docs/copilot-loop-operations.md "PR description
1043
- * contract") does not name one. Pass `false` (see
1044
- * `validateTrackerBackedPrBodySpec` below) to skip the `missing_open_questions`
1045
- * check without touching any other invariant — the lightweight caller's
1046
- * default stays byte-identical.
968
+ * `requireOpenQuestions` (default `true`): the tracker-backed PR-description
969
+ * contract does not name an Open questions/risks section; pass `false` (see
970
+ * `validateTrackerBackedPrBodySpec`) to skip that check without touching any
971
+ * other invariant.
1047
972
  *
1048
973
  * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean, requireOpenQuestions?: boolean }} input
1049
974
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
1050
975
  */
1051
976
 
1052
977
  // ---------------------------------------------------------------------------
1053
- // Grill sub-loop body predicates (GRILL-SUBLOOP-*, #1628)
978
+ // Grill sub-loop body predicates (GRILL-SUBLOOP-*)
1054
979
  // ---------------------------------------------------------------------------
1055
980
  // The loop-grill skill writes its raw Q&A transcript and synthesis to an
1056
981
  // ephemeral tmp artifact and keeps only the canonical synthesized sections
@@ -1170,8 +1095,8 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
1170
1095
 
1171
1096
  /**
1172
1097
  * Validate a TRACKER-BACKED PR's own body against the PR-description contract
1173
- * (skills/docs/copilot-loop-operations.md "PR description contract", issue
1174
- * #1863): Acceptance criteria + Definition of done checklists, an explicit
1098
+ * (skills/docs/copilot-loop-operations.md "PR description contract"):
1099
+ * Acceptance criteria + Definition of done checklists, an explicit
1175
1100
  * Non-goals section, and a `Closes #N`/`Fixes #N` reference — regardless of
1176
1101
  * whether the linked issue itself already carries a refinement artifact. A
1177
1102
  * linked issue with real ACs is necessary but not sufficient: the PR body is
@@ -1194,7 +1119,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
1194
1119
  }
1195
1120
 
1196
1121
  /**
1197
- * #1877: extract the UNCHECKED AC/DoD checkbox items from a PR body's own
1122
+ * Extract the UNCHECKED AC/DoD checkbox items from a PR body's own
1198
1123
  * Acceptance criteria / Definition of done checklists — the derived,
1199
1124
  * self-contained checklist that mirrors the linked issue's AC/DoD/Non-goals
1200
1125
  * matrix. Any unchecked `- [ ]` in those sections means an acceptance
@@ -1212,7 +1137,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
1212
1137
  * (same `parseMarkdownSections` + `extractUncheckedChecklistItems` seams as
1213
1138
  * `detectIssueRefinementArtifact` / `validatePrBodySpec`) so no parallel
1214
1139
  * parser can drift. Sections absent from the body contribute no items — the
1215
- * draft-exit `validateTrackerBackedPrBodySpec` check (#1863) already owns
1140
+ * draft-exit `validateTrackerBackedPrBodySpec` check already owns
1216
1141
  * requiring the sections to EXIST.
1217
1142
  *
1218
1143
  * @param {{ body?: string }} input
@@ -1225,7 +1150,7 @@ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
1225
1150
  const sections = parseMarkdownSections(body);
1226
1151
  // Union the unchecked boxes across ALL sections matching each pattern
1227
1152
  // family (exact-first ordered), flattening each section past its deeper
1228
- // sub-headings (#1877): a body nesting ACs under `###` subsections, or
1153
+ // sub-headings: a body nesting ACs under `###` subsections, or
1229
1154
  // repeating an AC/DoD heading, must not hide unchecked boxes from the
1230
1155
  // deterministic completeness block. Deduped by text (same box re-read in a
1231
1156
  // duplicate section is the same box).
@@ -1268,7 +1193,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1268
1193
  if (!targetIsPickup || artifact.finding === null) {
1269
1194
  return { action: "enqueue" };
1270
1195
  }
1271
- // #1866: artifact present but the contract-mandated Non-goals section is
1196
+ // artifact present but the contract-mandated Non-goals section is
1272
1197
  // absent/empty — a distinct failure with its own guidance.
1273
1198
  if (artifact.finding === MISSING_EXPLICIT_NON_GOALS_FINDING) {
1274
1199
  const reason =
@@ -1277,7 +1202,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1277
1202
  "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself)) — refusing to enqueue without an explicit Non-goals section.";
1278
1203
  return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
1279
1204
  }
1280
- // #1951: matrix present but empty/malformed/identifier-only — name the shape
1205
+ // matrix present but empty/malformed/identifier-only — name the shape
1281
1206
  // defect so the fix targets the mapping table, not a missing section.
1282
1207
  if (artifact.finding === MALFORMED_AC_DOD_MATRIX_FINDING) {
1283
1208
  const reason =
@@ -1286,7 +1211,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1286
1211
  "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself)) — refusing to enqueue on a malformed matrix.";
1287
1212
  return { action: auto ? "divert" : "block", reason, missing: ["valid AC→DoD mapping matrix"] };
1288
1213
  }
1289
- // #1951: matrix absent (whether or not the body carries duplicate issue-side
1214
+ // matrix absent (whether or not the body carries duplicate issue-side
1290
1215
  // checklists) — the mapping table is the authoritative issue artifact.
1291
1216
  if (artifact.finding === MISSING_AC_DOD_MATRIX_FINDING) {
1292
1217
  const reason =
@@ -1333,7 +1258,7 @@ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild
1333
1258
  throw new Error("Invalid JSON input");
1334
1259
  }
1335
1260
  const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
1336
- // #1866: a linked refinement doc satisfies the gate only when it actually
1261
+ // a linked refinement doc satisfies the gate only when it actually
1337
1262
  // resolves. Paths follow the `tmp/refinement/*.md` convention and are
1338
1263
  // anchored to the caller's repo root (`repoRoot` option, falling back to
1339
1264
  // process.cwd()) — never the ambient cwd of whichever subdirectory the