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

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,13 +433,11 @@ 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
  }
@@ -623,7 +556,7 @@ export function detectAcDodMatrix(body = "") {
623
556
 
624
557
  /**
625
558
  * 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
559
+ * checklists: the PR carries list-form Acceptance criteria and
627
560
  * Definition of done checkboxes derived from the matrix — never a matrix/table,
628
561
  * never checkboxes inside table cells. Accepts a pre-parsed `matrix` (from
629
562
  * {@link detectAcDodMatrix}) or a raw `body` to parse. Fails closed on a
@@ -697,36 +630,21 @@ export function detectLinkedRefinementDoc(body) {
697
630
  /**
698
631
  * Detect the refinement artifact on a parsed issue body.
699
632
  *
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).
633
+ * The floor is a valid AC→DoD mapping matrix (or a resolvable linked refinement
634
+ * doc) AND an explicit, non-empty Non-goals section. A missing Non-goals
635
+ * section fails closed with the distinct finding
636
+ * `MISSING_EXPLICIT_NON_GOALS_FINDING`; its matcher is shared with
637
+ * `validatePrBodySpec` so the two spec surfaces cannot drift. `hasACs` is true
638
+ * only when the FULL check passes, so every `.hasACs` consumer fails closed with
639
+ * no call-site change. `acItems`/`dodItems` stay populated for downstream
640
+ * consumers: from the issue's own checklist sections when present, otherwise
641
+ * projected from the matrix rows.
725
642
  *
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.
643
+ * `resolveLinkedDoc` (optional): a `(path) => boolean` callback verifying that a
644
+ * linked `tmp/refinement/*.md` doc actually resolves (e.g. `existsSync`). When
645
+ * not supplied the predicate stays pure/no-I/O and `linkedDoc` carries no
646
+ * `resolves` field; when supplied and the doc does not resolve, the linked doc
647
+ * does not satisfy the check (other artifact sources still count).
730
648
  *
731
649
  * @param {object} input
732
650
  * @param {string} [input.body] Raw issue body Markdown.
@@ -769,7 +687,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
769
687
  const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
770
688
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
771
689
 
772
- // CONSUMER-CONTRACT BOUNDARY (#1877, intentional asymmetry): the issue-side
690
+ // CONSUMER-CONTRACT BOUNDARY (intentional asymmetry): the issue-side
773
691
  // reads above are strict — ONE exact-first section, NO deep flattening —
774
692
  // while extractPrBodyUncheckedChecklistItems (PR side) unions ALL matching
775
693
  // sections and deep-flattens past ### sub-headings. The issue side is a
@@ -784,7 +702,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
784
702
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
785
703
  // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
786
704
  // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
787
- // must refuse on (#1621). Only actual unticked checkboxes count; a ticked
705
+ // must refuse on. Only actual unticked checkboxes count; a ticked
788
706
  // box and a plain bullet (no checkbox) are both excluded.
789
707
  const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
790
708
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
@@ -796,7 +714,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
796
714
  linkedDoc = { ...linkedDoc, resolves: linkedDocResolves };
797
715
  }
798
716
 
799
- // #1866: explicit Non-goals section required on a refined tracker-backed
717
+ // explicit Non-goals section required on a refined tracker-backed
800
718
  // issue body — same matcher the PR-body spec path uses, so the two cannot
801
719
  // drift. A heading-only or fenced-only section does not count
802
720
  // (sectionHasBody anti-spoof).
@@ -804,7 +722,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
804
722
  findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
805
723
  );
806
724
 
807
- // #1951: the authoritative issue artifact is the AC→DoD mapping MATRIX, not
725
+ // the authoritative issue artifact is the AC→DoD mapping MATRIX, not
808
726
  // duplicate interactive issue-side checklists. Detect its presence + shape.
809
727
  const matrix = detectAcDodMatrix(body);
810
728
 
@@ -851,7 +769,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
851
769
  };
852
770
  }
853
771
 
854
- // Matrix present but empty/malformed/identifier-only: fail closed (#1951 AC2).
772
+ // Matrix present but empty/malformed/identifier-only: fail closed.
855
773
  if (matrix.found && !matrix.valid) {
856
774
  return {
857
775
  ...base,
@@ -893,8 +811,8 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
893
811
  }
894
812
 
895
813
  // 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
814
+ // checklist-bearing issue missing the authoritative matrix during
815
+ // migration: fail closed on the missing matrix so it is re-grilled. A body
898
816
  // with no matrix and no AC/DoD content (prose-only, or only a Non-goals
899
817
  // section / an unresolved linked-doc mention) stays the pre-existing
900
818
  // missing_refinement_artifact.
@@ -925,7 +843,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
925
843
  }
926
844
 
927
845
  /**
928
- * PR-body-as-spec invariant sections (issue #1025, lightweight path).
846
+ * PR-body-as-spec invariant sections (lightweight path).
929
847
  *
930
848
  * When a lightweight session uses the PR description itself as the
931
849
  * spec-of-record (no committed phase/plan doc), the PR body must still carry
@@ -960,9 +878,8 @@ export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
960
878
  /**
961
879
  * GitHub's accepted closing-keyword issue references (close/closes/closed,
962
880
  * 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).
881
+ * `#N` or the cross-repo `owner/repo#N` form. Required linkage on the PR body:
882
+ * five lightweight PRs merged without this and none auto-closed their issue.
966
883
  */
967
884
  const CLOSING_ISSUE_REFERENCE_PATTERN =
968
885
  /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
@@ -1000,7 +917,7 @@ function extractClosingIssueNumbers(body) {
1000
917
  function sectionHasBody(section) {
1001
918
  // A real body needs >=1 non-whitespace line OUTSIDE any fenced code span —
1002
919
  // 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
920
+ // it cannot spoof the narrative-invariant gate (same stepFence as
1004
921
  // parseMarkdownSections + extractChecklistItems).
1005
922
  if (!section) return false;
1006
923
  let fence = null;
@@ -1017,40 +934,29 @@ function sectionHasBody(section) {
1017
934
  * Validate that a PR body carries every invariant required to serve as the
1018
935
  * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
1019
936
  * 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.
937
+ * (>=1 checklist item), Open questions/risks, and — unless issue-less mode is
938
+ * requested — a GitHub closing-keyword issue reference. Reuses the generic
939
+ * markdown logic so there is no parallel validator. Fails closed: every missing
940
+ * invariant is reported under its distinct `missing_*` code. Pure; no I/O.
1027
941
  *
1028
- * Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
1029
- * stay unconditional, but the closing-issue linkage flips from REQUIRED to
1030
- * FORBIDDENthe 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.
942
+ * Issue-less mode (`issueLess: true`): the closing-issue linkage flips from
943
+ * REQUIRED to FORBIDDEN (the PR is the sole artifact), failing closed under
944
+ * `unexpected_closing_issue_reference`distinct from
945
+ * `missing_closing_issue_reference` (tracker-backed, the default).
946
+ * `expectedIssue` and `issueLess` are mutually exclusive; callers pick exactly
947
+ * one mode.
1038
948
  *
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.
949
+ * `requireOpenQuestions` (default `true`): the tracker-backed PR-description
950
+ * contract does not name an Open questions/risks section; pass `false` (see
951
+ * `validateTrackerBackedPrBodySpec`) to skip that check without touching any
952
+ * other invariant.
1047
953
  *
1048
954
  * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean, requireOpenQuestions?: boolean }} input
1049
955
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
1050
956
  */
1051
957
 
1052
958
  // ---------------------------------------------------------------------------
1053
- // Grill sub-loop body predicates (GRILL-SUBLOOP-*, #1628)
959
+ // Grill sub-loop body predicates (GRILL-SUBLOOP-*)
1054
960
  // ---------------------------------------------------------------------------
1055
961
  // The loop-grill skill writes its raw Q&A transcript and synthesis to an
1056
962
  // ephemeral tmp artifact and keeps only the canonical synthesized sections
@@ -1170,8 +1076,8 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
1170
1076
 
1171
1077
  /**
1172
1078
  * 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
1079
+ * (skills/docs/copilot-loop-operations.md "PR description contract"):
1080
+ * Acceptance criteria + Definition of done checklists, an explicit
1175
1081
  * Non-goals section, and a `Closes #N`/`Fixes #N` reference — regardless of
1176
1082
  * whether the linked issue itself already carries a refinement artifact. A
1177
1083
  * linked issue with real ACs is necessary but not sufficient: the PR body is
@@ -1194,7 +1100,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
1194
1100
  }
1195
1101
 
1196
1102
  /**
1197
- * #1877: extract the UNCHECKED AC/DoD checkbox items from a PR body's own
1103
+ * Extract the UNCHECKED AC/DoD checkbox items from a PR body's own
1198
1104
  * Acceptance criteria / Definition of done checklists — the derived,
1199
1105
  * self-contained checklist that mirrors the linked issue's AC/DoD/Non-goals
1200
1106
  * matrix. Any unchecked `- [ ]` in those sections means an acceptance
@@ -1212,7 +1118,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
1212
1118
  * (same `parseMarkdownSections` + `extractUncheckedChecklistItems` seams as
1213
1119
  * `detectIssueRefinementArtifact` / `validatePrBodySpec`) so no parallel
1214
1120
  * parser can drift. Sections absent from the body contribute no items — the
1215
- * draft-exit `validateTrackerBackedPrBodySpec` check (#1863) already owns
1121
+ * draft-exit `validateTrackerBackedPrBodySpec` check already owns
1216
1122
  * requiring the sections to EXIST.
1217
1123
  *
1218
1124
  * @param {{ body?: string }} input
@@ -1225,7 +1131,7 @@ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
1225
1131
  const sections = parseMarkdownSections(body);
1226
1132
  // Union the unchecked boxes across ALL sections matching each pattern
1227
1133
  // family (exact-first ordered), flattening each section past its deeper
1228
- // sub-headings (#1877): a body nesting ACs under `###` subsections, or
1134
+ // sub-headings: a body nesting ACs under `###` subsections, or
1229
1135
  // repeating an AC/DoD heading, must not hide unchecked boxes from the
1230
1136
  // deterministic completeness block. Deduped by text (same box re-read in a
1231
1137
  // duplicate section is the same box).
@@ -1268,7 +1174,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1268
1174
  if (!targetIsPickup || artifact.finding === null) {
1269
1175
  return { action: "enqueue" };
1270
1176
  }
1271
- // #1866: artifact present but the contract-mandated Non-goals section is
1177
+ // artifact present but the contract-mandated Non-goals section is
1272
1178
  // absent/empty — a distinct failure with its own guidance.
1273
1179
  if (artifact.finding === MISSING_EXPLICIT_NON_GOALS_FINDING) {
1274
1180
  const reason =
@@ -1277,7 +1183,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1277
1183
  "(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
1184
  return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
1279
1185
  }
1280
- // #1951: matrix present but empty/malformed/identifier-only — name the shape
1186
+ // matrix present but empty/malformed/identifier-only — name the shape
1281
1187
  // defect so the fix targets the mapping table, not a missing section.
1282
1188
  if (artifact.finding === MALFORMED_AC_DOD_MATRIX_FINDING) {
1283
1189
  const reason =
@@ -1286,7 +1192,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1286
1192
  "(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
1193
  return { action: auto ? "divert" : "block", reason, missing: ["valid AC→DoD mapping matrix"] };
1288
1194
  }
1289
- // #1951: matrix absent (whether or not the body carries duplicate issue-side
1195
+ // matrix absent (whether or not the body carries duplicate issue-side
1290
1196
  // checklists) — the mapping table is the authoritative issue artifact.
1291
1197
  if (artifact.finding === MISSING_AC_DOD_MATRIX_FINDING) {
1292
1198
  const reason =
@@ -1333,7 +1239,7 @@ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild
1333
1239
  throw new Error("Invalid JSON input");
1334
1240
  }
1335
1241
  const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
1336
- // #1866: a linked refinement doc satisfies the gate only when it actually
1242
+ // a linked refinement doc satisfies the gate only when it actually
1337
1243
  // resolves. Paths follow the `tmp/refinement/*.md` convention and are
1338
1244
  // anchored to the caller's repo root (`repoRoot` option, falling back to
1339
1245
  // process.cwd()) — never the ambient cwd of whichever subdirectory the