@dev-loops/core 1.0.0 → 1.0.2-pre.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.
@@ -2,15 +2,35 @@
2
2
  * Deterministic issue refinement-artifact detection.
3
3
  *
4
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 (Acceptance criteria section, DoD section,
7
- * or a linked refinement doc) that the pre-approval gate can verify
8
- * against. Prose-only issues (Problem / Root Cause / Fix) without an
9
- * `Acceptance criteria` or `DoD` section cause the draft gate to post
10
- * `verdict=blocked` with the `missing_refinement_artifact` finding.
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
16
+ * `validateTrackerBackedPrBodySpec`, never this predicate).
11
17
  *
12
- * Since #1866 the check ALSO requires an explicit Non-goals section on the
13
- * issue body (see `MISSING_EXPLICIT_NON_GOALS_FINDING` below).
18
+ * 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
34
  */
15
35
  import { existsSync } from "node:fs";
16
36
  import path from "node:path";
@@ -28,6 +48,7 @@ import path from "node:path";
28
48
  */
29
49
 
30
50
  export const REFINEMENT_SOURCE = Object.freeze({
51
+ ISSUE_BODY_MATRIX: "issue-body-matrix",
31
52
  ISSUE_BODY_AC: "issue-body-ac",
32
53
  ISSUE_BODY_DOD: "issue-body-dod",
33
54
  LINKED_DOC: "linked-doc",
@@ -36,12 +57,14 @@ export const REFINEMENT_SOURCE = Object.freeze({
36
57
 
37
58
  const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
38
59
 
39
- // The three artifact sources, any ONE of which satisfies the refinement gate.
40
- // Single source of truth for the "missing" vocabulary reported when none is
41
- // present — consumed by the enqueue gate and the parked-unrefined discovery.
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.
42
65
  export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
43
- "Acceptance criteria section",
44
- "Definition of done section",
66
+ "AC→DoD mapping matrix (a two-column table)",
67
+ "explicit Non-goals section",
45
68
  "linked refinement doc",
46
69
  ]);
47
70
 
@@ -54,6 +77,25 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
54
77
  */
55
78
  export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
56
79
 
80
+ /**
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.
87
+ */
88
+ export const MISSING_AC_DOD_MATRIX_FINDING = "missing_ac_dod_matrix";
89
+
90
+ /**
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.
96
+ */
97
+ export const MALFORMED_AC_DOD_MATRIX_FINDING = "malformed_ac_dod_matrix";
98
+
57
99
  /**
58
100
  * Canonical list of section headings that satisfy the refinement check.
59
101
  * Matching is case-insensitive and tolerates trailing/leading whitespace.
@@ -62,16 +104,152 @@ export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
62
104
  * - one DoD-style section (DoD or Definition of Done)
63
105
  */
64
106
  const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
65
- /^acceptance criteria\s*$/i,
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.
115
+ /^acceptance criteria\b.*$/i,
66
116
  /^ac\b.*$/i,
67
117
  ]);
68
118
 
69
119
  const DOD_SECTION_PATTERNS = Object.freeze([
70
- /^definition of done\s*$/i,
71
- /^done\s*$/i,
72
- /^dod\s*$/i,
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).
125
+ /^definition of done\b.*$/i,
126
+ /^done\b.*$/i,
127
+ /^dod\b.*$/i,
73
128
  ]);
74
129
 
130
+ /**
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).
148
+ */
149
+ function normalizeHeadingName(name) {
150
+ if (typeof name !== "string") return name;
151
+ return name
152
+ // trailing decoration first: closing `##` ATX-style, colons, whitespace
153
+ .replace(/\s*:*\s*$/u, "")
154
+ .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)
159
+ .replace(/^[*_`]+/u, "")
160
+ .replace(/[*_`]+$/u, "")
161
+ .trim();
162
+ }
163
+
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.
170
+ const EXACT_PATTERN_INDEX = 0;
171
+
172
+ /**
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.
179
+ */
180
+ function findSectionByPatterns(sections, patterns) {
181
+ const exact = patterns[EXACT_PATTERN_INDEX];
182
+ for (const section of sections) {
183
+ if (exact.test(section.name)) {
184
+ return section;
185
+ }
186
+ }
187
+ for (const section of sections) {
188
+ for (let i = 1; i < patterns.length; i += 1) {
189
+ if (patterns[i].test(section.name)) {
190
+ return section;
191
+ }
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ /**
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.
202
+ */
203
+ function findAllSectionsByPatterns(sections, patterns) {
204
+ const exact = patterns[EXACT_PATTERN_INDEX];
205
+ const exactMatches = [];
206
+ const aliasMatches = [];
207
+ for (const section of sections) {
208
+ if (exact.test(section.name)) {
209
+ exactMatches.push(section);
210
+ } else {
211
+ for (let i = 1; i < patterns.length; i += 1) {
212
+ if (patterns[i].test(section.name)) {
213
+ aliasMatches.push(section);
214
+ break;
215
+ }
216
+ }
217
+ }
218
+ }
219
+ return [...exactMatches, ...aliasMatches];
220
+ }
221
+
222
+ /**
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.
231
+ */
232
+ function flattenSectionDeep(sections, startIndex) {
233
+ 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.)
245
+ const parts = [start.bodyLines.join("\n")];
246
+ for (let i = startIndex + 1; i < sections.length; i += 1) {
247
+ if (sections[i].level <= start.level) break;
248
+ parts.push(sections[i].bodyLines.join("\n"));
249
+ }
250
+ return parts.join("\n");
251
+ }
252
+
75
253
  /**
76
254
  * Fenced-code-span tracker. Given the previous fence state and the current
77
255
  * line, returns { fence, insideFence } where:
@@ -135,7 +313,12 @@ export function parseMarkdownSections(body) {
135
313
  }
136
314
  current = {
137
315
  level: match[1].length,
138
- name: match[2],
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).
321
+ name: normalizeHeadingName(match[2]),
139
322
  bodyLines: [],
140
323
  };
141
324
  continue;
@@ -152,22 +335,16 @@ export function parseMarkdownSections(body) {
152
335
  return sections;
153
336
  }
154
337
 
155
- function findSectionByPatterns(sections, patterns) {
156
- for (const section of sections) {
157
- for (const pattern of patterns) {
158
- if (pattern.test(section.name)) {
159
- return section;
160
- }
161
- }
162
- }
163
- return null;
164
- }
165
338
 
166
339
  /**
167
340
  * Parse bullet/checkbox items from a section body into item states. Each
168
- * checkbox item (`- [ ]`/`- [x]`/`- [X]`) becomes `{ text, checked }`
169
- * (`checked` true only for a ticked `[x]`/`[X]`); a top-level plain bullet
170
- * (`- text`, dash at column 0 so nested/indented sub-bullets are not counted)
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)
171
348
  * becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
172
349
  * checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
173
350
  * not counted, so a section of only unfilled placeholders reports as unrefined.
@@ -197,17 +374,30 @@ function parseChecklistItems(sectionBody) {
197
374
  if (step.insideFence) {
198
375
  continue;
199
376
  }
200
- // Checklist item: `- [ ]` / `- [x]` (leading indentation tolerated).
201
- // Consume ANY checkbox-marker line here; push only when it carries text,
202
- // so empty placeholders (`- [ ]`) are skipped rather than counted.
203
- const checkboxMatch = /^\s*-\s+\[(?:[ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
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.
391
+ const checkboxMatch =
392
+ /^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+\[([ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
204
393
  if (checkboxMatch) {
205
- const text = (checkboxMatch[1] ?? "").trim();
394
+ const text = (checkboxMatch[2] ?? "").trim();
206
395
  if (text.length > 0) {
207
- // `checked` is true only for a ticked box; `[ ]` (space) is false.
396
+ // `checked` is true only for a ticked marker (`[x]`/`[X]`); a space
397
+ // marker (`[ ]`) is false — regardless of what the label text says.
208
398
  // A plain bullet has no checkbox, so it stays `null` below — it is
209
399
  // neither ticked nor unticked and does not count as an unticked AC.
210
- items.push({ text, checked: /^\s*-\s+\[[xX]\]/u.test(line) });
400
+ items.push({ text, checked: checkboxMatch[1] !== " " });
211
401
  }
212
402
  continue;
213
403
  }
@@ -251,6 +441,214 @@ export function extractUncheckedChecklistItems(sectionBody) {
251
441
  .map((item) => item.text);
252
442
  }
253
443
 
444
+ // ---------------------------------------------------------------------------
445
+ // AC→DoD mapping matrix detection (#1951)
446
+ // ---------------------------------------------------------------------------
447
+ // The authoritative refined-issue artifact is a semantic AC→DoD mapping table:
448
+ // a GFM pipe table whose rows map each acceptance-criterion outcome to its
449
+ // required completion evidence. This is the "matrix on the issue" half of
450
+ // "matrix on the issue, checklist on the PR". Detection validates the table's
451
+ // PRESENCE and SHAPE only — its semantic truthfulness stays a reviewer duty.
452
+
453
+ // Heading families that name the mapping-matrix section. A qualifying table
454
+ // under one of these headings is treated as the matrix even when its column
455
+ // headers do not name criterion/evidence explicitly.
456
+ const MATRIX_SECTION_PATTERNS = Object.freeze([
457
+ /\bac\b.*\bdod\b.*\b(matrix|mapping|map)\b/i,
458
+ /\b(acceptance|criteri\w*)\b.*\b(matrix|mapping|map)\b/i,
459
+ /\bmapping (matrix|table)\b/i,
460
+ /\bac\s*(?:\/|→|->|to)\s*dod\b/i,
461
+ ]);
462
+
463
+ // 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
469
+ // 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.
472
+ const MATRIX_CRITERION_HEADER = /\b(criteri\w*|acceptance|ac)\b/i;
473
+ const MATRIX_EVIDENCE_HEADER = /\b(evidence|dod|definition of done)\b/i;
474
+
475
+ const TABLE_DELIMITER_RE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)+\|?\s*$/u;
476
+
477
+ /** Split one GFM table row into trimmed cell strings (drops leading/trailing pipes). */
478
+ function splitTableRow(line) {
479
+ let s = line.trim();
480
+ if (s.startsWith("|")) s = s.slice(1);
481
+ if (s.endsWith("|")) s = s.slice(0, -1);
482
+ // ponytail: no escaped-pipe (`\|`) handling — refined-issue matrix cells are
483
+ // short prose, not pipe-bearing code. Add a split-on-unescaped-pipe pass only
484
+ // if a real matrix cell ever needs a literal `|`.
485
+ return s.split("|").map((c) => c.trim());
486
+ }
487
+
488
+ /**
489
+ * Count real prose words in a matrix cell: runs of >=3 letters that are not the
490
+ * `dod` identifier token. Bare identifiers (`AC1`, `D1`, `DoD`), arrows, and
491
+ * digits contribute nothing, so a tautological/identifier-only cell scores 0.
492
+ */
493
+ function cellProseWordCount(cell) {
494
+ if (typeof cell !== "string") return 0;
495
+ const stripped = cell.replace(/[*_`]+/gu, " ");
496
+ const runs = stripped.match(/[A-Za-z]{3,}/gu) ?? [];
497
+ return runs.filter((w) => w.toLowerCase() !== "dod").length;
498
+ }
499
+
500
+ // 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).
508
+ function rowIsSemantic(criterion, evidence) {
509
+ return cellProseWordCount(criterion) >= 1 && cellProseWordCount(evidence) >= 1;
510
+ }
511
+
512
+ /**
513
+ * Parse every GFM pipe table in a Markdown body (skipping fenced code spans via
514
+ * the shared `stepFence`). Returns an array of
515
+ * `{ heading, headerCells, rows }` where `rows` is the list of data rows (each
516
+ * an array of trimmed cell strings). A table is a header line containing `|`,
517
+ * a delimiter row (`|---|---|`), and >=0 data rows.
518
+ */
519
+ function parseMarkdownTables(body) {
520
+ if (typeof body !== "string" || body.length === 0) return [];
521
+ const lines = body.split(/\r?\n/u);
522
+ const tables = [];
523
+ let fence = null;
524
+ let heading = null;
525
+ let i = 0;
526
+ while (i < lines.length) {
527
+ const step = stepFence(fence, lines[i]);
528
+ fence = step.fence;
529
+ if (step.insideFence) {
530
+ i += 1;
531
+ continue;
532
+ }
533
+ const headingMatch = /^(#{1,6})\s+(.+?)\s*$/u.exec(lines[i]);
534
+ if (headingMatch) {
535
+ heading = normalizeHeadingName(headingMatch[2]);
536
+ i += 1;
537
+ continue;
538
+ }
539
+ const header = lines[i];
540
+ const delim = lines[i + 1];
541
+ if (header.includes("|") && typeof delim === "string" && TABLE_DELIMITER_RE.test(delim)) {
542
+ const headerCells = splitTableRow(header);
543
+ const rows = [];
544
+ let j = i + 2;
545
+ while (j < lines.length) {
546
+ const rowStep = stepFence(fence, lines[j]);
547
+ // A table ends at the first non-fence line without a pipe, or a heading.
548
+ if (rowStep.insideFence) break;
549
+ if (!lines[j].includes("|") || /^#{1,6}\s+/u.test(lines[j])) break;
550
+ rows.push(splitTableRow(lines[j]));
551
+ j += 1;
552
+ }
553
+ tables.push({ heading, headerCells, rows });
554
+ i = j;
555
+ continue;
556
+ }
557
+ i += 1;
558
+ }
559
+ return tables;
560
+ }
561
+
562
+ /**
563
+ * Detect the authoritative AC→DoD mapping matrix in an issue body.
564
+ *
565
+ * A qualifying table has >=2 columns and EITHER sits under a matrix-named
566
+ * heading ({@link MATRIX_SECTION_PATTERNS}) OR names criterion/evidence-like
567
+ * columns in its header. The matrix is VALID when it carries at least one
568
+ * SEMANTIC data row (both mapped cells carry real prose — see
569
+ * {@link rowIsSemantic}); a header/separator-only table (no data rows) or a
570
+ * table whose rows are all identifier-only/tautological (`AC1 → D1`) is
571
+ * malformed.
572
+ *
573
+ * @param {string} [body]
574
+ * @returns {{ found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string }}
575
+ */
576
+ export function detectAcDodMatrix(body = "") {
577
+ const tables = parseMarkdownTables(body);
578
+ const candidates = tables.filter((t) => {
579
+ if (!Array.isArray(t.headerCells) || t.headerCells.length < 2) return false;
580
+ const underHeading = typeof t.heading === "string" &&
581
+ MATRIX_SECTION_PATTERNS.some((p) => p.test(t.heading));
582
+ const headerNamesMap =
583
+ MATRIX_CRITERION_HEADER.test(t.headerCells[0] ?? "") &&
584
+ MATRIX_EVIDENCE_HEADER.test(t.headerCells[1] ?? "");
585
+ return underHeading || headerNamesMap;
586
+ });
587
+ if (candidates.length === 0) {
588
+ return { found: false, valid: false, rowCount: 0, rows: [], reason: "No AC→DoD mapping matrix table found." };
589
+ }
590
+ // Prefer the first candidate that has >=1 semantic row; otherwise report the
591
+ // first candidate as malformed.
592
+ for (const table of candidates) {
593
+ const semanticRows = [];
594
+ for (const cells of table.rows) {
595
+ if (cells.length < 2) continue;
596
+ const criterion = cells[0] ?? "";
597
+ const evidence = cells[1] ?? "";
598
+ if (rowIsSemantic(criterion, evidence)) {
599
+ semanticRows.push({ criterion, evidence });
600
+ }
601
+ }
602
+ if (semanticRows.length > 0) {
603
+ return {
604
+ found: true,
605
+ valid: true,
606
+ rowCount: semanticRows.length,
607
+ rows: semanticRows,
608
+ reason: `Found an AC→DoD mapping matrix with ${semanticRows.length} semantic row(s).`,
609
+ };
610
+ }
611
+ }
612
+ const dataRowCount = candidates[0].rows.length;
613
+ return {
614
+ found: true,
615
+ valid: false,
616
+ rowCount: 0,
617
+ rows: [],
618
+ reason: dataRowCount === 0
619
+ ? "AC→DoD mapping matrix table is empty (header/separator only, no data rows)."
620
+ : "AC→DoD mapping matrix table is identifier-only/tautological (no row maps a concrete criterion to concrete completion evidence).",
621
+ };
622
+ }
623
+
624
+ /**
625
+ * 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
627
+ * Definition of done checkboxes derived from the matrix — never a matrix/table,
628
+ * never checkboxes inside table cells. Accepts a pre-parsed `matrix` (from
629
+ * {@link detectAcDodMatrix}) or a raw `body` to parse. Fails closed on a
630
+ * missing/malformed matrix rather than emitting empty checklists.
631
+ *
632
+ * @param {{ matrix?: ReturnType<typeof detectAcDodMatrix>, body?: string }} input
633
+ * @returns {{ acChecklist: string[], dodChecklist: string[], markdown: string }}
634
+ */
635
+ export function derivePrChecklistsFromIssueMatrix({ matrix = null, body = "" } = {}) {
636
+ const m = matrix ?? detectAcDodMatrix(body);
637
+ if (!m || !m.found || !m.valid || !Array.isArray(m.rows) || m.rows.length === 0) {
638
+ throw Object.assign(
639
+ new Error(`derivePrChecklistsFromIssueMatrix: ${m?.reason ?? "no valid AC→DoD mapping matrix to project"}`),
640
+ { code: "MALFORMED_MATRIX_SOURCE" },
641
+ );
642
+ }
643
+ const dedupe = (items) => [...new Set(items.map((s) => s.trim()).filter((s) => s.length > 0))];
644
+ const acChecklist = dedupe(m.rows.map((r) => r.criterion));
645
+ const dodChecklist = dedupe(m.rows.map((r) => r.evidence));
646
+ const render = (heading, items) =>
647
+ `## ${heading}\n\n${items.map((t) => `- [ ] ${t}`).join("\n")}\n`;
648
+ const markdown = `${render("Acceptance criteria", acChecklist)}\n${render("Definition of done", dodChecklist)}`;
649
+ return { acChecklist, dodChecklist, markdown };
650
+ }
651
+
254
652
  /**
255
653
  * Detect a linked refinement doc path from the issue body.
256
654
  * Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
@@ -308,9 +706,12 @@ export function detectLinkedRefinementDoc(body) {
308
706
  * `validatePrBodySpec` (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns`),
309
707
  * so the two spec surfaces cannot drift on what counts as an explicit
310
708
  * Non-goals section. `hasACs` keeps its caller-facing meaning: true only when
311
- * the FULL check passes, so every `.hasACs` consumer (enqueue gate, draft
312
- * gate, parked-items discovery, gate context) fails closed with no call-site
313
- * change.
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.
314
715
  *
315
716
  * `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
316
717
  * verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
@@ -340,6 +741,7 @@ export function detectLinkedRefinementDoc(body) {
340
741
  * dodItems: string[],
341
742
  * sections: string[],
342
743
  * linkedDoc: { found: boolean, path: string|null, reason: string, resolves?: boolean },
744
+ * matrix: { found: boolean, valid: boolean, rowCount: number, rows: { criterion: string, evidence: string }[], reason: string },
343
745
  * reason: string,
344
746
  * finding: string|null,
345
747
  * }}
@@ -355,7 +757,8 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
355
757
  dodItems: [],
356
758
  sections: [],
357
759
  linkedDoc: { found: false, path: null, reason: "empty-body" },
358
- reason: "Issue body is empty; no ACs/DoD/linked-doc can be detected.",
760
+ matrix: { found: false, valid: false, rowCount: 0, rows: [], reason: "empty-body" },
761
+ reason: "Issue body is empty; no matrix/ACs/DoD/linked-doc can be detected.",
359
762
  finding: REFINEMENT_ARTIFACT_FINDING,
360
763
  };
361
764
  }
@@ -366,6 +769,18 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
366
769
  const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
367
770
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
368
771
 
772
+ // CONSUMER-CONTRACT BOUNDARY (#1877, intentional asymmetry): the issue-side
773
+ // reads above are strict — ONE exact-first section, NO deep flattening —
774
+ // while extractPrBodyUncheckedChecklistItems (PR side) unions ALL matching
775
+ // sections and deep-flattens past ### sub-headings. The issue side is a
776
+ // presence check of the refinement matrix: a checklist hidden entirely
777
+ // under a ### sub-heading fails CLOSED (reported missing, the issue stays
778
+ // parked for human refinement). The PR side enforces a hard gate over the
779
+ // derived checklist: it must NEVER miss an unchecked box, so it fails open
780
+ // on nothing — it unions and deep-flattens. Do not "unify" these reads: the
781
+ // two failure directions are both deliberate (issue side = safe direction,
782
+ // PR side = fail-closed gate).
783
+
369
784
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
370
785
  // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
371
786
  // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
@@ -389,13 +804,15 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
389
804
  findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
390
805
  );
391
806
 
392
- const artifactSource = acItems.length > 0
393
- ? REFINEMENT_SOURCE.ISSUE_BODY_AC
394
- : dodItems.length > 0
395
- ? REFINEMENT_SOURCE.ISSUE_BODY_DOD
396
- : linkedDocResolves
397
- ? REFINEMENT_SOURCE.LINKED_DOC
398
- : null;
807
+ // #1951: the authoritative issue artifact is the AC→DoD mapping MATRIX, not
808
+ // duplicate interactive issue-side checklists. Detect its presence + shape.
809
+ const matrix = detectAcDodMatrix(body);
810
+
811
+ // Keep acItems/dodItems populated for downstream consumers (gate context,
812
+ // coordination state) even when the issue carries only the matrix and no
813
+ // interactive checklists: project the matrix rows into AC/DoD items.
814
+ const effectiveAcItems = acItems.length > 0 ? acItems : matrix.rows.map((r) => r.criterion.trim()).filter(Boolean);
815
+ const effectiveDodItems = dodItems.length > 0 ? dodItems : matrix.rows.map((r) => r.evidence.trim()).filter(Boolean);
399
816
 
400
817
  const base = {
401
818
  hasNonGoals,
@@ -404,39 +821,24 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
404
821
  dodItems,
405
822
  sections: sectionNames,
406
823
  linkedDoc,
824
+ matrix,
407
825
  };
408
826
 
409
- if (artifactSource !== null) {
827
+ // A linked refinement doc remains a complete artifact on its own (the doc
828
+ // carries the matrix). It still requires an explicit Non-goals section.
829
+ if (linkedDocResolves) {
410
830
  if (!hasNonGoals) {
411
831
  return {
412
832
  ...base,
413
833
  hasACs: false,
414
- source: artifactSource,
834
+ source: REFINEMENT_SOURCE.LINKED_DOC,
415
835
  reason:
416
- `Issue body carries a refinement artifact (${artifactSource}) but no explicit Non-goals section; ` +
836
+ "Issue body links a refinement doc but has no explicit Non-goals section; " +
417
837
  "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
418
838
  "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
419
839
  finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
420
840
  };
421
841
  }
422
- if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_AC) {
423
- return {
424
- ...base,
425
- hasACs: true,
426
- source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
427
- reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
428
- finding: null,
429
- };
430
- }
431
- if (artifactSource === REFINEMENT_SOURCE.ISSUE_BODY_DOD) {
432
- return {
433
- ...base,
434
- hasACs: true,
435
- source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
436
- reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
437
- finding: null,
438
- };
439
- }
440
842
  return {
441
843
  ...base,
442
844
  hasACs: true,
@@ -449,6 +851,67 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
449
851
  };
450
852
  }
451
853
 
854
+ // Matrix present but empty/malformed/identifier-only: fail closed (#1951 AC2).
855
+ if (matrix.found && !matrix.valid) {
856
+ return {
857
+ ...base,
858
+ hasACs: false,
859
+ source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
860
+ reason:
861
+ `Issue body carries an AC→DoD mapping matrix but it is not a valid semantic mapping (${matrix.reason}); ` +
862
+ "the refinement contract requires a real criterion→completion-evidence mapping " +
863
+ "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
864
+ "Refusing: the refinement check fails closed on a malformed/identifier-only matrix.",
865
+ finding: MALFORMED_AC_DOD_MATRIX_FINDING,
866
+ };
867
+ }
868
+
869
+ // Matrix present and valid: the refinement floor is the matrix + explicit
870
+ // Non-goals. Interactive issue-side AC/DoD checklists are NOT required.
871
+ if (matrix.found && matrix.valid) {
872
+ if (!hasNonGoals) {
873
+ return {
874
+ ...base,
875
+ hasACs: false,
876
+ source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
877
+ reason:
878
+ "Issue body carries a valid AC→DoD mapping matrix but no explicit Non-goals section; " +
879
+ "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
880
+ "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
881
+ finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
882
+ };
883
+ }
884
+ return {
885
+ ...base,
886
+ hasACs: true,
887
+ source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
888
+ acItems: effectiveAcItems,
889
+ dodItems: effectiveDodItems,
890
+ reason: `Found a valid AC→DoD mapping matrix with ${matrix.rowCount} semantic row(s) and an explicit Non-goals section.`,
891
+ finding: null,
892
+ };
893
+ }
894
+
895
+ // 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
898
+ // with no matrix and no AC/DoD content (prose-only, or only a Non-goals
899
+ // section / an unresolved linked-doc mention) stays the pre-existing
900
+ // missing_refinement_artifact.
901
+ if (acItems.length > 0 || dodItems.length > 0) {
902
+ return {
903
+ ...base,
904
+ hasACs: false,
905
+ source: REFINEMENT_SOURCE.MISSING,
906
+ reason:
907
+ "Issue body carries Acceptance criteria / Definition of done content but no authoritative AC→DoD mapping matrix table; " +
908
+ "under matrix-on-issue/checklist-on-PR the mapping table is the authoritative issue artifact " +
909
+ "(rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; e.g. run the loop-grill synthesis). " +
910
+ "Refusing: the refinement check fails closed without the mapping matrix.",
911
+ finding: MISSING_AC_DOD_MATRIX_FINDING,
912
+ };
913
+ }
914
+
452
915
  return {
453
916
  ...base,
454
917
  hasACs: false,
@@ -456,7 +919,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
456
919
  acItems: [],
457
920
  uncheckedAcItems: [],
458
921
  dodItems: [],
459
- reason: "Issue body has no Acceptance criteria section, no DoD section, and no linked refinement doc.",
922
+ reason: "Issue body has no AC→DoD mapping matrix, no Acceptance criteria/DoD content, and no linked refinement doc.",
460
923
  finding: REFINEMENT_ARTIFACT_FINDING,
461
924
  };
462
925
  }
@@ -730,6 +1193,57 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
730
1193
  return validatePrBodySpec({ body, expectedIssue, requireOpenQuestions: false });
731
1194
  }
732
1195
 
1196
+ /**
1197
+ * #1877: extract the UNCHECKED AC/DoD checkbox items from a PR body's own
1198
+ * Acceptance criteria / Definition of done checklists — the derived,
1199
+ * self-contained checklist that mirrors the linked issue's AC/DoD/Non-goals
1200
+ * matrix. Any unchecked `- [ ]` in those sections means an acceptance
1201
+ * criterion or definition-of-done item is still open, and the deterministic
1202
+ * pre-approval block (`upsert-checkpoint-verdict.mjs`) fails the gate closed:
1203
+ * the round is `blocked` and the PR cannot reach approval with an open
1204
+ * acceptance criterion. This enforces COMPLETENESS (nothing left
1205
+ * unchecked/forgotten), not truthfulness — a dishonestly-ticked `[x]` passes
1206
+ * this mechanical check and remains the reviewer/judge's responsibility
1207
+ * (ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Composes with
1208
+ * `tick-verified-checkboxes.mjs`: a box the gate could not verify stays
1209
+ * unchecked and therefore blocks.
1210
+ *
1211
+ * Pure; no I/O. Reuses the shared section patterns and checklist parser
1212
+ * (same `parseMarkdownSections` + `extractUncheckedChecklistItems` seams as
1213
+ * `detectIssueRefinementArtifact` / `validatePrBodySpec`) so no parallel
1214
+ * parser can drift. Sections absent from the body contribute no items — the
1215
+ * draft-exit `validateTrackerBackedPrBodySpec` check (#1863) already owns
1216
+ * requiring the sections to EXIST.
1217
+ *
1218
+ * @param {{ body?: string }} input
1219
+ * @returns {{ uncheckedAcItems: string[], uncheckedDodItems: string[] }}
1220
+ */
1221
+ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
1222
+ if (typeof body !== "string" || body.length === 0) {
1223
+ return { uncheckedAcItems: [], uncheckedDodItems: [] };
1224
+ }
1225
+ const sections = parseMarkdownSections(body);
1226
+ // Union the unchecked boxes across ALL sections matching each pattern
1227
+ // family (exact-first ordered), flattening each section past its deeper
1228
+ // sub-headings (#1877): a body nesting ACs under `###` subsections, or
1229
+ // repeating an AC/DoD heading, must not hide unchecked boxes from the
1230
+ // deterministic completeness block. Deduped by text (same box re-read in a
1231
+ // duplicate section is the same box).
1232
+ const collect = (patterns) => {
1233
+ const matched = findAllSectionsByPatterns(sections, patterns);
1234
+ const items = [];
1235
+ for (let i = 0; i < sections.length; i += 1) {
1236
+ if (!matched.includes(sections[i])) continue;
1237
+ items.push(...extractUncheckedChecklistItems(flattenSectionDeep(sections, i)));
1238
+ }
1239
+ return [...new Set(items)];
1240
+ };
1241
+ return {
1242
+ uncheckedAcItems: collect(ACCEPTANCE_SECTION_PATTERNS),
1243
+ uncheckedDodItems: collect(DOD_SECTION_PATTERNS),
1244
+ };
1245
+ }
1246
+
733
1247
  /**
734
1248
  * Decide what an enqueue caller should do with a refinement-artifact result,
735
1249
  * so an un-refined item never lands in the Next Up pickup column in the first
@@ -748,9 +1262,9 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
748
1262
  */
749
1263
  export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
750
1264
  // `artifact.finding === null` is the explicit "passes the full refinement
751
- // check" signal (artifact AND — since #1866 — an explicit Non-goals
752
- // section), clearer than reading `hasACs`, whose name understates what it
753
- // covers.
1265
+ // check" signal (a valid AC→DoD mapping matrix + an explicit Non-goals
1266
+ // section, or a resolvable linked refinement doc + Non-goals), clearer than
1267
+ // reading `hasACs`, whose name understates what it covers.
754
1268
  if (!targetIsPickup || artifact.finding === null) {
755
1269
  return { action: "enqueue" };
756
1270
  }
@@ -763,10 +1277,29 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
763
1277
  "(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.";
764
1278
  return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
765
1279
  }
1280
+ // #1951: matrix present but empty/malformed/identifier-only — name the shape
1281
+ // defect so the fix targets the mapping table, not a missing section.
1282
+ if (artifact.finding === MALFORMED_AC_DOD_MATRIX_FINDING) {
1283
+ const reason =
1284
+ "Issue carries an AC→DoD mapping matrix but it is empty, malformed, or identifier-only/tautological (e.g. `AC1 → D1`). " +
1285
+ "Rewrite the mapping table so each row maps a concrete acceptance-criterion outcome to concrete completion evidence " +
1286
+ "(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
+ return { action: auto ? "divert" : "block", reason, missing: ["valid AC→DoD mapping matrix"] };
1288
+ }
1289
+ // #1951: matrix absent (whether or not the body carries duplicate issue-side
1290
+ // checklists) — the mapping table is the authoritative issue artifact.
1291
+ if (artifact.finding === MISSING_AC_DOD_MATRIX_FINDING) {
1292
+ const reason =
1293
+ "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). " +
1294
+ "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 " +
1295
+ "(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.";
1296
+ return { action: auto ? "divert" : "block", reason, missing: ["AC→DoD mapping matrix"] };
1297
+ }
766
1298
  const missing = [...REFINEMENT_ARTIFACT_SOURCES];
767
1299
  const reason =
768
1300
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
769
- "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
1301
+ "Refine the issue to the authoritative AC→DoD mapping matrix (a two-column table mapping each acceptance-criterion outcome to its required completion evidence) plus an explicit Non-goals section — " +
1302
+ "or link a refinement doc (tmp/refinement/*.md), which is a complete artifact on its own " +
770
1303
  "(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.";
771
1304
  return { action: auto ? "divert" : "block", reason, missing };
772
1305
  }