mandrel 2.47.0 → 2.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/.agents/agents/story-worker.md +49 -49
  2. package/.agents/docs/configuration.md +1 -0
  3. package/.agents/docs/quality-gates.md +48 -0
  4. package/.agents/scripts/lib/baselines/kernel.js +19 -0
  5. package/.agents/scripts/lib/baselines/kinds/bundle-size.js +12 -0
  6. package/.agents/scripts/lib/baselines/kinds/coverage.js +1 -0
  7. package/.agents/scripts/lib/baselines/kinds/crap.js +21 -5
  8. package/.agents/scripts/lib/baselines/kinds/duplication.js +1 -0
  9. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +26 -1
  10. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +1 -0
  11. package/.agents/scripts/lib/baselines/kinds/lint.js +12 -0
  12. package/.agents/scripts/lib/baselines/kinds/maintainability.js +1 -0
  13. package/.agents/scripts/lib/baselines/kinds/mutation.js +1 -0
  14. package/.agents/scripts/lib/baselines/merge-envelopes.js +272 -0
  15. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +175 -0
  16. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +8 -2
  17. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  18. package/.agents/scripts/lib/orchestration/epic-container.js +48 -21
  19. package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
  20. package/.agents/scripts/lib/orchestration/epic-rollup.js +66 -7
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +32 -61
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +171 -0
  23. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +483 -0
  24. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  25. package/.agents/scripts/merge-baseline.js +238 -0
  26. package/.agents/scripts/providers/github/errors.js +66 -10
  27. package/.agents/scripts/providers/github/sub-issues.js +8 -1
  28. package/.agents/workflows/helpers/deliver-digest.md +30 -26
  29. package/.agents/workflows/helpers/parallel-tooling.md +17 -0
  30. package/docs/CHANGELOG.md +25 -0
  31. package/lib/cli/registry.js +63 -0
  32. package/package.json +1 -1
@@ -0,0 +1,272 @@
1
+ /**
2
+ * merge-envelopes.js — pure 3-way merge of baseline envelopes by row
3
+ * identity (Story #5215).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Every baseline write stamps `generatedAt`, and the stamp sits on line 4 of
8
+ * every envelope. Two branches that each refresh a baseline therefore always
9
+ * differ on that line, even when they moved completely disjoint rows — so
10
+ * git's LINE-based merge has to reconcile it. Whether it can separate that
11
+ * hunk from the moved rows is an accident of proximity, and both outcomes
12
+ * are bad:
13
+ *
14
+ * - it cannot → a conflict on work that never actually overlapped;
15
+ * - it can → it splices both sides' row lines together into a row set
16
+ * NEITHER side ever scored. That silent one is the worse failure: the
17
+ * ratchet then guards a number no scorer produced.
18
+ *
19
+ * A baseline is not a text file. It is a set of rows keyed by identity plus
20
+ * a rollup DERIVED from those rows, so merging it as text is a category
21
+ * error. This module merges it as what it is.
22
+ *
23
+ * ## Contract
24
+ *
25
+ * Pure: no filesystem, no process, no clock. `assertEnvelope` is deliberately
26
+ * NOT called here (it compiles schemas off disk on first use) — the driver
27
+ * validates what this returns.
28
+ *
29
+ * Per row identity, the standard 3-way rule: the side that differs from base
30
+ * wins; when both sides differ from base AND from each other, that identity
31
+ * is a conflict. Absence is a value, so a row deleted on one side and
32
+ * untouched on the other merges to deleted.
33
+ *
34
+ * Two invariants are load-bearing:
35
+ *
36
+ * 1. **The rollup is recomputed, never merged.** Merging two rollups is
37
+ * the same splice hazard compressed into a single number, and unlike a
38
+ * spliced row set it leaves no evidence. It is always derived from the
39
+ * merged rows via the kind's own `rollup()`.
40
+ * 2. **Identity comes from the kind module** (`rowIdentity`), never from
41
+ * `keyField`. CRAP groups by file and identifies by method; keying on
42
+ * `keyField` would drop every method in a file but one.
43
+ *
44
+ * @module lib/baselines/merge-envelopes
45
+ */
46
+
47
+ import { deepEqual } from '../json-utils.js';
48
+ import { KNOWN_KINDS } from './envelope.js';
49
+ import { getKindModule } from './kernel.js';
50
+
51
+ /**
52
+ * Envelope keys that are NOT merged side-by-side: `rows` merge by identity,
53
+ * `rollup` is recomputed from them, and `generatedAt` resolves to the later
54
+ * of the two stamps rather than conflicting (it is metadata about when a
55
+ * scorer ran, not a scored value — treating it as content is the whole bug).
56
+ */
57
+ const DERIVED_KEYS = Object.freeze(['rows', 'rollup', 'generatedAt']);
58
+
59
+ /**
60
+ * Identify an envelope's kind from its `$schema` reference.
61
+ *
62
+ * Derived from `KNOWN_KINDS` rather than pattern-matched, so a file that is
63
+ * not a known per-kind envelope answers `null` — which is how the driver
64
+ * knows to hand it back to git's text merge instead of guessing at a shape
65
+ * it does not understand. `baselines/*.json` also matches several
66
+ * non-envelope baselines (arch-cycles, cyclomatic, dead-exports, …).
67
+ *
68
+ * @param {unknown} envelope
69
+ * @returns {string|null}
70
+ */
71
+ export function kindFromEnvelope(envelope) {
72
+ const ref = envelope?.$schema;
73
+ if (typeof ref !== 'string') return null;
74
+ const base = ref.split('/').pop();
75
+ for (const kind of KNOWN_KINDS) {
76
+ if (base === `${kind}.schema.json`) return kind;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * The 3-way choice for one value. `undefined` means "absent on this side",
83
+ * which makes deletion just another value rather than a special case.
84
+ *
85
+ * @param {unknown} base
86
+ * @param {unknown} ours
87
+ * @param {unknown} theirs
88
+ * @returns {{ conflict: boolean, value?: unknown }}
89
+ */
90
+ function choose(base, ours, theirs) {
91
+ if (deepEqual(ours, theirs)) return { conflict: false, value: ours };
92
+ if (deepEqual(ours, base)) return { conflict: false, value: theirs };
93
+ if (deepEqual(theirs, base)) return { conflict: false, value: ours };
94
+ return { conflict: true };
95
+ }
96
+
97
+ /**
98
+ * Index rows by the kind's `rowIdentity`. A duplicate identity within one
99
+ * side is fatal rather than last-write-wins: it means the incoming file
100
+ * already violates the identity contract, and merging it would silently
101
+ * drop a row.
102
+ *
103
+ * @param {Array<object>} rows
104
+ * @param {(row: object) => string} rowIdentity
105
+ * @param {string} side
106
+ * @returns {Map<string, object>}
107
+ */
108
+ function indexRows(rows, rowIdentity, side) {
109
+ const out = new Map();
110
+ for (const [idx, row] of (rows ?? []).entries()) {
111
+ if (!row || typeof row !== 'object') {
112
+ throw new TypeError(
113
+ `mergeEnvelopes: ${side} row at index ${idx} is not an object`,
114
+ );
115
+ }
116
+ const id = rowIdentity(row);
117
+ if (out.has(id)) {
118
+ throw new Error(
119
+ `mergeEnvelopes: ${side} carries two rows with identity "${id}" — the baseline violates the identity contract and cannot be merged safely`,
120
+ );
121
+ }
122
+ out.set(id, row);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /**
128
+ * Resolve `generatedAt` to the later of the two sides. A stamp is metadata,
129
+ * so it never conflicts: the merged file describes a tree scored as recently
130
+ * as the newer of its inputs.
131
+ *
132
+ * @param {string|undefined} ours
133
+ * @param {string|undefined} theirs
134
+ * @returns {string|undefined}
135
+ */
136
+ function laterStamp(ours, theirs) {
137
+ if (typeof ours !== 'string') return theirs;
138
+ if (typeof theirs !== 'string') return ours;
139
+ const a = Date.parse(ours);
140
+ const b = Date.parse(theirs);
141
+ if (Number.isNaN(a) || Number.isNaN(b)) return ours > theirs ? ours : theirs;
142
+ return a >= b ? ours : theirs;
143
+ }
144
+
145
+ /**
146
+ * Merge the envelope-level stamps (`$schema`, `kernelVersion`, and per-kind
147
+ * extras like `scoringSemantics`) by the same 3-way rule as rows. A double
148
+ * bump to different values is a genuine conflict — two branches disagreeing
149
+ * about which scorer produced the file.
150
+ *
151
+ * @param {object} base
152
+ * @param {object} ours
153
+ * @param {object} theirs
154
+ * @returns {{ merged: object, conflicts: Array<object> }}
155
+ */
156
+ function mergeStamps(base, ours, theirs) {
157
+ const keys = new Set(
158
+ [...Object.keys(ours), ...Object.keys(theirs), ...Object.keys(base)].filter(
159
+ (k) => !DERIVED_KEYS.includes(k),
160
+ ),
161
+ );
162
+ const merged = {};
163
+ const conflicts = [];
164
+ for (const key of keys) {
165
+ const pick = choose(base[key], ours[key], theirs[key]);
166
+ if (pick.conflict) {
167
+ conflicts.push({
168
+ scope: 'envelope',
169
+ identity: key,
170
+ base: base[key],
171
+ ours: ours[key],
172
+ theirs: theirs[key],
173
+ });
174
+ merged[key] = ours[key];
175
+ continue;
176
+ }
177
+ if (pick.value !== undefined) merged[key] = pick.value;
178
+ }
179
+ return { merged, conflicts };
180
+ }
181
+
182
+ /**
183
+ * 3-way merge two baseline envelopes against their common ancestor.
184
+ *
185
+ * @param {{
186
+ * base: object|null,
187
+ * ours: object,
188
+ * theirs: object,
189
+ * kind?: string,
190
+ * components?: Array<object>,
191
+ * }} params
192
+ * - `base` — the merge ancestor; `null` (or a rowless object) when the
193
+ * file was added on both sides.
194
+ * - `components` — passed straight to the kind's `rollup()`. Defaults to
195
+ * `[]`, which is what `refreshBaseline` effectively uses, so a merged
196
+ * envelope carries the same `{'*': …}` rollup shape a refresh writes.
197
+ * @returns {{
198
+ * kind: string,
199
+ * envelope: object,
200
+ * conflicts: Array<{scope: string, identity: string, base?: unknown, ours?: unknown, theirs?: unknown}>,
201
+ * }}
202
+ */
203
+ export function mergeEnvelopes({
204
+ base,
205
+ ours,
206
+ theirs,
207
+ kind,
208
+ components = [],
209
+ } = {}) {
210
+ const resolvedKind =
211
+ kind ?? kindFromEnvelope(ours) ?? kindFromEnvelope(theirs);
212
+ if (!resolvedKind) {
213
+ throw new Error(
214
+ 'mergeEnvelopes: could not resolve a known baseline kind from the envelopes',
215
+ );
216
+ }
217
+ const mod = getKindModule(resolvedKind);
218
+ const baseEnv = base && typeof base === 'object' ? base : { rows: [] };
219
+
220
+ const baseRows = indexRows(baseEnv.rows, mod.rowIdentity, 'base');
221
+ const ourRows = indexRows(ours?.rows, mod.rowIdentity, 'ours');
222
+ const theirRows = indexRows(theirs?.rows, mod.rowIdentity, 'theirs');
223
+
224
+ const conflicts = [];
225
+ const rows = [];
226
+ const identities = new Set([
227
+ ...ourRows.keys(),
228
+ ...theirRows.keys(),
229
+ ...baseRows.keys(),
230
+ ]);
231
+ for (const id of identities) {
232
+ const b = baseRows.get(id);
233
+ const o = ourRows.get(id);
234
+ const t = theirRows.get(id);
235
+ const pick = choose(b, o, t);
236
+ if (pick.conflict) {
237
+ conflicts.push({
238
+ scope: 'row',
239
+ identity: id,
240
+ base: b,
241
+ ours: o,
242
+ theirs: t,
243
+ });
244
+ // Keep the ours-side value so the row set stays well-formed; the
245
+ // driver renders the conflict markers around it from this record.
246
+ if (o !== undefined) rows.push(o);
247
+ else if (t !== undefined) rows.push(t);
248
+ continue;
249
+ }
250
+ if (pick.value !== undefined) rows.push(pick.value);
251
+ }
252
+
253
+ const sortedRows = mod.sortRows(rows);
254
+ const { merged: stamps, conflicts: stampConflicts } = mergeStamps(
255
+ baseEnv,
256
+ ours ?? {},
257
+ theirs ?? {},
258
+ );
259
+
260
+ const envelope = {
261
+ ...stamps,
262
+ generatedAt: laterStamp(ours?.generatedAt, theirs?.generatedAt),
263
+ rollup: mod.rollup(sortedRows, components),
264
+ rows: sortedRows,
265
+ };
266
+
267
+ return {
268
+ kind: resolvedKind,
269
+ envelope,
270
+ conflicts: [...stampConflicts, ...conflicts],
271
+ };
272
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * bootstrap/baseline-merge-driver — register the `baselines/*.json` merge
3
+ * driver on a consumer clone (Story #5215).
4
+ *
5
+ * Registration has two halves that live in different places, and conflating
6
+ * them is why this needs a doctor check rather than just an installer:
7
+ *
8
+ * 1. **`.gitattributes`** says which files use the driver. It is a tracked
9
+ * file, so installing the line once ships it to everyone.
10
+ * 2. **`git config merge.mandrel-baseline.driver`** says what the driver
11
+ * actually is. Git deliberately keeps this out of tracked config —
12
+ * otherwise a clone would execute a command chosen by whoever wrote the
13
+ * repo — so it is **per clone**, and a fresh clone silently falls back
14
+ * to git's text merge with no error at all.
15
+ *
16
+ * That silence is the whole reason `mandrel doctor` carries a check: an
17
+ * unregistered clone is not broken in any way it can report on its own, it
18
+ * just quietly goes back to conflicting (or worse, splicing) baselines.
19
+ *
20
+ * This module owns both halves plus the strings they share, so the installer
21
+ * and the doctor check cannot drift apart on the exact command.
22
+ *
23
+ * @module lib/bootstrap/baseline-merge-driver
24
+ */
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { spawnCapture } from '../child-exec.js';
29
+
30
+ /** Driver name, as it appears on both sides of the registration. */
31
+ const BASELINE_MERGE_DRIVER_NAME = 'mandrel-baseline';
32
+
33
+ /** The `.gitattributes` line that routes baselines through the driver. */
34
+ const BASELINE_MERGE_ATTRIBUTE = `baselines/*.json merge=${BASELINE_MERGE_DRIVER_NAME}`;
35
+
36
+ /** Git config key holding the driver command. */
37
+ export const BASELINE_MERGE_DRIVER_CONFIG_KEY = `merge.${BASELINE_MERGE_DRIVER_NAME}.driver`;
38
+
39
+ /**
40
+ * The driver command. Relative to the worktree root, which is where git runs
41
+ * a merge driver from, and where `mandrel sync` materializes `.agents/`.
42
+ */
43
+ const BASELINE_MERGE_DRIVER_COMMAND =
44
+ 'node .agents/scripts/merge-baseline.js %O %A %B %P';
45
+
46
+ /** The exact command an operator runs to complete registration. */
47
+ export const BASELINE_MERGE_DRIVER_REMEDY = `git config ${BASELINE_MERGE_DRIVER_CONFIG_KEY} "${BASELINE_MERGE_DRIVER_COMMAND}"`;
48
+
49
+ /**
50
+ * Does this `.gitattributes` content route baselines through the driver?
51
+ * Comment lines do not count — a commented-out registration is not one.
52
+ *
53
+ * @param {string|null|undefined} gitattributes
54
+ * @returns {boolean}
55
+ */
56
+ export function declaresBaselineMergeDriver(gitattributes) {
57
+ return String(gitattributes ?? '')
58
+ .split('\n')
59
+ .some((line) => {
60
+ const trimmed = line.trim();
61
+ if (trimmed === '' || trimmed.startsWith('#')) return false;
62
+ return trimmed.includes(`merge=${BASELINE_MERGE_DRIVER_NAME}`);
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Add the attribute line, preserving every existing line verbatim.
68
+ *
69
+ * @param {string} projectRoot
70
+ * @param {typeof fs} [fsImpl]
71
+ * @returns {{ action: 'created'|'appended'|'already-present', path: string }}
72
+ */
73
+ function ensureGitattributesLine(projectRoot, fsImpl = fs) {
74
+ const target = path.join(projectRoot, '.gitattributes');
75
+ if (!fsImpl.existsSync(target)) {
76
+ fsImpl.writeFileSync(target, `${BASELINE_MERGE_ATTRIBUTE}\n`, 'utf8');
77
+ return { action: 'created', path: target };
78
+ }
79
+ const existing = fsImpl.readFileSync(target, 'utf8');
80
+ if (declaresBaselineMergeDriver(existing)) {
81
+ return { action: 'already-present', path: target };
82
+ }
83
+ // A file not ending in a newline would otherwise glue our line onto the
84
+ // last existing one, silently rewriting it.
85
+ const separator = existing === '' || existing.endsWith('\n') ? '' : '\n';
86
+ fsImpl.writeFileSync(
87
+ target,
88
+ `${existing}${separator}${BASELINE_MERGE_ATTRIBUTE}\n`,
89
+ 'utf8',
90
+ );
91
+ return { action: 'appended', path: target };
92
+ }
93
+
94
+ /**
95
+ * Point `merge.mandrel-baseline.driver` at the driver in THIS clone.
96
+ *
97
+ * @param {string} projectRoot
98
+ * @param {typeof spawnCapture} [spawnImpl]
99
+ * @returns {{ action: 'set'|'already-present'|'not-a-repo'|'failed' }}
100
+ */
101
+ function ensureDriverGitConfig(projectRoot, spawnImpl = spawnCapture) {
102
+ const opts = {
103
+ cwd: projectRoot,
104
+ encoding: 'utf-8',
105
+ stdio: 'pipe',
106
+ shell: false,
107
+ };
108
+ const inRepo = spawnImpl('git', ['rev-parse', '--git-dir'], opts);
109
+ if ((inRepo?.status ?? 1) !== 0) return { action: 'not-a-repo' };
110
+
111
+ const current = spawnImpl(
112
+ 'git',
113
+ ['config', '--local', '--get', BASELINE_MERGE_DRIVER_CONFIG_KEY],
114
+ opts,
115
+ );
116
+ if (
117
+ (current?.status ?? 1) === 0 &&
118
+ String(current.stdout ?? '').trim() === BASELINE_MERGE_DRIVER_COMMAND
119
+ ) {
120
+ return { action: 'already-present' };
121
+ }
122
+
123
+ const set = spawnImpl(
124
+ 'git',
125
+ [
126
+ 'config',
127
+ '--local',
128
+ BASELINE_MERGE_DRIVER_CONFIG_KEY,
129
+ BASELINE_MERGE_DRIVER_COMMAND,
130
+ ],
131
+ opts,
132
+ );
133
+ spawnImpl(
134
+ 'git',
135
+ [
136
+ 'config',
137
+ '--local',
138
+ `merge.${BASELINE_MERGE_DRIVER_NAME}.name`,
139
+ 'mandrel baseline merge by row identity',
140
+ ],
141
+ opts,
142
+ );
143
+ return { action: (set?.status ?? 1) === 0 ? 'set' : 'failed' };
144
+ }
145
+
146
+ /**
147
+ * Install both halves. Idempotent: a second run reports `already-present`
148
+ * and changes no bytes.
149
+ *
150
+ * @param {object} ctx
151
+ * @param {string} ctx.projectRoot
152
+ * @param {typeof spawnCapture} [ctx.spawnImpl]
153
+ * @param {typeof fs} [ctx.fsImpl]
154
+ * @returns {{
155
+ * action: 'already-present'|'updated',
156
+ * attributes: string,
157
+ * config: string,
158
+ * path: string,
159
+ * line: string,
160
+ * }}
161
+ */
162
+ export function ensureBaselineMergeDriver(ctx) {
163
+ const attributes = ensureGitattributesLine(ctx.projectRoot, ctx.fsImpl ?? fs);
164
+ const config = ensureDriverGitConfig(ctx.projectRoot, ctx.spawnImpl);
165
+ const settled =
166
+ attributes.action === 'already-present' &&
167
+ (config.action === 'already-present' || config.action === 'not-a-repo');
168
+ return {
169
+ action: settled ? 'already-present' : 'updated',
170
+ attributes: attributes.action,
171
+ config: config.action,
172
+ path: attributes.path,
173
+ line: BASELINE_MERGE_ATTRIBUTE,
174
+ };
175
+ }
@@ -15,7 +15,11 @@
15
15
  * 4. Seeds `delivery.quality.codingGuardrails` and
16
16
  * `delivery.quality.autoRefresh` defaults in `.agentrc.json` when
17
17
  * the keys are absent. Existing values are preserved.
18
- * 5. Prunes a committed pre-v2 `baselines/epic/` tree (Story #5007). The
18
+ * 5. Registers the `baselines/*.json` merge driver (Story #5215) — the
19
+ * `.gitattributes` line plus this clone's `merge.mandrel-baseline.driver`
20
+ * config, so concurrent baseline refreshes merge by row identity instead
21
+ * of conflicting on the `generatedAt` stamp.
22
+ * 6. Prunes a committed pre-v2 `baselines/epic/` tree (Story #5007). The
19
23
  * v2 model is Story-only — nothing writes, reads, or reaps per-Epic
20
24
  * ratchet snapshots — so an upgrading consumer is left carrying a
21
25
  * committed directory no gate consults. Absent on every repo that never
@@ -35,6 +39,7 @@ import fs from 'node:fs';
35
39
  import path from 'node:path';
36
40
  import { getAgentrcDefaults, lookupPath } from '../config/defaults.js';
37
41
  import { deepEqual } from '../json-utils.js';
42
+ import { ensureBaselineMergeDriver } from './baseline-merge-driver.js';
38
43
 
39
44
  /**
40
45
  * The exact pre-commit body the framework ships. Kept as a single string so
@@ -407,7 +412,7 @@ export function pruneLegacyEpicBaselines(ctx) {
407
412
  }
408
413
 
409
414
  /**
410
- * Run all five steps in order. Composable wrapper used by the bootstrap
415
+ * Run all six steps in order. Composable wrapper used by the bootstrap
411
416
  * and update workflows. Each step's outcome is returned under its own key
412
417
  * so callers can render a per-action summary.
413
418
  *
@@ -423,6 +428,7 @@ export function applyQualityBootstrap(ctx) {
423
428
  hook: ensurePreCommitHook(ctx),
424
429
  scripts: ensureQualityNpmScripts(ctx),
425
430
  config: ensureQualityConfigDefaults(ctx),
431
+ mergeDriver: ensureBaselineMergeDriver(ctx),
426
432
  legacyBaselines: pruneLegacyEpicBaselines(ctx),
427
433
  };
428
434
  }
@@ -121,6 +121,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
121
121
  'lint-issue-body.js',
122
122
  'lint-label-vocabulary.js',
123
123
  'mandrel-update-preflight.js',
124
+ 'merge-baseline.js',
124
125
  'nav-registry-diff.js',
125
126
  'notify.js',
126
127
  'plan-context.js',
@@ -33,23 +33,35 @@
33
33
  import { TYPE_LABELS } from '../label-constants.js';
34
34
 
35
35
  /**
36
- * The checklist grammar. `getSubTickets` (`providers/github/issues.js`)
37
- * already parses this exact form as its strategy-2 child source, so the
38
- * checklist is a durable mirror of the native sub-issue edges rather than a
39
- * second, competing representation: when the sub-issues API is unavailable
40
- * — an older GHES, a revoked scope, a partial write — the children are still
41
- * discoverable from the body alone.
42
- *
43
- * Kept in sync with `_getChecklistChildren` deliberately; a divergence here
44
- * would strand children the writer believes it linked.
36
+ * The checklist grammar: a checklist row, and the **first** issue reference
37
+ * anywhere on it. The checklist is a durable mirror of the native sub-issue
38
+ * edges rather than a second, competing representation — when the sub-issues
39
+ * API is unavailable (an older GHES, a revoked scope, a rate-limit burst) the
40
+ * children are still discoverable from the body alone.
41
+ *
42
+ * Deliberately the **loosest** of the three grammars that read this shape, and
43
+ * looser than it was: it used to require the id to be the whole row
44
+ * (`- [ ] #123`), which matched none of the annotated rows a hand-maintained
45
+ * rollout tracker actually carries — `- [ ] Design sign-off (#1897): pending`,
46
+ * `- [ ] 1.4 #1909 (Part B blocked)`. An Epic with 58 children presented three
47
+ * to the rollup, which closed it with 23 still open (Story #5210).
48
+ *
49
+ * It is NOT in sync with `_getChecklistChildren` (`providers/github/issues.js`)
50
+ * and no longer claims to be: that one is a general parent→child strategy for
51
+ * any issue and still requires `#N` immediately after the checkbox. Reading a
52
+ * superset here is safe in the direction that matters — a spurious id costs a
53
+ * skipped non-Story child, while a missed id costs a container closed over open
54
+ * work. The union with the native edges keeps both honest, and after #5210
55
+ * nothing irreversible is decided on this grammar alone.
45
56
  */
46
- const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s+#(\d+)\s*$/gm;
57
+ const CHECKLIST_ITEM_RE = /^-\s*\[[ xX]\]\s+.*?#(\d+)\b/gm;
47
58
 
48
59
  /**
49
60
  * The same grammar, unanchored to a global cursor — for callers testing one
50
- * line at a time. Kept beside its `/g` twin so the two cannot drift.
61
+ * line at a time. Kept beside its `/g` twin so the two cannot drift; the pair
62
+ * MUST accept the same line set, which `epic-container.test.js` pins.
51
63
  */
52
- export const CHECKLIST_ITEM_LINE_RE = /^-\s*\[[ xX]\]\s+#\d+\s*$/;
64
+ export const CHECKLIST_ITEM_LINE_RE = /^-\s*\[[ xX]\]\s+.*?#\d+\b/;
53
65
 
54
66
  /** Heading the container's one prose section renders under. */
55
67
  const GOAL_HEADING = '## Goal';
@@ -180,12 +192,23 @@ export function readEpicChildIds(body) {
180
192
  * degrades to the checklist rather than propagating, since a body-derived
181
193
  * child list is a strictly better answer than an error.
182
194
  *
195
+ * **The degrade is reported, not hidden.** The result carries
196
+ * `nativeReadFailed`, because a truncated list and a genuinely small Epic are
197
+ * otherwise indistinguishable downstream — and one caller
198
+ * (`epic-rollup.js`) decides an irreversible close on the difference. A caller
199
+ * that only needs "who are the children" reads `ids` and ignores the flag;
200
+ * a caller about to do something it cannot undo MUST NOT.
201
+ *
202
+ * `nativeReadFailed` is false when no reader was injected: a caller that
203
+ * supplied none never asked for authority and is not degraded relative to what
204
+ * it requested.
205
+ *
183
206
  * @param {{
184
207
  * epic: { number?: number, id?: number, body?: string, nodeId?: string },
185
208
  * readNativeChildIds?: (epic: object) => Promise<number[]>,
186
209
  * onWarn?: (message: string) => void,
187
210
  * }} opts
188
- * @returns {Promise<number[]>}
211
+ * @returns {Promise<{ ids: number[], nativeReadFailed: boolean }>}
189
212
  */
190
213
  export async function readEpicChildIdsFrom({
191
214
  epic,
@@ -193,18 +216,22 @@ export async function readEpicChildIdsFrom({
193
216
  onWarn,
194
217
  } = {}) {
195
218
  const fromBody = readEpicChildIds(epic?.body);
196
- if (typeof readNativeChildIds !== 'function') return fromBody;
219
+ if (typeof readNativeChildIds !== 'function') {
220
+ return { ids: fromBody, nativeReadFailed: false };
221
+ }
197
222
 
198
- let native = [];
199
223
  try {
200
- native = normalizeChildIds(await readNativeChildIds(epic));
224
+ const native = normalizeChildIds(await readNativeChildIds(epic));
225
+ return {
226
+ ids: normalizeChildIds([...native, ...fromBody]),
227
+ nativeReadFailed: false,
228
+ };
201
229
  } catch (err) {
202
- const id = epic?.number ?? epic?.id ?? '?';
203
230
  onWarn?.(
204
- `[epic-container] native sub-issue read failed for Epic #${id} ` +
205
- `(${err?.message ?? String(err)}); using the body checklist alone.`,
231
+ `[epic-container] native sub-issue read failed for Epic ` +
232
+ `#${epic?.number ?? epic?.id ?? '?'} (${err?.message ?? String(err)}); ` +
233
+ 'using the body checklist alone — the child list may be incomplete.',
206
234
  );
235
+ return { ids: fromBody, nativeReadFailed: true };
207
236
  }
208
-
209
- return normalizeChildIds([...native, ...fromBody]);
210
237
  }
@@ -27,6 +27,32 @@ function isStoryTicket(issue) {
27
27
  .includes(TYPE_LABELS.STORY);
28
28
  }
29
29
 
30
+ /**
31
+ * The error for an Epic that yielded no children.
32
+ *
33
+ * The two empty cases have different remedies, so they get different messages:
34
+ * an operator told to go link Stories that are already linked will do the wrong
35
+ * thing for what was really a transient API failure (Story #5210).
36
+ *
37
+ * @param {number} id
38
+ * @param {boolean} nativeReadFailed
39
+ * @returns {Error}
40
+ */
41
+ function noChildrenError(id, nativeReadFailed) {
42
+ if (nativeReadFailed) {
43
+ return new Error(
44
+ `[resolve-stories] Epic #${id} expanded to no child Stories, but the ` +
45
+ `native sub-issue read failed — the list is incomplete, not empty. ` +
46
+ `Re-run once the GitHub API read succeeds.`,
47
+ );
48
+ }
49
+ return new Error(
50
+ `[resolve-stories] Epic #${id} lists no child Stories. An Epic is a container: ` +
51
+ `link its Stories (a "- [ ] #N" checklist line or a GitHub sub-issue) ` +
52
+ `or deliver the Story ids directly.`,
53
+ );
54
+ }
55
+
30
56
  /**
31
57
  * Expand any container-Epic id in the requested set to its open child
32
58
  * Stories, leaving every other id untouched.
@@ -90,17 +116,13 @@ export async function expandEpicIds({
90
116
  continue;
91
117
  }
92
118
 
93
- const childIds = await readEpicChildIdsFrom({
119
+ const { ids: childIds, nativeReadFailed } = await readEpicChildIdsFrom({
94
120
  epic: issue,
95
121
  readNativeChildIds,
96
122
  onWarn: warn,
97
123
  });
98
124
  if (childIds.length === 0) {
99
- throw new Error(
100
- `[resolve-stories] Epic #${id} lists no child Stories. An Epic is a container: ` +
101
- `link its Stories (a "- [ ] #N" checklist line or a GitHub sub-issue) ` +
102
- `or deliver the Story ids directly.`,
103
- );
125
+ throw noChildrenError(id, nativeReadFailed);
104
126
  }
105
127
 
106
128
  const open = [];