@dev-loops/core 1.0.0 → 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 +1 -1
- package/src/config/config.mjs +11 -97
- package/src/config/extension-defaults.yaml +11 -5
- package/src/loop/gate-fanin.mjs +13 -7
- package/src/loop/issue-refinement-artifact.mjs +316 -42
- package/src/loop/pr-gate-coordination.mjs +27 -2
- package/src/loop/queue-board-sync.mjs +6 -10
- package/src/projects/resolve-project.mjs +6 -6
package/package.json
CHANGED
package/src/config/config.mjs
CHANGED
|
@@ -563,17 +563,11 @@ function boardRefConfig(ownerKey) {
|
|
|
563
563
|
});
|
|
564
564
|
}
|
|
565
565
|
|
|
566
|
-
const QueueBoardConfig = boardRefConfig("queue.board");
|
|
567
|
-
|
|
568
566
|
/** Queue mode config */
|
|
569
567
|
const QueueConfig = z.strictObject({
|
|
570
568
|
maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
|
|
571
569
|
maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
|
|
572
570
|
reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
|
|
573
|
-
// Deprecated: superseded by `tracker.board` (issue #1408, the tracker-agnostic
|
|
574
|
-
// seam). Kept accepted for back-compat — see resolveTrackerBoard, which reads
|
|
575
|
-
// `tracker.board` first and falls back to this field with a load-time warning.
|
|
576
|
-
board: QueueBoardConfig.describe("Deprecated: use tracker.board instead. GitHub Projects board identifier.").optional(),
|
|
577
571
|
archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
|
|
578
572
|
});
|
|
579
573
|
|
|
@@ -583,7 +577,7 @@ const QueueConfig = z.strictObject({
|
|
|
583
577
|
* at `resolveTrackerAdapter` call time, not at config-parse time — the
|
|
584
578
|
* seam/resolver must not preclude a consumer registering an external
|
|
585
579
|
* provider post-1.0 (`plugin`, reserved, not implemented in this pass).
|
|
586
|
-
* `board`
|
|
580
|
+
* `board` is the canonical GitHub Projects board identifier (see resolveTrackerBoard).
|
|
587
581
|
*
|
|
588
582
|
* No generic `fieldMappings` (logical-column -> provider-status) key here:
|
|
589
583
|
* the github provider's logical-column -> Status mapping IS the existing,
|
|
@@ -598,7 +592,7 @@ const QueueConfig = z.strictObject({
|
|
|
598
592
|
const TrackerConfig = z.strictObject({
|
|
599
593
|
provider: z.string().trim().min(1).describe("Tracker provider registry key. Built-in: \"github\" (default).").optional(),
|
|
600
594
|
plugin: z.string().trim().min(1).describe("Reserved: module specifier for an external tracker provider plugin (post-1.0, not implemented in this pass).").optional(),
|
|
601
|
-
board: boardRefConfig("tracker.board").describe("Tracker board identifier
|
|
595
|
+
board: boardRefConfig("tracker.board").describe("Tracker board identifier.").optional(),
|
|
602
596
|
});
|
|
603
597
|
|
|
604
598
|
/**
|
|
@@ -940,13 +934,11 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
940
934
|
maxParallel: 3,
|
|
941
935
|
maxAutoFiledIssues: 10,
|
|
942
936
|
reDispatchMaxRetries: 1,
|
|
943
|
-
// queue.board is intentionally absent from defaults — setting it is an
|
|
944
|
-
// explicit operator opt-in for Projects-based queue ordering.
|
|
945
937
|
}),
|
|
946
938
|
tracker: Object.freeze({
|
|
947
939
|
provider: "github",
|
|
948
940
|
// tracker.board is intentionally absent from defaults — setting it is an
|
|
949
|
-
// explicit operator opt-in
|
|
941
|
+
// explicit operator opt-in for Projects-based queue ordering. The logical-column ->
|
|
950
942
|
// Status mapping is queue.statusColumns (see TrackerConfig above), not a
|
|
951
943
|
// tracker-owned default.
|
|
952
944
|
}),
|
|
@@ -1285,7 +1277,7 @@ export function resolveRoleModel(config, { role, harness, kind } = {}) {
|
|
|
1285
1277
|
* @typedef {object} ConfigLoadError
|
|
1286
1278
|
* @property {string} path - Human-readable file path or layer name
|
|
1287
1279
|
* @property {string} message - Error description
|
|
1288
|
-
* @property {"
|
|
1280
|
+
* @property {"extensionDefaults"|"defaults"|"devloops"|"merged"} layer - Which config layer failed
|
|
1289
1281
|
*/
|
|
1290
1282
|
|
|
1291
1283
|
// ============================================================================
|
|
@@ -1499,10 +1491,11 @@ function configError(message, code, filePath) {
|
|
|
1499
1491
|
}
|
|
1500
1492
|
|
|
1501
1493
|
/**
|
|
1502
|
-
* Try to load and merge one config layer (defaults or
|
|
1494
|
+
* Try to load and merge one config layer (extensionDefaults, defaults, or
|
|
1495
|
+
* devloops).
|
|
1503
1496
|
* @param {Record<string, unknown>} merged - Current merged config
|
|
1504
1497
|
* @param {string|string[]} basePaths - Config file base path(s) without extension
|
|
1505
|
-
* @param {"defaults"|"
|
|
1498
|
+
* @param {"extensionDefaults"|"defaults"|"devloops"} layer - Layer name
|
|
1506
1499
|
* @param {string[]} warnings
|
|
1507
1500
|
* @param {ConfigLoadError[]} errors
|
|
1508
1501
|
* @param {{ warnOnMissing?: boolean }} [options]
|
|
@@ -1628,7 +1621,7 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
|
|
|
1628
1621
|
|
|
1629
1622
|
/**
|
|
1630
1623
|
* Load the dev-loop configuration with full precedence:
|
|
1631
|
-
*
|
|
1624
|
+
* repo .devloops > repo .pi/dev-loop/defaults.(yaml|yml|json) > extension defaults > built-in defaults
|
|
1632
1625
|
*
|
|
1633
1626
|
* Never throws for config-related problems.
|
|
1634
1627
|
* Returns extension defaults (with built-in defaults as the final fallback) even when all repo-local config files are missing or broken.
|
|
@@ -1641,7 +1634,6 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1641
1634
|
const configDir = path.join(repoRoot, ".pi", "dev-loop");
|
|
1642
1635
|
const defaultsPath = path.join(configDir, "defaults");
|
|
1643
1636
|
const devloopsPath = path.join(repoRoot, ".devloops");
|
|
1644
|
-
const settingsPaths = [path.join(configDir, "settings"), path.join(configDir, "overrides")];
|
|
1645
1637
|
|
|
1646
1638
|
/** @type {string[]} */
|
|
1647
1639
|
const warnings = [];
|
|
@@ -1678,79 +1670,7 @@ export async function loadDevLoopConfig(options = {}) {
|
|
|
1678
1670
|
|
|
1679
1671
|
if (primaryExists) {
|
|
1680
1672
|
// .devloops is the primary override — apply it
|
|
1681
|
-
merged = await applyLayer(merged, devloopsPath, "
|
|
1682
|
-
|
|
1683
|
-
// Warn if legacy files still exist alongside .devloops (but don't load them —
|
|
1684
|
-
// .devloops is authoritative; legacy must not override it)
|
|
1685
|
-
let legacyAlongside = false;
|
|
1686
|
-
for (const legacyPath of settingsPaths) {
|
|
1687
|
-
for (const ext of [".yaml", ".yml", ".json"]) {
|
|
1688
|
-
try {
|
|
1689
|
-
await readFile(legacyPath + ext, "utf8");
|
|
1690
|
-
legacyAlongside = true;
|
|
1691
|
-
break;
|
|
1692
|
-
} catch (err) {
|
|
1693
|
-
if (err?.code !== "ENOENT") {
|
|
1694
|
-
// File exists but is unreadable — treat as "found" so the
|
|
1695
|
-
// deprecation warning fires (applyLayer is not called for legacy
|
|
1696
|
-
// paths when .devloops is present, so the flag only controls the warning).
|
|
1697
|
-
legacyAlongside = true;
|
|
1698
|
-
break;
|
|
1699
|
-
}
|
|
1700
|
-
}
|
|
1701
|
-
}
|
|
1702
|
-
if (legacyAlongside) break;
|
|
1703
|
-
}
|
|
1704
|
-
if (legacyAlongside) {
|
|
1705
|
-
warnings.push(
|
|
1706
|
-
`Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
|
|
1707
|
-
`Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
|
|
1708
|
-
`Legacy paths will be removed in a future version.`
|
|
1709
|
-
);
|
|
1710
|
-
}
|
|
1711
|
-
} else {
|
|
1712
|
-
// No .devloops — fall back to legacy .pi/dev-loop/settings.* or overrides.* (deprecated)
|
|
1713
|
-
let legacyFound = false;
|
|
1714
|
-
for (const legacyPath of settingsPaths) {
|
|
1715
|
-
for (const ext of [".yaml", ".yml", ".json"]) {
|
|
1716
|
-
try {
|
|
1717
|
-
await readFile(legacyPath + ext, "utf8");
|
|
1718
|
-
legacyFound = true;
|
|
1719
|
-
break;
|
|
1720
|
-
} catch (err) {
|
|
1721
|
-
if (err?.code !== "ENOENT") {
|
|
1722
|
-
// File exists but is unreadable — treat as "found" so the
|
|
1723
|
-
// deprecation warning fires and applyLayer can surface the error
|
|
1724
|
-
// (legacy applyLayer runs in this branch).
|
|
1725
|
-
legacyFound = true;
|
|
1726
|
-
break;
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1729
|
-
}
|
|
1730
|
-
if (legacyFound) break;
|
|
1731
|
-
}
|
|
1732
|
-
if (legacyFound) {
|
|
1733
|
-
warnings.push(
|
|
1734
|
-
`Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
|
|
1735
|
-
`Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
|
|
1736
|
-
`Legacy paths will be removed in a future version.`
|
|
1737
|
-
);
|
|
1738
|
-
merged = await applyLayer(merged, settingsPaths, "settings", warnings, errors);
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
// Deprecated `queue.board` -> `tracker.board` alias (issue #1408, the
|
|
1743
|
-
// tracker-agnostic seam). Runs on the fully-merged object (unlike the
|
|
1744
|
-
// `strategy: "github-first"` alias above, this only affects cross-layer
|
|
1745
|
-
// MERGE PRECEDENCE, not per-layer schema validity — queue.board is still a
|
|
1746
|
-
// valid FileConfigSchema shape on its own — so normalizing once here, after
|
|
1747
|
-
// every layer has merged, is sufficient).
|
|
1748
|
-
if (isPlainObject(merged.queue?.board) && !isPlainObject(merged.tracker?.board)) {
|
|
1749
|
-
warnings.push(
|
|
1750
|
-
`queue.board is a deprecated alias for tracker.board (issue #1408). ` +
|
|
1751
|
-
`Update .devloops to set tracker.board instead; the alias will be removed in a future version.`
|
|
1752
|
-
);
|
|
1753
|
-
merged = { ...merged, tracker: { ...(merged.tracker ?? {}), board: merged.queue.board } };
|
|
1673
|
+
merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
|
|
1754
1674
|
}
|
|
1755
1675
|
|
|
1756
1676
|
// Validate final merged config
|
|
@@ -3085,19 +3005,13 @@ export function resolveTrackerProvider(config) {
|
|
|
3085
3005
|
}
|
|
3086
3006
|
|
|
3087
3007
|
/**
|
|
3088
|
-
* Resolve the effective tracker board identifier. `tracker.board` is
|
|
3089
|
-
* canonical
|
|
3090
|
-
* `tracker.board` by `loadDevLoopConfig` (with a load-time warning) for any
|
|
3091
|
-
* config that went through the loader. This resolver also accepts a
|
|
3092
|
-
* hand-built config object that sets `queue.board` directly (bypassing the
|
|
3093
|
-
* loader, e.g. in a test) and falls back to it — with no warning, since only
|
|
3094
|
-
* the loader surfaces warnings.
|
|
3008
|
+
* Resolve the effective tracker board identifier. `tracker.board` is the
|
|
3009
|
+
* canonical (and only) board config key.
|
|
3095
3010
|
*
|
|
3096
3011
|
* @param {DevLoopConfig} config
|
|
3097
3012
|
* @returns {{ number?: number, title?: string } | null}
|
|
3098
3013
|
*/
|
|
3099
3014
|
export function resolveTrackerBoard(config) {
|
|
3100
3015
|
if (isPlainObject(config?.tracker?.board)) return config.tracker.board;
|
|
3101
|
-
if (isPlainObject(config?.queue?.board)) return config.queue.board;
|
|
3102
3016
|
return null;
|
|
3103
3017
|
}
|
|
@@ -65,7 +65,7 @@ gates:
|
|
|
65
65
|
- name: config-drift
|
|
66
66
|
persona: review
|
|
67
67
|
prompt: |-
|
|
68
|
-
Cross-check config, schema, and documentation for contract drift: - Verify that configuration files (.
|
|
68
|
+
Cross-check config, schema, and documentation for contract drift: - Verify that configuration files (.devloops,
|
|
69
69
|
package.json, CI workflows, skill manifests) agree on canonical
|
|
70
70
|
status tokens, support floors, and required flags.
|
|
71
71
|
- Flag any instance where two sources of truth disagree about the
|
|
@@ -263,9 +263,15 @@ gates:
|
|
|
263
263
|
mandatory: true
|
|
264
264
|
persona: review
|
|
265
265
|
prompt: |-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
-
|
|
266
|
+
Completeness is enforced deterministically (#1877): the pre_approval_gate fails closed on any
|
|
267
|
+
unchecked `- [ ]` in the PR body's AC/DoD checklist, so this angle's completeness duty is
|
|
268
|
+
machine-backed. Your remaining duty is TRUTHFULNESS, which the machine cannot check: verify
|
|
269
|
+
that every checked `- [x]` box in the PR body's Acceptance criteria / Definition of done
|
|
270
|
+
checklists is actually satisfied by the implementation — cite concrete code/test/behavior
|
|
271
|
+
evidence; flag a dishonestly-ticked box as a blocking finding. Also verify the checked
|
|
272
|
+
content mirrors the linked issue's AC/DoD/Non-goals matrix and that declared non-goals are
|
|
273
|
+
respected (no scope creep). The boundary is explicit: the deterministic block enforces
|
|
274
|
+
completeness (nothing left unchecked/forgotten); you verify each [x] is real.
|
|
269
275
|
- contradiction-lens
|
|
270
276
|
- correctness-final
|
|
271
277
|
- ui-validation
|
|
@@ -324,7 +330,7 @@ localImplementation:
|
|
|
324
330
|
maxFiles: 2
|
|
325
331
|
maxLines: 100
|
|
326
332
|
|
|
327
|
-
# Queue defaults (repo-specific
|
|
333
|
+
# Queue defaults (repo-specific tracker.board omitted by design).
|
|
328
334
|
queue:
|
|
329
335
|
maxParallel: 3
|
|
330
336
|
# Local-first is PR-first (issues are skipped, #952), so auto-filing issues is
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -63,13 +63,19 @@ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Adaptive
|
|
67
|
-
* escalating to foreground one-at-a-time fallback.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
66
|
+
* Adaptive concurrency backoff (issue #1601; retry discipline refined by #1907):
|
|
67
|
+
* halve the active batch before escalating to foreground one-at-a-time fallback.
|
|
68
|
+
* A transient dispatch failure (429/5xx) is first retried on the SAME unit with
|
|
69
|
+
* exponential backoff — safe because a reviewer's findings artifact is an
|
|
70
|
+
* idempotent single-write at a deterministic path — and the conductor reduces
|
|
71
|
+
* concurrency ONLY after that unit's retries are exhausted (~3 failed attempts),
|
|
72
|
+
* recomputing the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
|
|
73
|
+
* retrying the reduced wave; if a single-unit wave still fails, it falls back to
|
|
74
|
+
* foreground (one-at-a-time) dispatch. This "retry the unit before reducing
|
|
75
|
+
* concurrency" ordering is owned by GATE-EXEC-DISPATCH-RETRY-BACKOFF in
|
|
76
|
+
* skills/docs/gate-review-sub-loop-contract.md; the backoff is recorded in the
|
|
77
|
+
* round's provenance. Pure; never returns 0 (a backoff from 1 stays 1 →
|
|
78
|
+
* foreground fallback owns that path).
|
|
73
79
|
* @param {number} maxConcurrent
|
|
74
80
|
* @returns {number}
|
|
75
81
|
*/
|
|
@@ -3,14 +3,15 @@
|
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
14
15
|
*/
|
|
15
16
|
import { existsSync } from "node:fs";
|
|
16
17
|
import path from "node:path";
|
|
@@ -36,9 +37,10 @@ export const REFINEMENT_SOURCE = Object.freeze({
|
|
|
36
37
|
|
|
37
38
|
const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
|
|
38
39
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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.
|
|
42
44
|
export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
|
|
43
45
|
"Acceptance criteria section",
|
|
44
46
|
"Definition of done section",
|
|
@@ -54,6 +56,24 @@ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
|
|
|
54
56
|
*/
|
|
55
57
|
export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
|
|
56
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
|
+
|
|
57
77
|
/**
|
|
58
78
|
* Canonical list of section headings that satisfy the refinement check.
|
|
59
79
|
* Matching is case-insensitive and tolerates trailing/leading whitespace.
|
|
@@ -62,16 +82,152 @@ export const MISSING_EXPLICIT_NON_GOALS_FINDING = "missing_explicit_non_goals";
|
|
|
62
82
|
* - one DoD-style section (DoD or Definition of Done)
|
|
63
83
|
*/
|
|
64
84
|
const ACCEPTANCE_SECTION_PATTERNS = Object.freeze([
|
|
65
|
-
|
|
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,
|
|
66
94
|
/^ac\b.*$/i,
|
|
67
95
|
]);
|
|
68
96
|
|
|
69
97
|
const DOD_SECTION_PATTERNS = Object.freeze([
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|
|
73
106
|
]);
|
|
74
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
|
+
|
|
75
231
|
/**
|
|
76
232
|
* Fenced-code-span tracker. Given the previous fence state and the current
|
|
77
233
|
* line, returns { fence, insideFence } where:
|
|
@@ -135,7 +291,12 @@ export function parseMarkdownSections(body) {
|
|
|
135
291
|
}
|
|
136
292
|
current = {
|
|
137
293
|
level: match[1].length,
|
|
138
|
-
|
|
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]),
|
|
139
300
|
bodyLines: [],
|
|
140
301
|
};
|
|
141
302
|
continue;
|
|
@@ -152,22 +313,16 @@ export function parseMarkdownSections(body) {
|
|
|
152
313
|
return sections;
|
|
153
314
|
}
|
|
154
315
|
|
|
155
|
-
function findSectionByPatterns(sections, patterns) {
|
|
156
|
-
for (const section of sections) {
|
|
157
|
-
for (const pattern of patterns) {
|
|
158
|
-
if (pattern.test(section.name)) {
|
|
159
|
-
return section;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
316
|
|
|
166
317
|
/**
|
|
167
318
|
* Parse bullet/checkbox items from a section body into item states. Each
|
|
168
|
-
* checkbox item
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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)
|
|
171
326
|
* becomes `{ text, checked: null }` — it has no checkbox to tick. Empty
|
|
172
327
|
* checkbox placeholders (`- [ ]` / `- [x]` with no trailing text) are skipped,
|
|
173
328
|
* not counted, so a section of only unfilled placeholders reports as unrefined.
|
|
@@ -197,17 +352,30 @@ function parseChecklistItems(sectionBody) {
|
|
|
197
352
|
if (step.insideFence) {
|
|
198
353
|
continue;
|
|
199
354
|
}
|
|
200
|
-
// Checklist item:
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
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);
|
|
204
371
|
if (checkboxMatch) {
|
|
205
|
-
const text = (checkboxMatch[
|
|
372
|
+
const text = (checkboxMatch[2] ?? "").trim();
|
|
206
373
|
if (text.length > 0) {
|
|
207
|
-
// `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.
|
|
208
376
|
// A plain bullet has no checkbox, so it stays `null` below — it is
|
|
209
377
|
// neither ticked nor unticked and does not count as an unticked AC.
|
|
210
|
-
items.push({ text, checked:
|
|
378
|
+
items.push({ text, checked: checkboxMatch[1] !== " " });
|
|
211
379
|
}
|
|
212
380
|
continue;
|
|
213
381
|
}
|
|
@@ -366,6 +534,18 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
366
534
|
const acceptanceSection = findSectionByPatterns(sections, ACCEPTANCE_SECTION_PATTERNS);
|
|
367
535
|
const dodSection = findSectionByPatterns(sections, DOD_SECTION_PATTERNS);
|
|
368
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
|
+
|
|
369
549
|
const acItems = acceptanceSection ? extractChecklistItems(acceptanceSection.bodyLines.join("\n")) : [];
|
|
370
550
|
// Unticked AC checkboxes (`- [ ]`) of the spec-of-record — the
|
|
371
551
|
// ACCEPT-CRITERIA-VERIFY-AND-REFLECT precondition a clean pre_approval_gate
|
|
@@ -420,6 +600,25 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
420
600
|
};
|
|
421
601
|
}
|
|
422
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
|
+
}
|
|
423
622
|
return {
|
|
424
623
|
...base,
|
|
425
624
|
hasACs: true,
|
|
@@ -429,12 +628,18 @@ export function detectIssueRefinementArtifact({ body = "", issueNumber = null, r
|
|
|
429
628
|
};
|
|
430
629
|
}
|
|
431
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.
|
|
432
633
|
return {
|
|
433
634
|
...base,
|
|
434
|
-
hasACs:
|
|
635
|
+
hasACs: false,
|
|
435
636
|
source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
|
|
436
|
-
reason:
|
|
437
|
-
|
|
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,
|
|
438
643
|
};
|
|
439
644
|
}
|
|
440
645
|
return {
|
|
@@ -730,6 +935,57 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
|
|
|
730
935
|
return validatePrBodySpec({ body, expectedIssue, requireOpenQuestions: false });
|
|
731
936
|
}
|
|
732
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
|
+
|
|
733
989
|
/**
|
|
734
990
|
* Decide what an enqueue caller should do with a refinement-artifact result,
|
|
735
991
|
* so an un-refined item never lands in the Next Up pickup column in the first
|
|
@@ -749,8 +1005,8 @@ export function validateTrackerBackedPrBodySpec({ body = "", closingIssues = []
|
|
|
749
1005
|
export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = false }) {
|
|
750
1006
|
// `artifact.finding === null` is the explicit "passes the full refinement
|
|
751
1007
|
// check" signal (artifact AND — since #1866 — an explicit Non-goals
|
|
752
|
-
// section
|
|
753
|
-
// covers.
|
|
1008
|
+
// section AND — since #1877 — the full AC/DoD checklist matrix), clearer
|
|
1009
|
+
// than reading `hasACs`, whose name understates what it covers.
|
|
754
1010
|
if (!targetIsPickup || artifact.finding === null) {
|
|
755
1011
|
return { action: "enqueue" };
|
|
756
1012
|
}
|
|
@@ -763,10 +1019,28 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
|
|
|
763
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.";
|
|
764
1020
|
return { action: auto ? "divert" : "block", reason, missing: ["explicit Non-goals section"] };
|
|
765
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
|
+
}
|
|
766
1039
|
const missing = [...REFINEMENT_ARTIFACT_SOURCES];
|
|
767
1040
|
const reason =
|
|
768
1041
|
`Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
|
|
769
|
-
"
|
|
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 " +
|
|
770
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.";
|
|
771
1045
|
return { action: auto ? "divert" : "block", reason, missing };
|
|
772
1046
|
}
|
|
@@ -3,6 +3,11 @@ import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
|
3
3
|
import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
|
|
4
4
|
import { evaluateUiDesignerReviewScoping } from "./ui-designer-review-scoping.mjs";
|
|
5
5
|
import { trimmedOrNull } from "./normalize.mjs";
|
|
6
|
+
import {
|
|
7
|
+
MISSING_AC_CHECKLIST_FINDING,
|
|
8
|
+
MISSING_DOD_CHECKLIST_FINDING,
|
|
9
|
+
MISSING_EXPLICIT_NON_GOALS_FINDING,
|
|
10
|
+
} from "./issue-refinement-artifact.mjs";
|
|
6
11
|
|
|
7
12
|
export const PR_CHECKPOINT = Object.freeze({
|
|
8
13
|
DRAFT_REVIEW: "draft_review",
|
|
@@ -241,15 +246,35 @@ function normalizeRefinementArtifactStatus(value) {
|
|
|
241
246
|
// their own validation-failure reason from the detector; that reason must
|
|
242
247
|
// replace the "linked issue" wording, which does not apply when the PR is the
|
|
243
248
|
// spec-of-record and no linked issue was ever expected.
|
|
249
|
+
// #1877 full-matrix vocabulary for the draft-gate blocked reason: which
|
|
250
|
+
// matrix arm is missing per finding — the SAME finding taxonomy the enqueue
|
|
251
|
+
// gate's guidance (decideEnqueueRefinementGate) and the detector
|
|
252
|
+
// (detectIssueRefinementArtifact) use, so the draft gate (the unconditional
|
|
253
|
+
// backstop for that floor) cannot drift from it. An AC-only or DoD-only
|
|
254
|
+
// linked issue is a matrix miss, not "no artifact" — the guidance must name
|
|
255
|
+
// the actually-missing arm so the fix is not misdirected.
|
|
256
|
+
const REFINEMENT_MISSING_ARM_BY_FINDING = Object.freeze({
|
|
257
|
+
[MISSING_DOD_CHECKLIST_FINDING]: "a Definition of done checklist (mapped to the acceptance criteria)",
|
|
258
|
+
[MISSING_AC_CHECKLIST_FINDING]: "an Acceptance criteria checklist (for the DoD items to map to)",
|
|
259
|
+
[MISSING_EXPLICIT_NON_GOALS_FINDING]: "an explicit Non-goals section",
|
|
260
|
+
});
|
|
261
|
+
|
|
244
262
|
function formatRefinementBlockedReason(linkedIssue, status, refinementArtifact) {
|
|
245
263
|
const specSource = refinementArtifact?.specSource;
|
|
246
264
|
if (specSource != null && specSource !== REFINEMENT_ARTIFACT_SPEC_SOURCE.LINKED_ISSUE && typeof refinementArtifact?.reason === "string" && refinementArtifact.reason.length > 0) {
|
|
247
265
|
return `The draft gate cannot complete: ${refinementArtifact.reason} finding=${REFINEMENT_ARTIFACT_FINDING}`;
|
|
248
266
|
}
|
|
267
|
+
const finding = typeof refinementArtifact?.finding === "string" && refinementArtifact.finding.length > 0
|
|
268
|
+
? refinementArtifact.finding
|
|
269
|
+
: REFINEMENT_ARTIFACT_FINDING;
|
|
270
|
+
const missingArm = REFINEMENT_MISSING_ARM_BY_FINDING[finding];
|
|
271
|
+
if (missingArm !== undefined) {
|
|
272
|
+
return `Linked issue #${linkedIssue} has an incomplete refinement matrix — it is missing ${missingArm}. Add it to the issue body to complete the full AC/DoD/Non-goals matrix, then re-open the draft PR. finding=${finding}`;
|
|
273
|
+
}
|
|
249
274
|
if (linkedIssue !== null && Number.isInteger(linkedIssue)) {
|
|
250
|
-
return `Linked issue #${linkedIssue} has no refinement artifact (Acceptance criteria
|
|
275
|
+
return `Linked issue #${linkedIssue} has no refinement artifact (no Acceptance criteria checklist, DoD checklist, or resolvable linked refinement doc). Refine the issue to the full AC/DoD/Non-goals matrix — or link a refinement doc (tmp/refinement/*.md), a complete artifact on its own — then re-open the draft PR. finding=${REFINEMENT_ARTIFACT_FINDING}`;
|
|
251
276
|
}
|
|
252
|
-
return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (Acceptance criteria
|
|
277
|
+
return `The draft gate cannot complete: the linked issue has no detectable refinement artifact (no Acceptance criteria checklist, DoD checklist, or resolvable linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
|
|
253
278
|
}
|
|
254
279
|
|
|
255
280
|
// #1472: describes the CI state a round-cap-reached fallback branch actually
|
|
@@ -172,9 +172,8 @@ function readDevloopsSettings(repoRoot) {
|
|
|
172
172
|
try {
|
|
173
173
|
const raw = readFileSync(base + ext, "utf8");
|
|
174
174
|
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
175
|
-
// `tracker` (issue #1408, the tracker-agnostic seam) is surfaced
|
|
176
|
-
//
|
|
177
|
-
// the deprecated queue.board without a second file read.
|
|
175
|
+
// `tracker` (issue #1408, the tracker-agnostic seam) is surfaced so
|
|
176
|
+
// loadBoardConfig can read tracker.board directly.
|
|
178
177
|
return { settings: settings?.queue ?? null, tracker: settings?.tracker ?? null };
|
|
179
178
|
} catch (err) {
|
|
180
179
|
if (err?.code === "ENOENT") {
|
|
@@ -204,18 +203,15 @@ function boardSelector(board) {
|
|
|
204
203
|
}
|
|
205
204
|
|
|
206
205
|
export function loadBoardConfig(repoRoot) {
|
|
207
|
-
const {
|
|
206
|
+
const { tracker, error } = readDevloopsSettings(repoRoot);
|
|
208
207
|
if (error) {
|
|
209
208
|
return { enabled: false, reason: `config read/parse error: ${error}` };
|
|
210
209
|
}
|
|
211
|
-
// tracker.board (canonical
|
|
212
|
-
//
|
|
213
|
-
//
|
|
210
|
+
// tracker.board (canonical board key, issue #1408) — see resolveTrackerBoard
|
|
211
|
+
// in ../config/config.mjs for the equivalent resolution against the
|
|
212
|
+
// validated, loaded config.
|
|
214
213
|
const trackerBoard = boardSelector(tracker?.board);
|
|
215
214
|
if (trackerBoard) return trackerBoard;
|
|
216
|
-
if (!queue) return { enabled: false };
|
|
217
|
-
const queueBoard = boardSelector(queue.board);
|
|
218
|
-
if (queueBoard) return queueBoard;
|
|
219
215
|
return { enabled: false };
|
|
220
216
|
}
|
|
221
217
|
|
|
@@ -6,9 +6,9 @@ import { parse as parseYaml } from "yaml";
|
|
|
6
6
|
// resolution used by ensure-queue-board.mjs. Returns { project }, { title },
|
|
7
7
|
// and/or { olderThanDays } when configured; never throws on a missing/bad file.
|
|
8
8
|
//
|
|
9
|
-
// `tracker.board` (issue #1408, the tracker-agnostic
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// The board resolves from `tracker.board` (issue #1408, the tracker-agnostic
|
|
10
|
+
// seam) — same source as loadBoardConfig in ../loop/queue-board-sync.mjs and
|
|
11
|
+
// resolveTrackerBoard in ../config/config.mjs.
|
|
12
12
|
function resolveSettings(cwd) {
|
|
13
13
|
const basePath = path.join(cwd, ".devloops");
|
|
14
14
|
const extensions = ["", ".yaml", ".yml", ".json"];
|
|
@@ -18,7 +18,7 @@ function resolveSettings(cwd) {
|
|
|
18
18
|
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
19
19
|
const queue = settings?.queue;
|
|
20
20
|
const out = {};
|
|
21
|
-
const board = settings?.tracker?.board
|
|
21
|
+
const board = settings?.tracker?.board;
|
|
22
22
|
if (board && typeof board === "object") {
|
|
23
23
|
if (typeof board.number === "number" && Number.isInteger(board.number) && board.number > 0) {
|
|
24
24
|
out.project = board.number;
|
|
@@ -139,7 +139,7 @@ function resolveProjectSelector(args) {
|
|
|
139
139
|
: null;
|
|
140
140
|
if (!projectRef && !projectTitle) {
|
|
141
141
|
throw Object.assign(
|
|
142
|
-
new Error("--project is required (or set tracker.board
|
|
142
|
+
new Error("--project is required (or set tracker.board number / title in .devloops)"),
|
|
143
143
|
{ code: "INVALID_PROJECT" },
|
|
144
144
|
);
|
|
145
145
|
}
|
|
@@ -178,7 +178,7 @@ function findProject(projects, { projectRef, projectTitle }, owner) {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
// Apply .devloops board settings when --project was not passed. Precedence:
|
|
181
|
-
// explicit --project flag >
|
|
181
|
+
// explicit --project flag > tracker.board.number/tracker.board.title. Mutates args.
|
|
182
182
|
function applyDevloopsBoard(args, cwd) {
|
|
183
183
|
if (args.project === undefined) {
|
|
184
184
|
const settings = resolveSettings(cwd);
|