@dev-loops/core 1.0.0-rc.7 → 1.0.1

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.
@@ -3,12 +3,20 @@
3
3
  *
4
4
  * Implements the bounded refinement check required by the draft gate per
5
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.
11
- *
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.
15
+ */
16
+ import { existsSync } from "node:fs";
17
+ import path from "node:path";
18
+
19
+ /**
12
20
  * This module owns:
13
21
  * - canonical section-name matching for AC / DoD blocks
14
22
  * - bullet-item extraction (checklist `- [ ]`/`- [x]` and top-level `- ` bullets)
@@ -29,15 +37,43 @@ export const REFINEMENT_SOURCE = Object.freeze({
29
37
 
30
38
  const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
31
39
 
32
- // The three artifact sources, any ONE of which satisfies the refinement gate.
33
- // Single source of truth for the "missing" vocabulary reported when none is
34
- // present — consumed by the enqueue gate and the parked-unrefined discovery.
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.
35
44
  export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
36
45
  "Acceptance criteria section",
37
46
  "Definition of done section",
38
47
  "linked refinement doc",
39
48
  ]);
40
49
 
50
+ /**
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
+ */
57
+ export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
58
+
59
+ /**
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.
66
+ */
67
+ export const MISSING_DOD_CHECKLIST_FINDING = "missing_dod_checklist";
68
+
69
+ /**
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.
74
+ */
75
+ export const MISSING_AC_CHECKLIST_FINDING = "missing_ac_checklist";
76
+
41
77
  /**
42
78
  * Canonical list of section headings that satisfy the refinement check.
43
79
  * Matching is case-insensitive and tolerates trailing/leading whitespace.
@@ -46,16 +82,152 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
46
82
  * - one DoD-style section (DoD or Definition of Done)
47
83
  */
48
84
  const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
49
- /^acceptance criteria\s*$/i,
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.
93
+ /^acceptance criteria\b.*$/i,
50
94
  /^ac\b.*$/i,
51
95
  ]);
52
96
 
53
97
  const DOD_SECTION_PATTERNS = Object.freeze([
54
- /^definition of done\s*$/i,
55
- /^done\s*$/i,
56
- /^dod\s*$/i,
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).
103
+ /^definition of done\b.*$/i,
104
+ /^done\b.*$/i,
105
+ /^dod\b.*$/i,
57
106
  ]);
58
107
 
108
+ /**
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).
126
+ */
127
+ function normalizeHeadingName(name) {
128
+ if (typeof name !== "string") return name;
129
+ return name
130
+ // trailing decoration first: closing `##` ATX-style, colons, whitespace
131
+ .replace(/\s*:*\s*$/u, "")
132
+ .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)
137
+ .replace(/^[*_`]+/u, "")
138
+ .replace(/[*_`]+$/u, "")
139
+ .trim();
140
+ }
141
+
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.
148
+ const EXACT_PATTERN_INDEX = 0;
149
+
150
+ /**
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.
157
+ */
158
+ function findSectionByPatterns(sections, patterns) {
159
+ const exact = patterns[EXACT_PATTERN_INDEX];
160
+ for (const section of sections) {
161
+ if (exact.test(section.name)) {
162
+ return section;
163
+ }
164
+ }
165
+ for (const section of sections) {
166
+ for (let i = 1; i < patterns.length; i += 1) {
167
+ if (patterns[i].test(section.name)) {
168
+ return section;
169
+ }
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ /**
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.
180
+ */
181
+ function findAllSectionsByPatterns(sections, patterns) {
182
+ const exact = patterns[EXACT_PATTERN_INDEX];
183
+ const exactMatches = [];
184
+ const aliasMatches = [];
185
+ for (const section of sections) {
186
+ if (exact.test(section.name)) {
187
+ exactMatches.push(section);
188
+ } else {
189
+ for (let i = 1; i < patterns.length; i += 1) {
190
+ if (patterns[i].test(section.name)) {
191
+ aliasMatches.push(section);
192
+ break;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ return [...exactMatches, ...aliasMatches];
198
+ }
199
+
200
+ /**
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.
209
+ */
210
+ function flattenSectionDeep(sections, startIndex) {
211
+ 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.)
223
+ const parts = [start.bodyLines.join("\n")];
224
+ for (let i = startIndex + 1; i < sections.length; i += 1) {
225
+ if (sections[i].level <= start.level) break;
226
+ parts.push(sections[i].bodyLines.join("\n"));
227
+ }
228
+ return parts.join("\n");
229
+ }
230
+
59
231
  /**
60
232
  * Fenced-code-span tracker. Given the previous fence state and the current
61
233
  * line, returns { fence, insideFence } where:
@@ -119,7 +291,12 @@ export function parseMarkdownSections(body) {
119
291
  }
120
292
  current = {
121
293
  level: match[1].length,
122
- name: match[2],
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).
299
+ name: normalizeHeadingName(match[2]),
123
300
  bodyLines: [],
124
301
  };
125
302
  continue;
@@ -136,22 +313,16 @@ export function parseMarkdownSections(body) {
136
313
  return sections;
137
314
  }
138
315
 
139
- function findSectionByPatterns(sections, patterns) {
140
- for (const section of sections) {
141
- for (const pattern of patterns) {
142
- if (pattern.test(section.name)) {
143
- return section;
144
- }
145
- }
146
- }
147
- return null;
148
- }
149
316
 
150
317
  /**
151
318
  * Parse bullet/checkbox items from a section body into item states. Each
152
- * checkbox item (`- [ ]`/`- [x]`/`- [X]`) becomes `{ text, checked }`
153
- * (`checked` true only for a ticked `[x]`/`[X]`); a top-level plain bullet
154
- * (`- text`, dash at column 0 so nested/indented sub-bullets are not counted)
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)
155
326
  * becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
156
327
  * checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
157
328
  * not counted, so a section of only unfilled placeholders reports as unrefined.
@@ -181,17 +352,30 @@ function parseChecklistItems(sectionBody) {
181
352
  if (step.insideFence) {
182
353
  continue;
183
354
  }
184
- // Checklist item: `- [ ]` / `- [x]` (leading indentation tolerated).
185
- // Consume ANY checkbox-marker line here; push only when it carries text,
186
- // so empty placeholders (`- [ ]`) are skipped rather than counted.
187
- const checkboxMatch = /^\s*-\s+\[(?:[ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
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.
369
+ const checkboxMatch =
370
+ /^\s*(?:>|\s)*(?:[-*+]|\d+[.)])\s+\[([ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
188
371
  if (checkboxMatch) {
189
- const text = (checkboxMatch[1] ?? "").trim();
372
+ const text = (checkboxMatch[2] ?? "").trim();
190
373
  if (text.length > 0) {
191
- // `checked` is true only for a ticked box; `[ ]` (space) is false.
374
+ // `checked` is true only for a ticked marker (`[x]`/`[X]`); a space
375
+ // marker (`[ ]`) is false — regardless of what the label text says.
192
376
  // A plain bullet has no checkbox, so it stays `null` below — it is
193
377
  // neither ticked nor unticked and does not count as an unticked AC.
194
- items.push({ text, checked: /^\s*-\s+\[[xX]\]/u.test(line) });
378
+ items.push({ text, checked: checkboxMatch[1] !== " " });
195
379
  }
196
380
  continue;
197
381
  }
@@ -248,6 +432,13 @@ export function detectLinkedRefinementDoc(body) {
248
432
 
249
433
  const pathMatch = /(?:^|\s|[`(\[<])(tmp\/refinement\/[A-Za-z0-9._/\-]+\.md)\b/u.exec(body);
250
434
  if (pathMatch) {
435
+ // Containment guard: reject actual '..' path segments (not benign
436
+ // double-dot filenames) so the new fs-probe wiring can never be used as a
437
+ // filesystem existence oracle outside tmp/refinement
438
+ // (e.g. `tmp/refinement/../../docs/some-existing.md`).
439
+ if (pathMatch[1].split("/").some((segment) => segment === "..")) {
440
+ return { found: false, path: null, reason: "path-escapes-refinement-dir" };
441
+ }
251
442
  return { found: true, path: pathMatch[1], reason: "explicit-path" };
252
443
  }
253
444
 
@@ -261,6 +452,11 @@ export function detectLinkedRefinementDoc(body) {
261
452
  if (refinementSection) {
262
453
  const inlinePath = /(?:^|\s)(tmp\/refinement\/[^\s)`'"]+\.md)\b/u.exec(refinementSection.bodyLines.join("\n"));
263
454
  if (inlinePath) {
455
+ // Containment guard: same segment-based '..' rejection as the
456
+ // explicit-path branch.
457
+ if (inlinePath[1].split("/").some((segment) => segment === "..")) {
458
+ return { found: false, path: null, reason: "path-escapes-refinement-dir" };
459
+ }
264
460
  return { found: true, path: inlinePath[1], reason: "refinement-section-path" };
265
461
  }
266
462
  }
@@ -271,25 +467,56 @@ export function detectLinkedRefinementDoc(body) {
271
467
  /**
272
468
  * Detect the refinement artifact on a parsed issue body.
273
469
  *
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).
492
+ *
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.
497
+ *
274
498
  * @param {object} input
275
499
  * @param {string} [input.body] Raw issue body Markdown.
276
500
  * @param {number} [input.issueNumber] Issue number, used for linked-doc convention.
501
+ * @param {Function} [input.resolveLinkedDoc] Optional `(path) => boolean` doc-resolution check.
277
502
  * @returns {{
278
503
  * hasACs: boolean,
504
+ * hasNonGoals: boolean,
279
505
  * source: string,
280
506
  * acItems: string[],
281
507
  * uncheckedAcItems: string[],
282
508
  * dodItems: string[],
283
509
  * sections: string[],
284
- * linkedDoc: { found: boolean, path: string|null, reason: string },
510
+ * linkedDoc: { found: boolean, path: string|null, reason: string, resolves?: boolean },
285
511
  * reason: string,
286
512
  * finding: string|null,
287
513
  * }}
288
514
  */
289
- export function detectIssueRefinementArtifact({ body = "", issueNumber = null } = {}) {
515
+ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, resolveLinkedDoc = null } = {}) {
290
516
  if (typeof body !== "string" || body.length === 0) {
291
517
  return {
292
518
  hasACs: false,
519
+ hasNonGoals: false,
293
520
  source: REFINEMENT_SOURCE.MISSING,
294
521
  acItems: [],
295
522
  uncheckedAcItems: [],
@@ -307,6 +534,18 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
307
534
  const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
308
535
  const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
309
536
 
537
+ // CONSUMER-CONTRACT BOUNDARY (#1877, intentional asymmetry): the issue-side
538
+ // reads above are strict — ONE exact-first section, NO deep flattening —
539
+ // while extractPrBodyUncheckedChecklistItems (PR side) unions ALL matching
540
+ // sections and deep-flattens past ### sub-headings. The issue side is a
541
+ // presence check of the refinement matrix: a checklist hidden entirely
542
+ // under a ### sub-heading fails CLOSED (reported missing, the issue stays
543
+ // parked for human refinement). The PR side enforces a hard gate over the
544
+ // derived checklist: it must NEVER miss an unchecked box, so it fails open
545
+ // on nothing — it unions and deep-flattens. Do not "unify" these reads: the
546
+ // two failure directions are both deliberate (issue side = safe direction,
547
+ // PR side = fail-closed gate).
548
+
310
549
  const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
311
550
  // Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
312
551
  // ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
@@ -315,58 +554,113 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null }
315
554
  const uncheckedAcItems = acceptanceSection ? extractUncheckedChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
316
555
  const dodItems = dodSection ? extractChecklistItems(dodSection.bodyLines.join("\n")) : [];
317
556
 
318
- const linkedDoc = detectLinkedRefinementDoc(body);
319
-
320
- if (acItems.length > 0) {
321
- return {
322
- hasACs: true,
323
- source: REFINEMENT_SOURCE.ISSUE_BODY_AC,
324
- acItems,
325
- uncheckedAcItems,
326
- dodItems,
327
- sections: sectionNames,
328
- linkedDoc,
329
- reason: `Found ${acItems.length} Acceptance criteria checklist item(s) in the issue body.`,
330
- finding: null,
331
- };
557
+ let linkedDoc = detectLinkedRefinementDoc(body);
558
+ let linkedDocResolves = linkedDoc.found;
559
+ if (linkedDoc.found && typeof resolveLinkedDoc === "function") {
560
+ linkedDocResolves = resolveLinkedDoc(linkedDoc.path) === true;
561
+ linkedDoc = { ...linkedDoc, resolves: linkedDocResolves };
332
562
  }
333
563
 
334
- if (dodItems.length > 0) {
335
- return {
336
- hasACs: true,
337
- source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
338
- acItems,
339
- uncheckedAcItems,
340
- dodItems,
341
- sections: sectionNames,
342
- linkedDoc,
343
- reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
344
- finding: null,
345
- };
346
- }
564
+ // #1866: explicit Non-goals section required on a refined tracker-backed
565
+ // issue body — same matcher the PR-body spec path uses, so the two cannot
566
+ // drift. A heading-only or fenced-only section does not count
567
+ // (sectionHasBody anti-spoof).
568
+ const hasNonGoals = sectionHasBody(
569
+ findSectionByPatterns(sections, PR_BODY_SPEC_NARRATIVE_SECTIONS.non_goals.patterns),
570
+ );
571
+
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;
579
+
580
+ const base = {
581
+ hasNonGoals,
582
+ acItems,
583
+ uncheckedAcItems,
584
+ dodItems,
585
+ sections: sectionNames,
586
+ linkedDoc,
587
+ };
347
588
 
348
- if (linkedDoc.found) {
589
+ if (artifactSource !== null) {
590
+ if (!hasNonGoals) {
591
+ return {
592
+ ...base,
593
+ hasACs: false,
594
+ source: artifactSource,
595
+ reason:
596
+ `Issue body carries a refinement artifact (${artifactSource}) but no explicit Non-goals section; ` +
597
+ "the tracker-backed refinement contract requires one (rule ARTIFACT-TRACKER-ISSUE-REFINEMENT-FLOOR; " +
598
+ "e.g. run the loop-grill synthesis). Refusing: the refinement check fails closed without an explicit Non-goals section.",
599
+ finding: MISSING_EXPLICIT_NON_GOALS_FINDING,
600
+ };
601
+ }
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.
633
+ return {
634
+ ...base,
635
+ hasACs: false,
636
+ source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
637
+ 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,
643
+ };
644
+ }
349
645
  return {
646
+ ...base,
350
647
  hasACs: true,
351
648
  source: REFINEMENT_SOURCE.LINKED_DOC,
352
649
  acItems: [],
353
650
  uncheckedAcItems: [],
354
651
  dodItems: [],
355
- sections: sectionNames,
356
- linkedDoc,
357
652
  reason: `Issue body links a refinement doc at ${linkedDoc.path}; treating that as the refinement artifact source.`,
358
653
  finding: null,
359
654
  };
360
655
  }
361
656
 
362
657
  return {
658
+ ...base,
363
659
  hasACs: false,
364
660
  source: REFINEMENT_SOURCE.MISSING,
365
661
  acItems: [],
366
662
  uncheckedAcItems: [],
367
663
  dodItems: [],
368
- sections: sectionNames,
369
- linkedDoc,
370
664
  reason: "Issue body has no Acceptance criteria section, no DoD section, and no linked refinement doc.",
371
665
  finding: REFINEMENT_ARTIFACT_FINDING,
372
666
  };
@@ -484,7 +778,16 @@ function sectionHasBody(section) {
484
778
  * pick exactly one mode (tracker-backed, with or without a specific
485
779
  * expected issue) or issue-less — never both.
486
780
  *
487
- * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean }} input
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.
789
+ *
790
+ * @param {{ body?: string, expectedIssue?: number, issueLess?: boolean, requireOpenQuestions?: boolean }} input
488
791
  * @returns {{ checker: "validate-pr-body-spec", ok: boolean, errors: { code: string, message: string }[], sections: string[], acItems: string[], dodItems: string[], closesIssues: number[] }}
489
792
  */
490
793
 
@@ -540,7 +843,7 @@ export function detectGrillEmbedHeading(body = "") {
540
843
  return null;
541
844
  }
542
845
 
543
- export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false } = {}) {
846
+ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess = false, requireOpenQuestions = true } = {}) {
544
847
  if (issueLess && Number.isInteger(expectedIssue)) {
545
848
  // Fail closed at the library boundary too (not just the CLI): the two modes
546
849
  // are contradictory and silently preferring one would hide caller bugs.
@@ -550,7 +853,8 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
550
853
  const sections = parseMarkdownSections(bodyText);
551
854
  const errors = [];
552
855
 
553
- for (const { code, label, patterns } of Object.values(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
856
+ for (const [key, { code, label, patterns }] of Object.entries(PR_BODY_SPEC_NARRATIVE_SECTIONS)) {
857
+ if (key === "open_questions" && !requireOpenQuestions) continue;
554
858
  const section = findSectionByPatterns(sections, patterns);
555
859
  if (!sectionHasBody(section)) {
556
860
  errors.push({ code, message: `Missing or empty ${label} section.` });
@@ -606,6 +910,82 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
606
910
  };
607
911
  }
608
912
 
913
+ /**
914
+ * 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
917
+ * Non-goals section, and a `Closes #N`/`Fixes #N` reference — regardless of
918
+ * whether the linked issue itself already carries a refinement artifact. A
919
+ * linked issue with real ACs is necessary but not sufficient: the PR body is
920
+ * the portable spec-of-record a tracker-agnostic consumer reads.
921
+ *
922
+ * Thin wrapper over `validatePrBodySpec`, not a second divergent checker:
923
+ * `requireOpenQuestions: false` because the tracker-backed contract, unlike
924
+ * the lightweight PR-body-as-spec path, does not require an Open
925
+ * questions/risks section. `expectedIssue` is only checked when the PR closes
926
+ * exactly ONE issue — an umbrella PR closing several is not required to name
927
+ * any single one of them in the `expectedIssue` slot (each linked issue's
928
+ * refinement is verified separately by the caller).
929
+ *
930
+ * @param {{ body?: string, closingIssues?: number[] }} input
931
+ * @returns {ReturnType<typeof validatePrBodySpec>}
932
+ */
933
+ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = [] } = {}) {
934
+ const expectedIssue = Array.isArray(closingIssues) && closingIssues.length === 1 ? closingIssues[0] : null;
935
+ return validatePrBodySpec({ body, expectedIssue, requireOpenQuestions: false });
936
+ }
937
+
938
+ /**
939
+ * #1877: extract the UNCHECKED AC/DoD checkbox items from a PR body's own
940
+ * Acceptance criteria / Definition of done checklists — the derived,
941
+ * self-contained checklist that mirrors the linked issue's AC/DoD/Non-goals
942
+ * matrix. Any unchecked `- [ ]` in those sections means an acceptance
943
+ * criterion or definition-of-done item is still open, and the deterministic
944
+ * pre-approval block (`upsert-checkpoint-verdict.mjs`) fails the gate closed:
945
+ * the round is `blocked` and the PR cannot reach approval with an open
946
+ * acceptance criterion. This enforces COMPLETENESS (nothing left
947
+ * unchecked/forgotten), not truthfulness — a dishonestly-ticked `[x]` passes
948
+ * this mechanical check and remains the reviewer/judge's responsibility
949
+ * (ACCEPT-CRITERIA-VERIFY-AND-REFLECT). Composes with
950
+ * `tick-verified-checkboxes.mjs`: a box the gate could not verify stays
951
+ * unchecked and therefore blocks.
952
+ *
953
+ * Pure; no I/O. Reuses the shared section patterns and checklist parser
954
+ * (same `parseMarkdownSections` + `extractUncheckedChecklistItems` seams as
955
+ * `detectIssueRefinementArtifact` / `validatePrBodySpec`) so no parallel
956
+ * parser can drift. Sections absent from the body contribute no items — the
957
+ * draft-exit `validateTrackerBackedPrBodySpec` check (#1863) already owns
958
+ * requiring the sections to EXIST.
959
+ *
960
+ * @param {{ body?: string }} input
961
+ * @returns {{ uncheckedAcItems: string[], uncheckedDodItems: string[] }}
962
+ */
963
+ export function extractPrBodyUncheckedChecklistItems({ body = "" } = {}) {
964
+ if (typeof body !== "string" || body.length === 0) {
965
+ return { uncheckedAcItems: [], uncheckedDodItems: [] };
966
+ }
967
+ const sections = parseMarkdownSections(body);
968
+ // Union the unchecked boxes across ALL sections matching each pattern
969
+ // family (exact-first ordered), flattening each section past its deeper
970
+ // sub-headings (#1877): a body nesting ACs under `###` subsections, or
971
+ // repeating an AC/DoD heading, must not hide unchecked boxes from the
972
+ // deterministic completeness block. Deduped by text (same box re-read in a
973
+ // duplicate section is the same box).
974
+ const collect = (patterns) => {
975
+ const matched = findAllSectionsByPatterns(sections, patterns);
976
+ const items = [];
977
+ for (let i = 0; i < sections.length; i += 1) {
978
+ if (!matched.includes(sections[i])) continue;
979
+ items.push(...extractUncheckedChecklistItems(flattenSectionDeep(sections, i)));
980
+ }
981
+ return [...new Set(items)];
982
+ };
983
+ return {
984
+ uncheckedAcItems: collect(ACCEPTANCE_SECTION_PATTERNS),
985
+ uncheckedDodItems: collect(DOD_SECTION_PATTERNS),
986
+ };
987
+ }
988
+
609
989
  /**
610
990
  * Decide what an enqueue caller should do with a refinement-artifact result,
611
991
  * so an un-refined item never lands in the Next Up pickup column in the first
@@ -623,16 +1003,44 @@ export function validatePrBodySpec({ body = "", expectedIssue = null, issueLess
623
1003
  * @returns {{ action: "enqueue" } | { action: "block"|"divert", reason: string, missing: string[] }}
624
1004
  */
625
1005
  export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
626
- // `artifact.finding === null` is the explicit "has ANY refinement artifact"
627
- // signal (AC checklist OR DoD checklist OR linked doc) — clearer than reading
628
- // `hasACs`, whose name understates that a DoD or linked doc also satisfies it.
1006
+ // `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.
629
1010
  if (!targetIsPickup || artifact.finding === null) {
630
1011
  return { action: "enqueue" };
631
1012
  }
1013
+ // #1866: artifact present but the contract-mandated Non-goals section is
1014
+ // absent/empty — a distinct failure with its own guidance.
1015
+ if (artifact.finding === MISSING_EXPLICIT_NON_GOALS_FINDING) {
1016
+ const reason =
1017
+ "Issue carries a refinement artifact but no explicit Non-goals section. " +
1018
+ "Add an explicit `## Non-goals` section to the issue body " +
1019
+ "(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
+ return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
1021
+ }
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) {
1026
+ 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"] };
1031
+ }
1032
+ if (artifact.finding === MISSING_AC_CHECKLIST_FINDING) {
1033
+ 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"] };
1038
+ }
632
1039
  const missing = [...REFINEMENT_ARTIFACT_SOURCES];
633
1040
  const reason =
634
1041
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
635
- "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
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 — " +
1043
+ "or link a refinement doc (tmp/refinement/*.md), which is a complete artifact on its own " +
636
1044
  "(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.";
637
1045
  return { action: auto ? "divert" : "block", reason, missing };
638
1046
  }
@@ -649,7 +1057,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
649
1057
  * @param {{ issueNumber: number, repo: string, env: object, runChild: Function, auto?: boolean }} input
650
1058
  * @returns {Promise<{ action: "enqueue" } | { action: "divert"|"block", reason: string, missing: string[] }>}
651
1059
  */
652
- export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false }) {
1060
+ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild, auto = false, repoRoot = null }) {
653
1061
  const bodyResult = await runChild(
654
1062
  "gh",
655
1063
  ["issue", "view", String(issueNumber), "--repo", repo, "--json", "body"],
@@ -666,7 +1074,17 @@ export async function runPickupRefinementGate({ issueNumber, repo, env, runChild
666
1074
  throw new Error("Invalid JSON input");
667
1075
  }
668
1076
  const body = typeof bodyPayload?.body === "string" ? bodyPayload.body : "";
669
- const artifact = detectIssueRefinementArtifact({ body, issueNumber });
1077
+ // #1866: a linked refinement doc satisfies the gate only when it actually
1078
+ // resolves. Paths follow the `tmp/refinement/*.md` convention and are
1079
+ // anchored to the caller's repo root (`repoRoot` option, falling back to
1080
+ // process.cwd()) — never the ambient cwd of whichever subdirectory the
1081
+ // gate happened to run from.
1082
+ const docAnchor = repoRoot ?? process.cwd();
1083
+ const artifact = detectIssueRefinementArtifact({
1084
+ body,
1085
+ issueNumber,
1086
+ resolveLinkedDoc: (p) => existsSync(path.isAbsolute(p) ? p : path.resolve(docAnchor, p)),
1087
+ });
670
1088
  const decision = decideEnqueueRefinementGate({ artifact, targetIsPickup: true, auto });
671
1089
  if (decision.action === "block") {
672
1090
  throw Object.assign(new Error(decision.reason), {