@dev-loops/core 1.0.1 → 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,16 +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 — 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
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).
17
+ *
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
12
24
  * `missing_refinement_artifact`); prose-only issues (Problem / Root Cause /
13
25
  * Fix) cause the draft gate to post `verdict=blocked` with the
14
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.
15
34
  */
16
35
  import { existsSync } from "node:fs";
17
36
  import path from "node:path";
@@ -29,6 +48,7 @@ import path from "node:path";
29
48
  */
30
49
 
31
50
  export const REFINEMENT_SOURCE = Object.freeze({
51
+ ISSUE_BODY_MATRIX: "issue-body-matrix",
32
52
  ISSUE_BODY_AC: "issue-body-ac",
33
53
  ISSUE_BODY_DOD: "issue-body-dod",
34
54
  LINKED_DOC: "linked-doc",
@@ -37,13 +57,14 @@ export const REFINEMENT_SOURCE = Object.freeze({
37
57
 
38
58
  const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
39
59
 
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.
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.
44
65
  export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
45
- "Acceptance criteria section",
46
- "Definition of done section",
66
+ "AC→DoD mapping matrix (a two-column table)",
67
+ "explicit Non-goals section",
47
68
  "linked refinement doc",
48
69
  ]);
49
70
 
@@ -57,22 +78,23 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
57
78
  export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
58
79
 
59
80
  /**
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.
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.
66
87
  */
67
- export const MISSING_DOD_CHECKLIST_FINDING = "missing_dod_checklist";
88
+ export const MISSING_AC_DOD_MATRIX_FINDING = "missing_ac_dod_matrix";
68
89
 
69
90
  /**
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.
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.
74
96
  */
75
- export const MISSING_AC_CHECKLIST_FINDING = "missing_ac_checklist";
97
+ export const MALFORMED_AC_DOD_MATRIX_FINDING = "malformed_ac_dod_matrix";
76
98
 
77
99
  /**
78
100
  * Canonical list of section headings that satisfy the refinement check.
@@ -419,6 +441,214 @@ export function extractUncheckedChecklistItems(sectionBody) {
419
441
  .map((item) => item.text);
420
442
  }
421
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
+
422
652
  /**
423
653
  * Detect a linked refinement doc path from the issue body.
424
654
  * Looks for explicit `tmp/refinement/<n>-plan.md` style paths and the
@@ -476,9 +706,12 @@ export function detectLinkedRefinementDoc(body) {
476
706
  * `validatePrBodySpec` (`PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns`),
477
707
  * so the two spec surfaces cannot drift on what counts as an explicit
478
708
  * 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.
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.
482
715
  *
483
716
  * `resolveLinkedDoc` (optional, #1866): a `(path) => boolean` callback used to
484
717
  * verify that a linked `tmp/refinement/*.md` doc actually resolves (e.g.
@@ -508,6 +741,7 @@ export function detectLinkedRefinementDoc(body) {
508
741
  * dodItems: string[],
509
742
  * sections: string[],
510
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 },
511
745
  * reason: string,
512
746
  * finding: string|null,
513
747
  * }}
@@ -523,7 +757,8 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
523
757
  dodItems: [],
524
758
  sections: [],
525
759
  linkedDoc: { found: false, path: null, reason: "empty-body" },
526
- 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.",
527
762
  finding: REFINEMENT_ARTIFACT_FINDING,
528
763
  };
529
764
  }
@@ -569,13 +804,15 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
569
804
  findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
570
805
  );
571
806
 
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;
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);
579
816
 
580
817
  const base = {
581
818
  hasNonGoals,
@@ -584,76 +821,97 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
584
821
  dodItems,
585
822
  sections: sectionNames,
586
823
  linkedDoc,
824
+ matrix,
587
825
  };
588
826
 
589
- 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) {
590
830
  if (!hasNonGoals) {
591
831
  return {
592
832
  ...base,
593
833
  hasACs: false,
594
- source: artifactSource,
834
+ source: REFINEMENT_SOURCE.LINKED_DOC,
595
835
  reason:
596
- `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; " +
597
837
  "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
598
838
  "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
599
839
  finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
600
840
  };
601
841
  }
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.
842
+ return {
843
+ ...base,
844
+ hasACs: true,
845
+ source: REFINEMENT_SOURCE.LINKED_DOC,
846
+ acItems: [],
847
+ uncheckedAcItems: [],
848
+ dodItems: [],
849
+ reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
850
+ finding: null,
851
+ };
852
+ }
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) {
633
873
  return {
634
874
  ...base,
635
875
  hasACs: false,
636
- source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
876
+ source: REFINEMENT_SOURCE.ISSUE_BODY_MATRIX,
637
877
  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,
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,
643
882
  };
644
883
  }
645
884
  return {
646
885
  ...base,
647
886
  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.`,
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.`,
653
891
  finding: null,
654
892
  };
655
893
  }
656
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
+
657
915
  return {
658
916
  ...base,
659
917
  hasACs: false,
@@ -661,7 +919,7 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
661
919
  acItems: [],
662
920
  uncheckedAcItems: [],
663
921
  dodItems: [],
664
- 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.",
665
923
  finding: REFINEMENT_ARTIFACT_FINDING,
666
924
  };
667
925
  }
@@ -1004,9 +1262,9 @@ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
1004
1262
  */
1005
1263
  export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
1006
1264
  // `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.
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.
1010
1268
  if (!targetIsPickup || artifact.finding === null) {
1011
1269
  return { action: "enqueue" };
1012
1270
  }
@@ -1019,27 +1277,28 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
1019
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.";
1020
1278
  return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
1021
1279
  }
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) {
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) {
1026
1283
  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"] };
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"] };
1031
1288
  }
1032
- if (artifact.finding === MISSING_AC_CHECKLIST_FINDING) {
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) {
1033
1292
  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"] };
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"] };
1038
1297
  }
1039
1298
  const missing = [...REFINEMENT_ARTIFACT_SOURCES];
1040
1299
  const reason =
1041
1300
  `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 — " +
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 — " +
1043
1302
  "or link a refinement doc (tmp/refinement/*.md), which is a complete artifact on its own " +
1044
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.";
1045
1304
  return { action: auto ? "divert" : "block", reason, missing };