@dev-loops/core 1.0.1 → 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,17 +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: a draft PR cannot leave draft unless the linked issue has an
6
- * explicit refinement artifact the full matrix of an Acceptance criteria
7
- * checklist plus a Definition of done checklist plus an explicit Non-goals
8
- * section, or a linked refinement doc that is a complete artifact on its own —
9
- * that the pre-approval gate can verify against. An issue missing any matrix
10
- * part fails closed with the matching finding (`missing_dod_checklist`,
11
- * `missing_ac_checklist`, `missing_explicit_non_goals`, or
12
- * `missing_refinement_artifact`); prose-only issues (Problem / Root Cause /
13
- * Fix) cause the draft gate to post `verdict=blocked` with the
14
- * `missing_refinement_artifact` finding.
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
11
+ * `validateTrackerBackedPrBodySpec`, never this predicate).
12
+ *
13
+ * Detection validates the structural PRESENCE and SHAPE of the mapping table,
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.
15
20
  */
16
21
  import { existsSync } from "node:fs";
17
22
  import path from "node:path";
@@ -29,6 +34,7 @@ import path from "node:path";
29
34
  */
30
35
 
31
36
  export const REFINEMENT_SOURCE = Object.freeze({
37
+ ISSUE_BODY_MATRIX: "issue-body-matrix",
32
38
  ISSUE_BODY_AC: "issue-body-ac",
33
39
  ISSUE_BODY_DOD: "issue-body-dod",
34
40
  LINKED_DOC: "linked-doc",
@@ -37,42 +43,37 @@ export const REFINEMENT_SOURCE = Object.freeze({
37
43
 
38
44
  const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
39
45
 
40
- // REFINEMENT_ARTIFACT_SOURCES: the full-matrix floor vocabulary (#1877). The
41
- // refinement floor is the FULL AC/DoD/Non-goals matrix (a linked refinement
42
- // doc remains a complete artifact on its own) — this list is the shape of a
43
- // COMPLETE artifact, not a menu where any one 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.
44
49
  export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
45
- "Acceptance criteria section",
46
- "Definition of done section",
50
+ "AC→DoD mapping matrix (a two-column table)",
51
+ "explicit Non-goals section",
47
52
  "linked refinement doc",
48
53
  ]);
49
54
 
50
55
  /**
51
- * #1866: finding reported when the issue body carries a refinement artifact
52
- * (AC/DoD checklist or a resolvable linked doc) but no explicit Non-goals
53
- * section. Mirrors the PR-path narrative-invariant code
54
- * (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.code`) so both spec surfaces
55
- * 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.
56
59
  */
57
60
  export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
58
61
 
59
62
  /**
60
- * #1877: finding reported when the issue body carries an AC checklist (and
61
- * the Non-goals floor is met) but NO DoD checklist — the tracker-backed
62
- * refinement floor is the full AC/DoD/Non-goals matrix (each AC mapped to its
63
- * DoD item(s), plus explicit Non-goals), not AC-or-DoD. This lifts the
64
- * epic-only matrix requirement (epic-tree-refinement-procedure.md) into the
65
- * general refinement predicate, reconciled with #1866's Non-goals parity.
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.
66
67
  */
67
- export const MISSING_DOD_CHECKLIST_FINDING = "missing_dod_checklist";
68
+ export const MISSING_AC_DOD_MATRIX_FINDING = "missing_ac_dod_matrix";
68
69
 
69
70
  /**
70
- * #1877: the symmetric matrix miss a DoD checklist with no Acceptance
71
- * criteria checklist. The matrix is authored at refinement on the issue; the
72
- * PR then carries the derived checklist whose boxes the pre-approval gate
73
- * requires all ticked.
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.
74
75
  */
75
- export const MISSING_AC_CHECKLIST_FINDING = "missing_ac_checklist";
76
+ export const MALFORMED_AC_DOD_MATRIX_FINDING = "malformed_ac_dod_matrix";
76
77
 
77
78
  /**
78
79
  * Canonical list of section headings that satisfy the refinement check.
@@ -82,47 +83,35 @@ export const MISSING_AC_CHECKLIST_FINDING = "missing_ac_checklist";
82
83
  * - one DoD-style section (DoD or Definition of Done)
83
84
  */
84
85
  const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
85
- // #1877 round-6: index 0 is the exact-canonical ANCHOR family
86
- // `^acceptance criteria\b` so a decorated-variant canonical heading
87
- // (`## Acceptance criteria (v2)`, `## Definition of done core`) still lands
88
- // in the exact bucket rather than matching NO pattern at all (the alias
89
- // families anchor on the `AC`/`DoD` abbreviations and never fire for the
90
- // spelled-out phrase). The anchor stays distinct from the alias family below
91
- // (`/^ac\b/`), so the precedence contract is unchanged: a spelled-out
92
- // 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.
93
91
  /^acceptance criteria\b.*$/i,
94
92
  /^ac\b.*$/i,
95
93
  ]);
96
94
 
97
95
  const DOD_SECTION_PATTERNS = Object.freeze([
98
- // Same anchor-family widening as the AC family (see above). #1877 round-7:
99
- // the ALIAS arms are widened symmetrically with the AC family too a
100
- // decorated-variant alias heading (`## DoD (v2)`, `## Done core`) must land
101
- // in the alias bucket, not in NO bucket (a `$`-anchored alias silently
102
- // 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.
103
100
  /^definition of done\b.*$/i,
104
101
  /^done\b.*$/i,
105
102
  /^dod\b.*$/i,
106
103
  ]);
107
104
 
108
105
  /**
109
- * Normalize a heading name before section-pattern matching (#1877 round-6
110
- * parser hardening): GitHub authors legitimately write decorated canonical
111
- * headings `## **Acceptance criteria**`, `## Acceptance criteria:`,
112
- * `## Acceptance criteria ##` and the raw ATX capture (`match[2]`)
113
- * fails every pattern family on them, silently disarming the deterministic
114
- * AC/DoD reads (PR-side extractor fail-open; issue-side false
115
- * missing_refinement_artifact). Strip the harmless decoration once, at the
116
- * parse boundary, so exact-vs-alias precedence stays intact: a normalized
117
- * `Acceptance criteria` still matches the exact pattern, a decorated alias
118
- * still matches its alias family. Strips: surrounding emphasis runs of any
119
- * of `*`/`_` (bold `**`/`__` and single-char italic `*`/`_` alike, #1877
120
- * round-7), surrounding backtick runs, trailing `:` and surrounding
121
- * whitespace.
122
- * Deliberately NOT touched: interior text (a real `AC (v2) - final` name keeps
123
- * its interior), leading `#` (ATX markers never reach `match[2]`), and any
124
- * decoration a section pattern itself could rely on (none does — every family
125
- * 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]`).
126
115
  */
127
116
  function normalizeHeadingName(name) {
128
117
  if (typeof name !== "string") return name;
@@ -130,30 +119,23 @@ function normalizeHeadingName(name) {
130
119
  // trailing decoration first: closing `##` ATX-style, colons, whitespace
131
120
  .replace(/\s*:*\s*$/u, "")
132
121
  .replace(/\s*#+\s*$/u, "")
133
- // surrounding emphasis/backtick runs (any length, must pair; #1877
134
- // round-7: a run may be single-char italic `*`/`_` as well as bold
135
- // `**`/`__`, so `## *Acceptance criteria*` and `## _Definition of done_`
136
- // 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 `**`/`__`)
137
124
  .replace(/^[*_`]+/u, "")
138
125
  .replace(/[*_`]+$/u, "")
139
126
  .trim();
140
127
  }
141
128
 
142
- // #1877 alias-precedence: exact canonical headings (the first pattern in each
143
- // family) must outrank loose aliases (`/^ac\b/`, `/^dod\b/`) so a matrix-shaped
144
- // heading the refined-issue contract itself produces (`## AC/DoD matrix`,
145
- // `## AC → DoD mapping`) can never hijack the canonical section read. Split
146
- // each pattern family into [exact, aliases] by convention: pattern index 0
147
- // 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.
148
133
  const EXACT_PATTERN_INDEX = 0;
149
134
 
150
135
  /**
151
- * Resolve the sections matching a heading-pattern family with exact-first
152
- * precedence (#1877): the first section matching the EXACT canonical pattern
153
- * (index 0) wins over any earlier section that only matched a loose alias
154
- * (e.g. `## AC/DoD matrix` before `## Acceptance criteria`). When no exact
155
- * match exists, the first alias match is returned (alias-only bodies keep
156
- * 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.
157
139
  */
158
140
  function findSectionByPatterns(sections, patterns) {
159
141
  const exact = patterns[EXACT_PATTERN_INDEX];
@@ -173,10 +155,9 @@ function findSectionByPatterns(sections, patterns) {
173
155
  }
174
156
 
175
157
  /**
176
- * Collect ALL sections matching a heading-pattern family, exact-first ordered
177
- * (exact canonical matches before alias-only matches). Shared with
178
- * `findSectionByPatterns`'s precedence semantics so single-section consumers
179
- * 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.
180
161
  */
181
162
  function findAllSectionsByPatterns(sections, patterns) {
182
163
  const exact = patterns[EXACT_PATTERN_INDEX];
@@ -198,28 +179,19 @@ function findAllSectionsByPatterns(sections, patterns) {
198
179
  }
199
180
 
200
181
  /**
201
- * Flatten a section (heading record) into a body string that extends past
202
- * `###` sub-headings (#1877): a section's checklist may nest items under
203
- * deeper sub-headings (`### edge cases` inside `## Acceptance criteria`), so
204
- * join the section and every following section of a DEEPER heading level up
205
- * to the next same-or-shallower heading. `parseMarkdownSections` terminates a
206
- * section's `bodyLines` at ANY heading, which is correct for heading
207
- * matching but hides unchecked boxes from consumers that must see ALL of a
208
- * 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.
209
186
  */
210
187
  function flattenSectionDeep(sections, startIndex) {
211
188
  const start = sections[startIndex];
212
- // #1877 round-6 heading-name re-injection fix: the raw sub-heading NAME is
213
- // NEVER re-injected into the text the checklist parser re-parses. A name is
214
- // a different input class from checklist body text: a fence-opening name
215
- // (`### ``` `) used to corrupt the parser's fence state and eat every real
216
- // box after it (fail-open), and a checkbox-shaped name (`### - [ ] fake`)
217
- // used to be counted as a phantom unchecked item (spurious fail-closed).
218
- // Only already-classified bodyLines are joined — a heading can never match
219
- // any line-level grammar, and real boxes under sub-headings stay visible
220
- // because their bodyLines still join normally. (Keeping a marker line is
221
- // unnecessary: parseChecklistItems never needed the heading boundary to
222
- // 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.
223
195
  const parts = [start.bodyLines.join("\n")];
224
196
  for (let i = startIndex + 1; i < sections.length; i += 1) {
225
197
  if (sections[i].level <= start.level) break;
@@ -238,7 +210,7 @@ function flattenSectionDeep(sections, startIndex) {
238
210
  * CommonMark: an N-marker fence (``` or ~~~) closes only on a line of >= N
239
211
  * markers of the SAME char with no info string. This is the single source of
240
212
  * truth shared by parseMarkdownSections (headings) and extractChecklistItems
241
- * (checkboxes) so the two anti-spoof layers cannot drift (issue #1025).
213
+ * (checkboxes) so the two anti-spoof layers cannot drift.
242
214
  */
243
215
  function stepFence(fence, line) {
244
216
  const openMatch = /^\s*(`{3,}|~{3,})/u.exec(line);
@@ -265,7 +237,7 @@ function stepFence(fence, line) {
265
237
  *
266
238
  * Headings inside a fenced code span (``` or ~~~) are NOT treated as headings —
267
239
  * otherwise a body could spoof the refinement/spec gate with real-looking
268
- * headings that carry no real spec (gate integrity, issue #1025).
240
+ * headings that carry no real spec (gate integrity).
269
241
  */
270
242
  export function parseMarkdownSections(body) {
271
243
  if (typeof body !== "string" || body.length === 0) {
@@ -291,11 +263,9 @@ export function parseMarkdownSections(body) {
291
263
  }
292
264
  current = {
293
265
  level: match[1].length,
294
- // #1877 round-6: normalize the captured name so decorated canonical
295
- // headings (`## **Acceptance criteria**`) match the section patterns.
296
- // The RAW name is never re-parsed as body text (flattenSectionDeep no
297
- // longer re-injects it), so normalization is the only consumer of the
298
- // 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.
299
269
  name: normalizeHeadingName(match[2]),
300
270
  bodyLines: [],
301
271
  };
@@ -315,27 +285,21 @@ export function parseMarkdownSections(body) {
315
285
 
316
286
 
317
287
  /**
318
- * Parse bullet/checkbox items from a section body into item states. Each
319
- * checkbox item — any GFM/CommonMark task-list marker: `-`/`*`/`+` bullets,
320
- * ordered `N.`/`N)`, and blockquote-nested `> - [ ]` (#1877 round-6 grammar
321
- * widening; parity with tick-verified-checkboxes.mjs's `[-*+]`) — becomes
322
- * `{ text, checked }` (`checked` true only for a ticked `[x]`/`[X]` marker,
323
- * read from the captured marker group, never a whole-line re-test); a
324
- * top-level plain bullet (`- text`, dash at column 0 so nested/indented
325
- * sub-bullets are not counted)
326
- * becomes `{ text, checked: null }` it has no checkbox to tick. Empty
327
- * checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
328
- * not counted, so a section of only unfilled placeholders reports as unrefined.
329
- * Code-fenced lines are skipped (same fence logic as parseMarkdownSections,
330
- * issue #1025) so a body cannot spoof the AC/DoD gate with code-fenced
331
- * 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.
332
298
  *
333
- * Shared by `extractChecklistItems` (text-only) and the unticked-AC check
334
- * (`extractUncheckedChecklistItems`) so the two never drift on what counts as
335
- * a checklist item or on the checkbox-state read (#1621). Only ever called on
336
- * the body of an already-recognized AC/DoD section (see
337
- * `detectIssueRefinementArtifact`), so counting plain bullets is scoped to
338
- * 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.
339
303
  */
340
304
  function parseChecklistItems(sectionBody) {
341
305
  if (typeof sectionBody !== "string" || sectionBody.length === 0) {
@@ -352,20 +316,15 @@ function parseChecklistItems(sectionBody) {
352
316
  if (step.insideFence) {
353
317
  continue;
354
318
  }
355
- // Checklist item: GFM/CommonMark task-list markers (#1877 round-6 parser
356
- // hardening): `-`/`*`/`+` bullets, ordered `N.`/`N)`, and blockquote-nested
357
- // `> - [ ]` the forms GitHub itself renders as interactive checkboxes.
358
- // Grammar parity with tick-verified-checkboxes.mjs's CHECKBOX_RE (same
359
- // #1877 round-1 widening): both accept bullets, ordered markers, and
360
- // blockquote-nested forms, so every form this extractor surfaces as
361
- // unchecked is flippable by the tick tool. Consume ANY checkbox-marker line
362
- // here; push only when it carries text, so empty placeholders (`- [ ]`) are
363
- // skipped rather than counted. #1877 round-7: the tick state comes from the
364
- // CAPTURED marker group of this single match — never a second whole-line
365
- // re-test. An unanchored `/\[(?:[xX])\]/u.test(line)` reads an UNCHECKED box
366
- // whose label text merely mentions `[x]` (e.g. `- [ ] verify [x] flags`) as
367
- // checked, silently disarming the deterministic block — the exact
368
- // 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.
369
328
  const checkboxMatch =
370
329
  /^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+\[([ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
371
330
  if (checkboxMatch) {
@@ -399,7 +358,7 @@ function parseChecklistItems(sectionBody) {
399
358
  * are skipped. Returns the trimmed item text for each matching line; the
400
359
  * checkbox state is not preserved (use `extractUncheckedChecklistItems` for
401
360
  * that). Thin wrapper over `parseChecklistItems` so the text-only contract
402
- * stays byte-identical to its pre-#1621 shape.
361
+ * stays byte-identical to its original shape.
403
362
  */
404
363
  export function extractChecklistItems(sectionBody) {
405
364
  return parseChecklistItems(sectionBody).map((item) => item.text);
@@ -409,7 +368,7 @@ export function extractChecklistItems(sectionBody) {
409
368
  * Extract the text of UNCHECKED checkbox items (`- [ ]`) from a section body.
410
369
  * A ticked box (`- [x]`/`- [X]`) and a plain bullet (no checkbox) are both
411
370
  * excluded — only an actual unticked checkbox is an "unticked AC item"
412
- * (#1621, ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
371
+ * (ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Empty placeholders are skipped.
413
372
  * Thin wrapper over `parseChecklistItems` so the unticked read never drifts
414
373
  * from `extractChecklistItems` on what counts as a checklist item.
415
374
  */
@@ -419,6 +378,210 @@ export function extractUncheckedChecklistItems(sectionBody) {
419
378
  .map((item) => item.text);
420
379
  }
421
380
 
381
+ // ---------------------------------------------------------------------------
382
+ // AC→DoD mapping matrix detection
383
+ // ---------------------------------------------------------------------------
384
+ // The authoritative refined-issue artifact is a semantic AC→DoD mapping table:
385
+ // a GFM pipe table whose rows map each acceptance-criterion outcome to its
386
+ // required completion evidence. This is the "matrix on the issue" half of
387
+ // "matrix on the issue, checklist on the PR". Detection validates the table's
388
+ // PRESENCE and SHAPE only — its semantic truthfulness stays a reviewer duty.
389
+
390
+ // Heading families that name the mapping-matrix section. A qualifying table
391
+ // under one of these headings is treated as the matrix even when its column
392
+ // headers do not name criterion/evidence explicitly.
393
+ const MATRIX_SECTION_PATTERNS = Object.freeze([
394
+ /\bac\b.*\bdod\b.*\b(matrix|mapping|map)\b/i,
395
+ /\b(acceptance|criteri\w*)\b.*\b(matrix|mapping|map)\b/i,
396
+ /\bmapping (matrix|table)\b/i,
397
+ /\bac\s*(?:\/|→|->|to)\s*dod\b/i,
398
+ ]);
399
+
400
+ // Header column families: col0 names the criterion side, col1 the evidence
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
405
+ // without a matrix heading. A matrix under a weaker header still qualifies via
406
+ // its `## AC / DoD matrix` heading (MATRIX_SECTION_PATTERNS).
407
+ const MATRIX_CRITERION_HEADER = /\b(criteri\w*|acceptance|ac)\b/i;
408
+ const MATRIX_EVIDENCE_HEADER = /\b(evidence|dod|definition of done)\b/i;
409
+
410
+ const TABLE_DELIMITER_RE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/u;
411
+
412
+ /** Split one GFM table row into trimmed cell strings (drops leading/trailing pipes). */
413
+ function splitTableRow(line) {
414
+ let s = line.trim();
415
+ if (s.startsWith("|")) s = s.slice(1);
416
+ if (s.endsWith("|")) s = s.slice(0, -1);
417
+ // ponytail: no escaped-pipe (`\|`) handling — refined-issue matrix cells are
418
+ // short prose, not pipe-bearing code. Add a split-on-unescaped-pipe pass only
419
+ // if a real matrix cell ever needs a literal `|`.
420
+ return s.split("|").map((c) => c.trim());
421
+ }
422
+
423
+ /**
424
+ * Count real prose words in a matrix cell: runs of >=3 letters that are not the
425
+ * `dod` identifier token. Bare identifiers (`AC1`, `D1`, `DoD`), arrows, and
426
+ * digits contribute nothing, so a tautological/identifier-only cell scores 0.
427
+ */
428
+ function cellProseWordCount(cell) {
429
+ if (typeof cell !== "string") return 0;
430
+ const stripped = cell.replace(/[*_`]+/gu, " ");
431
+ const runs = stripped.match(/[A-Za-z]{3,}/gu) ?? [];
432
+ return runs.filter((w) => w.toLowerCase() !== "dod").length;
433
+ }
434
+
435
+ // A matrix data row is semantic when BOTH mapped cells carry at least one real
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.
441
+ function rowIsSemantic(criterion, evidence) {
442
+ return cellProseWordCount(criterion) >= 1 && cellProseWordCount(evidence) >= 1;
443
+ }
444
+
445
+ /**
446
+ * Parse every GFM pipe table in a Markdown body (skipping fenced code spans via
447
+ * the shared `stepFence`). Returns an array of
448
+ * `{ heading, headerCells, rows }` where `rows` is the list of data rows (each
449
+ * an array of trimmed cell strings). A table is a header line containing `|`,
450
+ * a delimiter row (`|---|---|`), and >=0 data rows.
451
+ */
452
+ function parseMarkdownTables(body) {
453
+ if (typeof body !== "string" || body.length === 0) return [];
454
+ const lines = body.split(/\r?\n/u);
455
+ const tables = [];
456
+ let fence = null;
457
+ let heading = null;
458
+ let i = 0;
459
+ while (i < lines.length) {
460
+ const step = stepFence(fence, lines[i]);
461
+ fence = step.fence;
462
+ if (step.insideFence) {
463
+ i += 1;
464
+ continue;
465
+ }
466
+ const headingMatch = /^(#{1,6})\s+(.+?)\s*$/u.exec(lines[i]);
467
+ if (headingMatch) {
468
+ heading = normalizeHeadingName(headingMatch[2]);
469
+ i += 1;
470
+ continue;
471
+ }
472
+ const header = lines[i];
473
+ const delim = lines[i + 1];
474
+ if (header.includes("|") && typeof delim === "string" && TABLE_DELIMITER_RE.test(delim)) {
475
+ const headerCells = splitTableRow(header);
476
+ const rows = [];
477
+ let j = i + 2;
478
+ while (j < lines.length) {
479
+ const rowStep = stepFence(fence, lines[j]);
480
+ // A table ends at the first non-fence line without a pipe, or a heading.
481
+ if (rowStep.insideFence) break;
482
+ if (!lines[j].includes("|") || /^#{1,6}\s+/u.test(lines[j])) break;
483
+ rows.push(splitTableRow(lines[j]));
484
+ j += 1;
485
+ }
486
+ tables.push({ heading, headerCells, rows });
487
+ i = j;
488
+ continue;
489
+ }
490
+ i += 1;
491
+ }
492
+ return tables;
493
+ }
494
+
495
+ /**
496
+ * Detect the authoritative AC→DoD mapping matrix in an issue body.
497
+ *
498
+ * A qualifying table has >=2 columns and EITHER sits under a matrix-named
499
+ * heading ({@link MATRIX_SECTION_PATTERNS}) OR names criterion/evidence-like
500
+ * columns in its header. The matrix is VALID when it carries at least one
501
+ * SEMANTIC data row (both mapped cells carry real prose — see
502
+ * {@link rowIsSemantic}); a header/separator-only table (no data rows) or a
503
+ * table whose rows are all identifier-only/tautological (`AC1 → D1`) is
504
+ * malformed.
505
+ *
506
+ * @param {string} [body]
507
+ * @returns {{ found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string }}
508
+ */
509
+ export function detectAcDodMatrix(body = "") {
510
+ const tables = parseMarkdownTables(body);
511
+ const candidates = tables.filter((t) => {
512
+ if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) return false;
513
+ const underHeading = typeof t.heading === "string" &&
514
+ MATRIX_SECTION_PATTERNS.some((p) => p.test(t.heading));
515
+ const headerNamesMap =
516
+ MATRIX_CRITERION_HEADER.test(t.headerCells[0] ?? "") &&
517
+ MATRIX_EVIDENCE_HEADER.test(t.headerCells[1] ?? "");
518
+ return underHeading || headerNamesMap;
519
+ });
520
+ if (candidates.length === 0) {
521
+ return { found: false, valid: false, rowCount: 0, rows: [], reason: "No AC→DoD mapping matrix table found." };
522
+ }
523
+ // Prefer the first candidate that has >=1 semantic row; otherwise report the
524
+ // first candidate as malformed.
525
+ for (const table of candidates) {
526
+ const semanticRows = [];
527
+ for (const cells of table.rows) {
528
+ if (cells.length < 2) continue;
529
+ const criterion = cells[0] ?? "";
530
+ const evidence = cells[1] ?? "";
531
+ if (rowIsSemantic(criterion, evidence)) {
532
+ semanticRows.push({ criterion, evidence });
533
+ }
534
+ }
535
+ if (semanticRows.length > 0) {
536
+ return {
537
+ found: true,
538
+ valid: true,
539
+ rowCount: semanticRows.length,
540
+ rows: semanticRows,
541
+ reason: `Found an AC→DoD mapping matrix with ${semanticRows.length} semantic row(s).`,
542
+ };
543
+ }
544
+ }
545
+ const dataRowCount = candidates[0].rows.length;
546
+ return {
547
+ found: true,
548
+ valid: false,
549
+ rowCount: 0,
550
+ rows: [],
551
+ reason: dataRowCount === 0
552
+ ? "AC→DoD mapping matrix table is empty (header/separator only, no data rows)."
553
+ : "AC→DoD mapping matrix table is identifier-only/tautological (no row maps a concrete criterion to concrete completion evidence).",
554
+ };
555
+ }
556
+
557
+ /**
558
+ * Project an issue's AC→DoD mapping matrix into self-contained list-form PR
559
+ * checklists: the PR carries list-form Acceptance criteria and
560
+ * Definition of done checkboxes derived from the matrix — never a matrix/table,
561
+ * never checkboxes inside table cells. Accepts a pre-parsed `matrix` (from
562
+ * {@link detectAcDodMatrix}) or a raw `body` to parse. Fails closed on a
563
+ * missing/malformed matrix rather than emitting empty checklists.
564
+ *
565
+ * @param {{ matrix?: ReturnType<typeof detectAcDodMatrix>, body?: string }} input
566
+ * @returns {{ acChecklist: string[], dodChecklist: string[], markdown: string }}
567
+ */
568
+ export function derivePrChecklistsFromIssueMatrix({ matrix = null, body = "" } = {}) {
569
+ const m = matrix ?? detectAcDodMatrix(body);
570
+ if (!m || !m.found || !m.valid || !Array.isArray(m.rows) || m.rows.length === 0) {
571
+ throw Object.assign(
572
+ new Error(`derivePrChecklistsFromIssueMatrix: ${m?.reason ?? "no valid AC→DoD mapping matrix to project"}`),
573
+ { code: "MALFORMED_MATRIX_SOURCE" },
574
+ );
575
+ }
576
+ const dedupe = (items) => [...new Set(items.map((s) => s.trim()).filter((s) => s.length > 0))];
577
+ const acChecklist = dedupe(m.rows.map((r) => r.criterion));
578
+ const dodChecklist = dedupe(m.rows.map((r) => r.evidence));
579
+ const render = (heading, items) =>
580
+ `## ${heading}\n\n${items.map((t) => `- [ ] ${t}`).join("\n")}\n`;
581
+ const markdown = `${render("Acceptance criteria", acChecklist)}\n${render("Definition of done", dodChecklist)}`;
582
+ return { acChecklist, dodChecklist, markdown };
583
+ }
584
+
422
585
  /**
423
586
  * Detect a linked refinement doc path from the issue body.
424
587
  * Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
@@ -467,33 +630,21 @@ export function detectLinkedRefinementDoc(body) {
467
630
  /**
468
631
  * Detect the refinement artifact on a parsed issue body.
469
632
  *
470
- * #1866: the tracker-backed refinement floor is the artifact (AC checklist,
471
- * DoD checklist, or a resolvable linked refinement doc) AND an explicit,
472
- * non-empty Non-goals section the loop-grill / artifact-authority contract
473
- * requires Non-goals on a refined issue body, so the deterministic check
474
- * enforces it (fail-closed) with the distinct finding
475
- * `MISSING_EXPLICIT_NON_GOALS_FINDING`. The non-goals matcher is shared with
476
- * `validatePrBodySpec` (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns`),
477
- * so the two spec surfaces cannot drift on what counts as an explicit
478
- * Non-goals section. `hasACs` keeps its caller-facing meaning: true only when
479
- * the FULL check passes, so every `.hasACs` consumer (enqueue gate, draft
480
- * gate, parked-items discovery, gate context) fails closed with no call-site
481
- * change.
482
- *
483
- * `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
484
- * verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
485
- * `existsSync`). Enforcement-point callers (enqueue gate, draft-gate
486
- * linked-issue path) supply it; a linked doc found in the body then satisfies
487
- * the artifact check only when the callback returns true. When the callback is
488
- * not supplied the predicate stays pure/no-I/O and behavior is unchanged, and
489
- * the `linkedDoc` result carries no `resolves` field. When supplied and the
490
- * doc does not resolve, `linkedDoc.resolves === false` and the linked doc does
491
- * 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.
492
642
  *
493
- * Result-shape note: on a `missing_explicit_non_goals` result, `source` keeps
494
- * the detected artifact origin (e.g. `issue-body-ac`) so callers/reporting can
495
- * still see what artifact exists; `hasACs` is false because the full
496
- * 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).
497
648
  *
498
649
  * @param {object} input
499
650
  * @param {string} [input.body] Raw issue body Markdown.
@@ -508,6 +659,7 @@ export function detectLinkedRefinementDoc(body) {
508
659
  * dodItems: string[],
509
660
  * sections: string[],
510
661
  * linkedDoc: { found: boolean, path: string|null, reason: string, resolves?: boolean },
662
+ * matrix: { found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string },
511
663
  * reason: string,
512
664
  * finding: string|null,
513
665
  * }}
@@ -523,7 +675,8 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
523
675
  dodItems: [],
524
676
  sections: [],
525
677
  linkedDoc: { found: false, path: null, reason: "empty-body" },
526
- reason: "Issue body is empty; no ACs/DoD/linked-doc can be detected.",
678
+ matrix: { found: false, valid: false, rowCount: 0, rows: [], reason: "empty-body" },
679
+ reason: "Issue body is empty; no matrix/ACs/DoD/linked-doc can be detected.",
527
680
  finding: REFINEMENT_ARTIFACT_FINDING,
528
681
  };
529
682
  }
@@ -534,7 +687,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
534
687
  const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
535
688
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
536
689
 
537
- // CONSUMER-CONTRACT BOUNDARY (#1877, intentional asymmetry): the issue-side
690
+ // CONSUMER-CONTRACT BOUNDARY (intentional asymmetry): the issue-side
538
691
  // reads above are strict — ONE exact-first section, NO deep flattening —
539
692
  // while extractPrBodyUncheckedChecklistItems (PR side) unions ALL matching
540
693
  // sections and deep-flattens past ### sub-headings. The issue side is a
@@ -549,7 +702,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
549
702
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
550
703
  // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
551
704
  // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
552
- // must refuse on (#1621). Only actual unticked checkboxes count; a ticked
705
+ // must refuse on. Only actual unticked checkboxes count; a ticked
553
706
  // box and a plain bullet (no checkbox) are both excluded.
554
707
  const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
555
708
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
@@ -561,7 +714,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
561
714
  linkedDoc = { ...linkedDoc, resolves: linkedDocResolves };
562
715
  }
563
716
 
564
- // #1866: explicit Non-goals section required on a refined tracker-backed
717
+ // explicit Non-goals section required on a refined tracker-backed
565
718
  // issue body — same matcher the PR-body spec path uses, so the two cannot
566
719
  // drift. A heading-only or fenced-only section does not count
567
720
  // (sectionHasBody anti-spoof).
@@ -569,13 +722,15 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
569
722
  findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
570
723
  );
571
724
 
572
- const artifactSource = acItems.length > 0
573
- ? REFINEMENT_SOURCE.ISSUE_BODY_AC
574
- : dodItems.length > 0
575
- ? REFINEMENT_SOURCE.ISSUE_BODY_DOD
576
- : linkedDocResolves
577
- ? REFINEMENT_SOURCE.LINKED_DOC
578
- : null;
725
+ // the authoritative issue artifact is the AC→DoD mapping MATRIX, not
726
+ // duplicate interactive issue-side checklists. Detect its presence + shape.
727
+ const matrix = detectAcDodMatrix(body);
728
+
729
+ // Keep acItems/dodItems populated for downstream consumers (gate context,
730
+ // coordination state) even when the issue carries only the matrix and no
731
+ // interactive checklists: project the matrix rows into AC/DoD items.
732
+ const effectiveAcItems = acItems.length > 0 ? acItems : matrix.rows.map((r) => r.criterion.trim()).filter(Boolean);
733
+ const effectiveDodItems = dodItems.length > 0 ? dodItems : matrix.rows.map((r) => r.evidence.trim()).filter(Boolean);
579
734
 
580
735
  const base = {
581
736
  hasNonGoals,
@@ -584,76 +739,97 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
584
739
  dodItems,
585
740
  sections: sectionNames,
586
741
  linkedDoc,
742
+ matrix,
587
743
  };
588
744
 
589
- if (artifactSource !== null) {
745
+ // A linked refinement doc remains a complete artifact on its own (the doc
746
+ // carries the matrix). It still requires an explicit Non-goals section.
747
+ if (linkedDocResolves) {
590
748
  if (!hasNonGoals) {
591
749
  return {
592
750
  ...base,
593
751
  hasACs: false,
594
- source: artifactSource,
752
+ source: REFINEMENT_SOURCE.LINKED_DOC,
595
753
  reason:
596
- `Issue body carries a refinement artifact (${artifactSource}) but no explicit Non-goals section; ` +
754
+ "Issue body links a refinement doc but has no explicit Non-goals section; " +
597
755
  "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
598
756
  "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
599
757
  finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
600
758
  };
601
759
  }
602
- if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_AC) {
603
- // #1877 matrix floor: an AC checklist alone is no longer a complete
604
- // refinement artifact on a tracker-backed issue — the matrix is each AC
605
- // mapped to its DoD item(s) plus explicit Non-goals, so a missing DoD
606
- // checklist fails closed with its own finding. A linked refinement doc
607
- // stays a complete artifact on its own (the doc itself carries the
608
- // matrix).
609
- if (dodItems.length === 0) {
610
- return {
611
- ...base,
612
- hasACs: false,
613
- source: artifactSource,
614
- reason:
615
- "Issue body carries an Acceptance criteria checklist but no Definition of done checklist; " +
616
- "the tracker-backed refinement contract requires the full AC/DoD/Non-goals matrix " +
617
- "(#1877, rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR). Refusing: the refinement check fails closed " +
618
- "without a DoD checklist mapped to the acceptance criteria.",
619
- finding: MISSING_DOD_CHECKLIST_FINDING,
620
- };
621
- }
622
- return {
623
- ...base,
624
- hasACs: true,
625
- source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
626
- reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
627
- finding: null,
628
- };
629
- }
630
- if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_DOD) {
631
- // #1877 matrix floor, symmetric arm: a DoD checklist with no Acceptance
632
- // criteria checklist is an incomplete matrix, not a refined issue.
760
+ return {
761
+ ...base,
762
+ hasACs: true,
763
+ source: REFINEMENT_SOURCE.LINKED_DOC,
764
+ acItems: [],
765
+ uncheckedAcItems: [],
766
+ dodItems: [],
767
+ reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
768
+ finding: null,
769
+ };
770
+ }
771
+
772
+ // Matrix present but empty/malformed/identifier-only: fail closed.
773
+ if (matrix.found && !matrix.valid) {
774
+ return {
775
+ ...base,
776
+ hasACs: false,
777
+ source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
778
+ reason:
779
+ `Issue body carries an AC→DoD mapping matrix but it is not a valid semantic mapping (${matrix.reason}); ` +
780
+ "the refinement contract requires a real criterion→completion-evidence mapping " +
781
+ "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
782
+ "Refusing: the refinement check fails closed on a malformed/identifier-only matrix.",
783
+ finding: MALFORMED_AC_DOD_MATRIX_FINDING,
784
+ };
785
+ }
786
+
787
+ // Matrix present and valid: the refinement floor is the matrix + explicit
788
+ // Non-goals. Interactive issue-side AC/DoD checklists are NOT required.
789
+ if (matrix.found && matrix.valid) {
790
+ if (!hasNonGoals) {
633
791
  return {
634
792
  ...base,
635
793
  hasACs: false,
636
- source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
794
+ source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
637
795
  reason:
638
- "Issue body carries a Definition of done checklist but no Acceptance criteria checklist; " +
639
- "the tracker-backed refinement contract requires the full AC/DoD/Non-goals matrix " +
640
- "(#1877, rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR). Refusing: the refinement check fails closed " +
641
- "without acceptance criteria for the DoD items to map to.",
642
- finding: MISSING_AC_CHECKLIST_FINDING,
796
+ "Issue body carries a valid AC→DoD mapping matrix but no explicit Non-goals section; " +
797
+ "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
798
+ "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
799
+ finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
643
800
  };
644
801
  }
645
802
  return {
646
803
  ...base,
647
804
  hasACs: true,
648
- source: REFINEMENT_SOURCE.LINKED_DOC,
649
- acItems: [],
650
- uncheckedAcItems: [],
651
- dodItems: [],
652
- reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
805
+ source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
806
+ acItems: effectiveAcItems,
807
+ dodItems: effectiveDodItems,
808
+ reason: `Found a valid AC→DoD mapping matrix with ${matrix.rowCount} semantic row(s) and an explicit Non-goals section.`,
653
809
  finding: null,
654
810
  };
655
811
  }
656
812
 
813
+ // Matrix absent. If the body carries AC/DoD checklist content it is a
814
+ // checklist-bearing issue missing the authoritative matrix during
815
+ // migration: fail closed on the missing matrix so it is re-grilled. A body
816
+ // with no matrix and no AC/DoD content (prose-only, or only a Non-goals
817
+ // section / an unresolved linked-doc mention) stays the pre-existing
818
+ // missing_refinement_artifact.
819
+ if (acItems.length > 0 || dodItems.length > 0) {
820
+ return {
821
+ ...base,
822
+ hasACs: false,
823
+ source: REFINEMENT_SOURCE.MISSING,
824
+ reason:
825
+ "Issue body carries Acceptance criteria / Definition of done content but no authoritative AC→DoD mapping matrix table; " +
826
+ "under matrix-on-issue/checklist-on-PR the mapping table is the authoritative issue artifact " +
827
+ "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
828
+ "Refusing: the refinement check fails closed without the mapping matrix.",
829
+ finding: MISSING_AC_DOD_MATRIX_FINDING,
830
+ };
831
+ }
832
+
657
833
  return {
658
834
  ...base,
659
835
  hasACs: false,
@@ -661,13 +837,13 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
661
837
  acItems: [],
662
838
  uncheckedAcItems: [],
663
839
  dodItems: [],
664
- reason: "Issue body has no Acceptance criteria section, no DoD section, and no linked refinement doc.",
840
+ reason: "Issue body has no AC→DoD mapping matrix, no Acceptance criteria/DoD content, and no linked refinement doc.",
665
841
  finding: REFINEMENT_ARTIFACT_FINDING,
666
842
  };
667
843
  }
668
844
 
669
845
  /**
670
- * PR-body-as-spec invariant sections (issue #1025, lightweight path).
846
+ * PR-body-as-spec invariant sections (lightweight path).
671
847
  *
672
848
  * When a lightweight session uses the PR description itself as the
673
849
  * spec-of-record (no committed phase/plan doc), the PR body must still carry
@@ -702,9 +878,8 @@ export const PR_BODY_SPEC_NARRATIVE_SECTIONS = Object.freeze({
702
878
  /**
703
879
  * GitHub's accepted closing-keyword issue references (close/closes/closed,
704
880
  * fix/fixes/fixed, resolve/resolves/resolved), case-insensitive, followed by
705
- * `#N` or the cross-repo `owner/repo#N` form. Mirrors the linkage the
706
- * lightweight path (#1025) requires the PR body to carry (issue #1181: five
707
- * 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.
708
883
  */
709
884
  const CLOSING_ISSUE_REFERENCE_PATTERN =
710
885
  /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[\w.-]+\/[\w.-]+)?#(\d+)/giu;
@@ -742,7 +917,7 @@ function extractClosingIssueNumbers(body) {
742
917
  function sectionHasBody(section) {
743
918
  // A real body needs >=1 non-whitespace line OUTSIDE any fenced code span —
744
919
  // a section whose only content is a ```fenced``` block is treated as empty so
745
- // it cannot spoof the narrative-invariant gate (issue #1025, same stepFence as
920
+ // it cannot spoof the narrative-invariant gate (same stepFence as
746
921
  // parseMarkdownSections + extractChecklistItems).
747
922
  if (!section) return false;
748
923
  let fence = null;
@@ -759,40 +934,29 @@ function sectionHasBody(section) {
759
934
  * Validate that a PR body carries every invariant required to serve as the
760
935
  * lightweight spec-of-record: Objective/why, in-scope, explicit non-goals,
761
936
  * testable Acceptance criteria (>=1 checklist item), Definition of done
762
- * (>=1 checklist item), Open questions/risks, and — unless explicit
763
- * issue-less mode is requested — a GitHub closing-keyword issue reference
764
- * (`Closes #N` and GitHub's other accepted forms the lightweight path's
765
- * `Closes #N` linkage, issue #1181). Reuses the generic markdown logic
766
- * (parseMarkdownSections / AC + DoD patterns / extractChecklistItems) so
767
- * there is no parallel validator. Fails closed: every missing invariant is
768
- * 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.
769
941
  *
770
- * Issue-less mode (`issueLess: true`, issue #1210): the narrative invariants
771
- * stay unconditional, but the closing-issue linkage flips from REQUIRED to
772
- * FORBIDDENthe PR is the sole artifact, so it MUST NOT carry a closing
773
- * reference to an issue that doesn't back it. A present reference in this
774
- * mode fails closed under `unexpected_closing_issue_reference`, distinct
775
- * from `missing_closing_issue_reference` (tracker-backed mode, the default)
776
- * so callers can tell "no issue expected" apart from "issue expected but
777
- * absent". `expectedIssue` and `issueLess` are mutually exclusive; callers
778
- * pick exactly one mode (tracker-backed, with or without a specific
779
- * 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.
780
948
  *
781
- * `requireOpenQuestions` (default `true`, issue #1863): the lightweight
782
- * PR-body-as-spec contract (this function's original scope) requires an Open
783
- * questions/risks section; the ordinary tracker-backed PR-description
784
- * contract (skills/docs/copilot-loop-operations.md "PR description
785
- * contract") does not name one. Pass `false` (see
786
- * `validateTrackerBackedPrBodySpec` below) to skip the `missing_open_questions`
787
- * check without touching any other invariant — the lightweight caller's
788
- * 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.
789
953
  *
790
954
  * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean, requireOpenQuestions?: boolean }} input
791
955
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
792
956
  */
793
957
 
794
958
  // ---------------------------------------------------------------------------
795
- // Grill sub-loop body predicates (GRILL-SUBLOOP-*, #1628)
959
+ // Grill sub-loop body predicates (GRILL-SUBLOOP-*)
796
960
  // ---------------------------------------------------------------------------
797
961
  // The loop-grill skill writes its raw Q&A transcript and synthesis to an
798
962
  // ephemeral tmp artifact and keeps only the canonical synthesized sections
@@ -912,8 +1076,8 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
912
1076
 
913
1077
  /**
914
1078
  * Validate a TRACKER-BACKED PR's own body against the PR-description contract
915
- * (skills/docs/copilot-loop-operations.md "PR description contract", issue
916
- * #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
917
1081
  * Non-goals section, and a `Closes #N`/`Fixes #N` reference — regardless of
918
1082
  * whether the linked issue itself already carries a refinement artifact. A
919
1083
  * linked issue with real ACs is necessary but not sufficient: the PR body is
@@ -936,7 +1100,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
936
1100
  }
937
1101
 
938
1102
  /**
939
- * #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
940
1104
  * Acceptance criteria / Definition of done checklists — the derived,
941
1105
  * self-contained checklist that mirrors the linked issue's AC/DoD/Non-goals
942
1106
  * matrix. Any unchecked `- [ ]` in those sections means an acceptance
@@ -954,7 +1118,7 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
954
1118
  * (same `parseMarkdownSections` + `extractUncheckedChecklistItems` seams as
955
1119
  * `detectIssueRefinementArtifact` / `validatePrBodySpec`) so no parallel
956
1120
  * parser can drift. Sections absent from the body contribute no items — the
957
- * draft-exit `validateTrackerBackedPrBodySpec` check (#1863) already owns
1121
+ * draft-exit `validateTrackerBackedPrBodySpec` check already owns
958
1122
  * requiring the sections to EXIST.
959
1123
  *
960
1124
  * @param {{ body?: string }} input
@@ -967,7 +1131,7 @@ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
967
1131
  const sections = parseMarkdownSections(body);
968
1132
  // Union the unchecked boxes across ALL sections matching each pattern
969
1133
  // family (exact-first ordered), flattening each section past its deeper
970
- // sub-headings (#1877): a body nesting ACs under `###` subsections, or
1134
+ // sub-headings: a body nesting ACs under `###` subsections, or
971
1135
  // repeating an AC/DoD heading, must not hide unchecked boxes from the
972
1136
  // deterministic completeness block. Deduped by text (same box re-read in a
973
1137
  // duplicate section is the same box).
@@ -1004,13 +1168,13 @@ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
1004
1168
  */
1005
1169
  export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
1006
1170
  // `artifact.finding === null` is the explicit "passes the full refinement
1007
- // check" signal (artifact AND since #1866 an explicit Non-goals
1008
- // section AND since #1877 the full AC/DoD checklist matrix), clearer
1009
- // than reading `hasACs`, whose name understates what it covers.
1171
+ // check" signal (a valid AC→DoD mapping matrix + an explicit Non-goals
1172
+ // section, or a resolvable linked refinement doc + Non-goals), clearer than
1173
+ // reading `hasACs`, whose name understates what it covers.
1010
1174
  if (!targetIsPickup || artifact.finding === null) {
1011
1175
  return { action: "enqueue" };
1012
1176
  }
1013
- // #1866: artifact present but the contract-mandated Non-goals section is
1177
+ // artifact present but the contract-mandated Non-goals section is
1014
1178
  // absent/empty — a distinct failure with its own guidance.
1015
1179
  if (artifact.finding === MISSING_EXPLICIT_NON_GOALS_FINDING) {
1016
1180
  const reason =
@@ -1019,27 +1183,28 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1019
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.";
1020
1184
  return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
1021
1185
  }
1022
- // #1877 matrix arms: name the actual missing matrix arm an AC-only or
1023
- // DoD-only issue is NOT artifact-less, so the generic reason below would be
1024
- // factually wrong and would misdirect the fix.
1025
- if (artifact.finding === MISSING_DOD_CHECKLIST_FINDING) {
1186
+ // matrix present but empty/malformed/identifier-onlyname the shape
1187
+ // defect so the fix targets the mapping table, not a missing section.
1188
+ if (artifact.finding === MALFORMED_AC_DOD_MATRIX_FINDING) {
1026
1189
  const reason =
1027
- "Issue carries an Acceptance criteria checklist but no Definition of done checklist the refinement floor is the full AC/DoD/Non-goals matrix (#1877). " +
1028
- "Add a Definition of done checklist to the issue body (mapped to the acceptance criteria) " +
1029
- "(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 the full matrix.";
1030
- return { action: auto ? "divert" : "block", reason, missing: ["Definition of done checklist"] };
1190
+ "Issue carries an AC→DoD mapping matrix but it is empty, malformed, or identifier-only/tautological (e.g. `AC1 D1`). " +
1191
+ "Rewrite the mapping table so each row maps a concrete acceptance-criterion outcome to concrete completion evidence " +
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.";
1193
+ return { action: auto ? "divert" : "block", reason, missing: ["valid AC→DoD mapping matrix"] };
1031
1194
  }
1032
- if (artifact.finding === MISSING_AC_CHECKLIST_FINDING) {
1195
+ // matrix absent (whether or not the body carries duplicate issue-side
1196
+ // checklists) — the mapping table is the authoritative issue artifact.
1197
+ if (artifact.finding === MISSING_AC_DOD_MATRIX_FINDING) {
1033
1198
  const reason =
1034
- "Issue carries a Definition of done checklist but no Acceptance criteria checklist — the refinement floor is the full AC/DoD/Non-goals matrix (#1877). " +
1035
- "Add an Acceptance criteria checklist to the issue body (for the DoD items to map to) " +
1036
- "(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 the full matrix.";
1037
- return { action: auto ? "divert" : "block", reason, missing: ["Acceptance criteria checklist"] };
1199
+ "Issue carries Acceptance criteria / Definition of done content but no authoritative AC→DoD mapping matrix under matrix-on-issue/checklist-on-PR the mapping table is the authoritative issue artifact (#1951). " +
1200
+ "Add a semantic AC→DoD mapping table to the issue body (each acceptance-criterion outcome mapped to its required completion evidence), and an explicit Non-goals section if one is not already present " +
1201
+ "(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 the mapping matrix.";
1202
+ return { action: auto ? "divert" : "block", reason, missing: ["AC→DoD mapping matrix"] };
1038
1203
  }
1039
1204
  const missing = [...REFINEMENT_ARTIFACT_SOURCES];
1040
1205
  const reason =
1041
1206
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
1042
- "Refine the issue to the full AC/DoD/Non-goals matrix an Acceptance criteria checklist, a Definition of done checklist, and an explicit Non-goals section — " +
1207
+ "Refine the issue to the authoritative ACDoD mapping matrix (a two-column table mapping each acceptance-criterion outcome to its required completion evidence) plus an explicit Non-goals section — " +
1043
1208
  "or link a refinement doc (tmp/refinement/*.md), which is a complete artifact on its own " +
1044
1209
  "(e.g. run `/dev-loops:loop-grill <issue> --auto` (or `/loop-grill <issue> --auto` in the dev-loops repo itself), or the refiner) — before it enters the pickup queue.";
1045
1210
  return { action: auto ? "divert" : "block", reason, missing };
@@ -1074,7 +1239,7 @@ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild
1074
1239
  throw new Error("Invalid JSON input");
1075
1240
  }
1076
1241
  const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
1077
- // #1866: a linked refinement doc satisfies the gate only when it actually
1242
+ // a linked refinement doc satisfies the gate only when it actually
1078
1243
  // resolves. Paths follow the `tmp/refinement/*.md` convention and are
1079
1244
  // anchored to the caller's repo root (`repoRoot` option, falling back to
1080
1245
  // process.cwd()) — never the ambient cwd of whichever subdirectory the