@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.
- package/package.json +2 -1
- package/src/config/config.mjs +11 -97
- package/src/config/extension-defaults.yaml +11 -5
- package/src/loop/commit-msg-guard.mjs +168 -0
- package/src/loop/gate-fanin.mjs +13 -7
- package/src/loop/issue-refinement-artifact.mjs +495 -77
- package/src/loop/pr-gate-coordination.mjs +27 -2
- package/src/loop/public-dev-loop-routing.mjs +4 -0
- package/src/loop/queue-board-sync.mjs +6 -10
- package/src/loop/retrospective-checkpoint.mjs +59 -1
- package/src/projects/move-queue-item.mjs +1 -1
- package/src/projects/resolve-project.mjs +6 -6
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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:
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
|
|
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[
|
|
372
|
+
const text = (checkboxMatch[2] ?? "").trim();
|
|
190
373
|
if (text.length > 0) {
|
|
191
|
-
// `checked` is true only for a ticked
|
|
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:
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
if (
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 (
|
|
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
|
-
*
|
|
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.
|
|
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 "
|
|
627
|
-
// signal (
|
|
628
|
-
//
|
|
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
|
-
"
|
|
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
|
-
|
|
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), {
|