@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -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` supersedes the deprecated `queue.board` (see resolveTrackerBoard).
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; supersedes the deprecated queue.board.").optional(),
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 (mirrors queue.board). The logical-column ->
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 {"defaults"|"settings"|"extensionDefaults"|"merged"} layer - Which config layer failed
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 settings).
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"|"settings"} layer - Layer name
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
- * settings.(yaml|yml|json) > legacy overrides.(yaml|yml|json) > repo .pi/dev-loop/defaults.(yaml|yml|json) > extension defaults > built-in defaults
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, "settings", warnings, errors);
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; `queue.board` is a DEPRECATED alias, already normalized onto
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 (.pi/dev-loop/settings.yaml,
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
- Verify before approval that the PR checklist and AC/DoD/non-goals matrix are complete. - Every PR checkbox (`- [ ]`) must be checked. If any box is unchecked, flag it as a blocking finding. - The PR body must contain an AC/DoD/non-goals matrix that maps each acceptance criterion
267
- to its definition-of-done item(s) and lists explicit non-goals.
268
- - The matrix must have a markdown table with at least a header row and one content row. - Flag the matrix as incomplete if any acceptance criterion, definition-of-done item, or non-goal is missing.
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 queue.board omitted by design).
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
@@ -63,13 +63,19 @@ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
63
63
  }
64
64
 
65
65
  /**
66
- * Adaptive 429-backoff concurrency (issue #1601): halve the active batch before
67
- * escalating to foreground one-at-a-time fallback. On a 429, the conductor
68
- * recomputes the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
69
- * retries the failed wave; if a single-unit wave still 429s, it falls back to
70
- * foreground (one-at-a-time) dispatch. The backoff is recorded in the round's
71
- * provenance (see skills/docs/gate-review-sub-loop-contract.md). Pure; never
72
- * returns 0 (a backoff from 1 stays 1 → foreground fallback owns that path).
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 (Acceptance criteria section, DoD section,
7
- * or a linked refinement doc) that the pre-approval gate can verify
8
- * against. Prose-only issues (Problem / Root Cause / Fix) without an
9
- * `Acceptance criteria` or `DoD` section cause the draft gate to post
10
- * `verdict=blocked` with the `missing_refinement_artifact` finding.
11
- *
12
- * Since #1866 the check ALSO requires an explicit Non-goals section on the
13
- * issue body (see `MISSING_EXPLICIT_NON_GOALS_FINDING` below).
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
- // The three artifact sources, any ONE of which satisfies the refinement gate.
40
- // Single source of truth for the "missing" vocabulary reported when none is
41
- // present consumed by the enqueue gate and the parked-unrefined discovery.
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
- /^acceptance criteria\s*$/i,
85
+ // #1877 round-6: index 0 is the exact-canonical ANCHOR family —
86
+ // `^acceptance criteria\b` — so a decorated-variant canonical heading
87
+ // (`## Acceptance criteria (v2)`, `## Definition of done — core`) still lands
88
+ // in the exact bucket rather than matching NO pattern at all (the alias
89
+ // families anchor on the `AC`/`DoD` abbreviations and never fire for the
90
+ // spelled-out phrase). The anchor stays distinct from the alias family below
91
+ // (`/^ac\b/`), so the precedence contract is unchanged: a spelled-out
92
+ // canonical heading always outranks an abbreviation-shaped alias heading.
93
+ /^acceptance criteria\b.*$/i,
66
94
  /^ac\b.*$/i,
67
95
  ]);
68
96
 
69
97
  const DOD_SECTION_PATTERNS = Object.freeze([
70
- /^definition of done\s*$/i,
71
- /^done\s*$/i,
72
- /^dod\s*$/i,
98
+ // Same anchor-family widening as the AC family (see above). #1877 round-7:
99
+ // the ALIAS arms are widened symmetrically with the AC family too — a
100
+ // decorated-variant alias heading (`## DoD (v2)`, `## Done — core`) must land
101
+ // in the alias bucket, not in NO bucket (a `$`-anchored alias silently
102
+ // disarms the PR-side DoD read and false-blocks the issue side).
103
+ /^definition of done\b.*$/i,
104
+ /^done\b.*$/i,
105
+ /^dod\b.*$/i,
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
- name: match[2],
294
+ // #1877 round-6: normalize the captured name so decorated canonical
295
+ // headings (`## **Acceptance criteria**`) match the section patterns.
296
+ // The RAW name is never re-parsed as body text (flattenSectionDeep no
297
+ // longer re-injects it), so normalization is the only consumer of the
298
+ // capture — the raw form is not retained (no consumer reads it).
299
+ name: normalizeHeadingName(match[2]),
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 (`- [ ]`/`- [x]`/`- [X]`) becomes `{ text, checked }`
169
- * (`checked` true only for a ticked `[x]`/`[X]`); a top-level plain bullet
170
- * (`- text`, dash at column 0 so nested/indented sub-bullets are not counted)
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: `- [ ]` / `- [x]` (leading indentation tolerated).
201
- // Consume ANY checkbox-marker line here; push only when it carries text,
202
- // so empty placeholders (`- [ ]`) are skipped rather than counted.
203
- const checkboxMatch = /^\s*-\s+\[(?:[ xX])\](?:\s+(.+?))?\s*$/u.exec(line);
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[1] ?? "").trim();
372
+ const text = (checkboxMatch[2] ?? "").trim();
206
373
  if (text.length > 0) {
207
- // `checked` is true only for a ticked box; `[ ]` (space) is false.
374
+ // `checked` is true only for a ticked marker (`[x]`/`[X]`); a space
375
+ // marker (`[ ]`) is false — regardless of what the label text says.
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: /^\s*-\s+\[[xX]\]/u.test(line) });
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: true,
635
+ hasACs: false,
435
636
  source: REFINEMENT_SOURCE.ISSUE_BODY_DOD,
436
- reason: `Found ${dodItems.length} DoD checklist item(s) in the issue body.`,
437
- finding: null,
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), clearer than reading `hasACs`, whose name understates what it
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
- "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
1042
+ "Refine the issue to the full AC/DoD/Non-goals matrix — an Acceptance criteria checklist, a Definition of done checklist, and an explicit Non-goals section " +
1043
+ "or link a refinement doc (tmp/refinement/*.md), which is a complete artifact on its own " +
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 / DoD / linked refinement doc). Run refinement first, add ACs/DoD to the issue, then re-open the draft PR. finding=${REFINEMENT_ARTIFACT_FINDING}`;
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 / DoD / linked refinement doc). finding=${REFINEMENT_ARTIFACT_FINDING}`;
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
- // alongside `queue` so loadBoardConfig can prefer tracker.board over
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 { settings: queue, tracker, error } = readDevloopsSettings(repoRoot);
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) takes priority over the deprecated queue.board
212
- // (issue #1408) — see resolveTrackerBoard in ../config/config.mjs for the
213
- // equivalent resolution against the validated, loaded config.
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 seam) takes priority over
10
- // the deprecated `queue.board` — same precedence as loadBoardConfig in
11
- // ../loop/queue-board-sync.mjs and resolveTrackerBoard in ../config/config.mjs.
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 ?? queue?.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 — or the deprecated queue.board — number / title in .devloops)"),
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 > queue.board.number/queue.board.title. Mutates args.
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);