mandrel 2.46.0 → 2.48.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/docs/configuration.md +1 -0
  2. package/.agents/docs/quality-gates.md +48 -0
  3. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  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/column-sync.js +26 -2
  19. package/.agents/scripts/lib/orchestration/epic-container.js +56 -21
  20. package/.agents/scripts/lib/orchestration/epic-expansion.js +28 -6
  21. package/.agents/scripts/lib/orchestration/epic-rollup.js +460 -0
  22. package/.agents/scripts/lib/orchestration/run-epilogue.js +44 -104
  23. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +60 -1
  24. package/.agents/scripts/merge-baseline.js +238 -0
  25. package/.agents/scripts/providers/github/errors.js +66 -10
  26. package/.agents/scripts/providers/github/sub-issues.js +8 -1
  27. package/.agents/scripts/single-story-init.js +35 -0
  28. package/.agents/workflows/helpers/deliver-reference.md +31 -9
  29. package/.agents/workflows/mandrel-deliver.md +3 -3
  30. package/docs/CHANGELOG.md +19 -0
  31. package/lib/cli/registry.js +63 -0
  32. package/package.json +1 -1
@@ -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 = [];
@@ -0,0 +1,460 @@
1
+ /**
2
+ * epic-rollup.js — derive a container Epic's board state from its children.
3
+ *
4
+ * A container Epic is never delivered, so nothing in the delivery engine
5
+ * ever writes to it. That left it inert on the board for the whole run it
6
+ * was the subject of: `columnForLabels` reads `agent::*` labels and the
7
+ * container carries none by construction, so the Status sync always skipped
8
+ * it; and the only closure lived in the multi-Story run epilogue, which a
9
+ * single-Story delivery never reaches.
10
+ *
11
+ * This module is the one place that answers "what state is this Epic in?"
12
+ * — by asking its children — and the one place that writes the answer.
13
+ * {@link rollUpEpicForStory} is invoked from the two per-Story lifecycle
14
+ * edges (the `agent::executing` flip in `single-story-init.js` and the
15
+ * post-land tail), which is what makes the rollup hold at N=1, and the run
16
+ * epilogue delegates its Epic close here so one closure rule exists.
17
+ *
18
+ * Three invariants shape the writes:
19
+ *
20
+ * 1. **The Epic never gains an `agent::*` label.** That absence keeps the
21
+ * container out of the bare `/mandrel-deliver` ready list and outside
22
+ * `lint-issue-body.js`, so the derived column goes to the board
23
+ * directly via `ColumnSync.setColumn` rather than through a label.
24
+ * 2. **Status recomputes in both directions; closure is one-way.** A
25
+ * reopened child pulls a closed Epic's Status back to `In Progress`
26
+ * and MUST NOT reopen the issue — an operator who closed a container
27
+ * deliberately is not overruled by a reopened child.
28
+ * 3. **Never throws.** Every step degrades with a reason. A stale
29
+ * container costs tidiness; a delivery failed on a board mutation
30
+ * costs a landed Story its terminal envelope.
31
+ * 4. **Closure requires an authoritative child list.** Invariant 3 makes
32
+ * every read degrade rather than fail, which is right for the writes
33
+ * that recompute next tick and wrong for the one that does not. When
34
+ * the native sub-issue read fails, the body checklist still answers
35
+ * "who are the children" — but no longer "are these *all* of them",
36
+ * and closing on that difference shut an Epic over 23 open children
37
+ * (Story #5210). Degraded reads keep the Status and assignee writes
38
+ * and lose only the close.
39
+ *
40
+ * @module lib/orchestration/epic-rollup
41
+ * @see Story #5205
42
+ * @see Story #5210 — fail closed on a degraded child read.
43
+ */
44
+
45
+ import { Logger } from '../Logger.js';
46
+ import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
47
+ import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
48
+ import { isEpicTicket, readEpicChildIdsFrom } from './epic-container.js';
49
+ import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
50
+ import { deriveParentState } from './ticketing/bulk.js';
51
+
52
+ /**
53
+ * Derived states that mean "children are moving" — the window in which the
54
+ * Epic carries an owner. `agent::blocked` counts: a blocked child is still
55
+ * this operator's problem, and dropping the assignee at the moment someone
56
+ * needs to be found would invert the signal.
57
+ */
58
+ const IN_FLIGHT_STATES = new Set([
59
+ AGENT_LABELS.EXECUTING,
60
+ AGENT_LABELS.BLOCKED,
61
+ ]);
62
+
63
+ /**
64
+ * Read an Epic's native sub-issue children as issue numbers.
65
+ *
66
+ * A local adapter rather than an import from `resolve-stories.js`: that
67
+ * module is a CLI entrypoint, and reaching up into one from the lib layer to
68
+ * borrow four lines would invert the dependency direction for no gain. What
69
+ * matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
70
+ * same on both paths, which is what keeps an Epic from being expandable but
71
+ * unclosable.
72
+ *
73
+ * @param {object} provider
74
+ * @returns {(epic: object) => Promise<number[]>}
75
+ */
76
+ function nativeChildReader(provider) {
77
+ return async (epic) => {
78
+ if (typeof provider?._getNativeSubIssues !== 'function') return [];
79
+ return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Resolve the handle the Epic is assigned to while its children run.
85
+ *
86
+ * Deliberately the **non-throwing** resolution (`missingHandleBehavior:
87
+ * 'null'`), unlike the Story lease's: a container with no owner recorded is
88
+ * a cosmetic gap, and refusing the whole rollup over it would cost the
89
+ * Status write and the closure too.
90
+ *
91
+ * @param {object} config Resolved `.agentrc.json` config.
92
+ * @returns {string|null} Bare login, or null when none is configured.
93
+ */
94
+ function resolveEpicOwner(config) {
95
+ return resolveOperatorFromCandidates({
96
+ candidates: [config?.github?.operatorHandle],
97
+ missingHandleBehavior: 'null',
98
+ });
99
+ }
100
+
101
+ /**
102
+ * Normalize an issue's assignee list to bare logins.
103
+ *
104
+ * @param {unknown} raw
105
+ * @returns {string[]}
106
+ */
107
+ function normalizeAssignees(raw) {
108
+ if (!Array.isArray(raw)) return [];
109
+ return raw
110
+ .map((a) => (typeof a === 'string' ? a : a?.login))
111
+ .filter((login) => typeof login === 'string' && login.length > 0);
112
+ }
113
+
114
+ /**
115
+ * Is this issue already closed?
116
+ *
117
+ * @param {{ state?: string }} issue
118
+ * @returns {boolean}
119
+ */
120
+ function isClosed(issue) {
121
+ return String(issue?.state ?? '').toLowerCase() === 'closed';
122
+ }
123
+
124
+ /**
125
+ * Read every child of one Epic, freshly.
126
+ *
127
+ * Returns `null` when any child is unreadable. That is the conservative
128
+ * answer, not a lazy one: `deriveParentState` reads "all children done" off
129
+ * the list it is handed, so a silently dropped child could close a container
130
+ * with work still open under it.
131
+ *
132
+ * Note the scope: this validates the **readability of the ids it was given**,
133
+ * never the **completeness of the id list**. Completeness is
134
+ * `nativeReadFailed`'s job in {@link rollUpOneEpic} — checking only this one
135
+ * is what let three readable ids stand in for 58 (Story #5210).
136
+ *
137
+ * @param {{ epicId: number, childIds: number[], provider: object }} opts
138
+ * @returns {Promise<object[]|null>}
139
+ */
140
+ async function readChildren({ epicId, childIds, provider }) {
141
+ const children = [];
142
+ for (const childId of childIds) {
143
+ let child;
144
+ try {
145
+ child = await provider.getTicket(childId);
146
+ } catch (err) {
147
+ Logger.warn(
148
+ `[epic-rollup] Epic #${epicId}: could not read child #${childId} ` +
149
+ `(${err?.message ?? err}) — leaving the Epic untouched.`,
150
+ );
151
+ return null;
152
+ }
153
+ if (!child) {
154
+ Logger.warn(
155
+ `[epic-rollup] Epic #${epicId}: child #${childId} was not found — ` +
156
+ 'leaving the Epic untouched.',
157
+ );
158
+ return null;
159
+ }
160
+ children.push(child);
161
+ }
162
+ return children;
163
+ }
164
+
165
+ /**
166
+ * Push the derived column onto the Epic's board item.
167
+ *
168
+ * @param {{ epicId: number, column: string|null, columnSync: object }} opts
169
+ * @returns {Promise<{ column: string|null, detail: string|null }>}
170
+ */
171
+ async function applyColumn({ epicId, column, columnSync }) {
172
+ if (!column || !columnSync) return { column: null, detail: null };
173
+ try {
174
+ const result = await columnSync.setColumn(epicId, column);
175
+ if (result?.status === 'synced') return { column, detail: null };
176
+ return { column: null, detail: result?.reason ?? 'column-not-synced' };
177
+ } catch (err) {
178
+ return { column: null, detail: String(err?.message ?? err) };
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Record the operator as the Epic's owner, additively.
184
+ *
185
+ * The additive assignees mutation is the only one that cannot evict a login
186
+ * another run wrote between our read and our write, so it is the only one
187
+ * used here — and the reason the assignee is never removed when the Epic
188
+ * closes. A closed container naming who delivered it is useful; a removal
189
+ * would need the replacing endpoint and would race every concurrent run.
190
+ *
191
+ * @param {{ epicId: number, epic: object, owner: string|null, provider: object }} opts
192
+ * @returns {Promise<{ assigned: boolean, detail: string|null }>}
193
+ */
194
+ async function applyOwner({ epicId, epic, owner, provider }) {
195
+ if (!owner) return { assigned: false, detail: 'no-operator-handle' };
196
+ if (normalizeAssignees(epic?.assignees).includes(owner)) {
197
+ return { assigned: false, detail: null };
198
+ }
199
+ try {
200
+ await provider.updateTicket(epicId, { addAssignees: [owner] });
201
+ return { assigned: true, detail: null };
202
+ } catch (err) {
203
+ return { assigned: false, detail: String(err?.message ?? err) };
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Close a container whose children have all landed.
209
+ *
210
+ * @param {{ epicId: number, provider: object }} opts
211
+ * @returns {Promise<{ closed: boolean, detail: string|null }>}
212
+ */
213
+ async function applyClosure({ epicId, provider }) {
214
+ try {
215
+ await provider.updateTicket(epicId, {
216
+ state: 'closed',
217
+ state_reason: 'completed',
218
+ });
219
+ Logger.info(
220
+ `[epic-rollup] Closed container Epic #${epicId} — every child Story landed.`,
221
+ );
222
+ return { closed: true, detail: null };
223
+ } catch (err) {
224
+ return { closed: false, detail: String(err?.message ?? err) };
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Roll one Epic up from the children it lists.
230
+ *
231
+ * `nativeReadFailed` splits the writes by reversibility. Column and assignee
232
+ * are recomputed from scratch on every later tick, so applying them to a
233
+ * possibly-truncated list costs at most a stale board cell that self-corrects.
234
+ * Closure does not: it is the one write no subsequent tick undoes (invariant 2
235
+ * — a reopened child pulls Status back but MUST NOT reopen the issue), so it
236
+ * requires a child list we know to be complete.
237
+ *
238
+ * @param {{ epic: object, childIds: number[], nativeReadFailed?: boolean, provider: object, columnSync: object, owner: string|null }} opts
239
+ * @returns {Promise<object>} Per-Epic outcome record.
240
+ */
241
+ async function rollUpOneEpic({
242
+ epic,
243
+ childIds,
244
+ nativeReadFailed = false,
245
+ provider,
246
+ columnSync,
247
+ owner,
248
+ }) {
249
+ const epicId = Number(epic?.number ?? epic?.id);
250
+ const outcome = {
251
+ epicId,
252
+ column: null,
253
+ assigned: false,
254
+ closed: false,
255
+ pending: false,
256
+ detail: null,
257
+ };
258
+
259
+ const children = await readChildren({ epicId, childIds, provider });
260
+ if (children === null) {
261
+ outcome.pending = true;
262
+ outcome.detail = 'child-read-failed';
263
+ return outcome;
264
+ }
265
+
266
+ const derived = deriveParentState(children);
267
+ const applied = await applyColumn({
268
+ epicId,
269
+ column: derived ? (LABEL_TO_COLUMN[derived] ?? null) : null,
270
+ columnSync,
271
+ });
272
+ outcome.column = applied.column;
273
+ if (applied.detail) outcome.detail = applied.detail;
274
+
275
+ if (IN_FLIGHT_STATES.has(derived)) {
276
+ const ownership = await applyOwner({ epicId, epic, owner, provider });
277
+ outcome.assigned = ownership.assigned;
278
+ if (ownership.detail) outcome.detail = ownership.detail;
279
+ }
280
+
281
+ if (derived !== AGENT_LABELS.DONE) {
282
+ // Not every child has landed. Reported pending only when the Epic is
283
+ // still open — a closed container with an outstanding child is the
284
+ // reopened-child case, whose Status we just corrected and whose issue
285
+ // state is deliberately left alone.
286
+ outcome.pending = !isClosed(epic);
287
+ return outcome;
288
+ }
289
+
290
+ if (isClosed(epic)) return outcome;
291
+
292
+ if (nativeReadFailed) {
293
+ // Every child we could see has landed — but the authoritative read threw,
294
+ // so "every child" is exactly the claim we cannot make. `readChildren`
295
+ // above validates that the ids we were handed are *readable*; nothing
296
+ // there validates that the list is *complete*, which is how an Epic with
297
+ // 23 open children closed off the three its body happened to spell in the
298
+ // bare `- [ ] #N` form (Story #5210). Overwrites any column/owner detail
299
+ // deliberately: this is the reason the Epic is still pending.
300
+ outcome.pending = true;
301
+ outcome.detail = 'child-read-degraded';
302
+ Logger.warn(
303
+ `[epic-rollup] Epic #${epicId}: every child read looks done, but the ` +
304
+ 'native sub-issue read degraded — refusing to close on a possibly ' +
305
+ 'incomplete child list. Re-run once the API read succeeds.',
306
+ );
307
+ return outcome;
308
+ }
309
+
310
+ const closure = await applyClosure({ epicId, provider });
311
+ outcome.closed = closure.closed;
312
+ outcome.pending = !closure.closed;
313
+ if (closure.detail) outcome.detail = closure.detail;
314
+ return outcome;
315
+ }
316
+
317
+ /**
318
+ * Find the open container Epics that list a given Story, with their children.
319
+ *
320
+ * The lookup runs child→parent by scanning open Epics because linkage is
321
+ * parent→child only — a Story body carries no pointer back, and adding one
322
+ * would reverse ADR `20260726-v2-story-collapse`. It is cheap: open
323
+ * containers are few, and only one listing this Story is ever read further.
324
+ *
325
+ * @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
326
+ * @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean }>>}
327
+ */
328
+ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
329
+ let epics;
330
+ try {
331
+ epics = await provider.listIssuesByLabel({
332
+ state: 'open',
333
+ labels: TYPE_LABELS.EPIC,
334
+ });
335
+ } catch (err) {
336
+ Logger.warn(
337
+ `[epic-rollup] Could not list open Epics (${err?.message ?? err}); ` +
338
+ 'skipping the rollup.',
339
+ );
340
+ return [];
341
+ }
342
+
343
+ const matches = [];
344
+ for (const epic of Array.isArray(epics) ? epics : []) {
345
+ if (!isEpicTicket(epic)) continue;
346
+ const epicId = Number(epic?.number ?? epic?.id);
347
+ if (!Number.isInteger(epicId)) continue;
348
+ if (skipEpicIds.has(epicId)) continue;
349
+ // Body checklist UNION native sub-issue edges — the same reader the
350
+ // delivery expansion uses. Reading the body alone here is what made an
351
+ // Epic whose children were linked in the GitHub UI expandable but
352
+ // permanently unclosable.
353
+ const { ids: childIds, nativeReadFailed } = await readEpicChildIdsFrom({
354
+ epic,
355
+ readNativeChildIds: nativeChildReader(provider),
356
+ onWarn: (message) => Logger.warn(message),
357
+ });
358
+ if (!childIds.includes(storyId)) {
359
+ // A degraded read can truncate this Story out of its own container's
360
+ // child list, which drops the Epic from the run entirely rather than
361
+ // rolling it up wrongly. Non-destructive, but silent — say so, since it
362
+ // is the same root cause as the refusal in `rollUpOneEpic`.
363
+ if (nativeReadFailed) {
364
+ Logger.warn(
365
+ `[epic-rollup] Epic #${epicId}: skipped for Story #${storyId} on a ` +
366
+ 'degraded child read — the Story may in fact be linked to it.',
367
+ );
368
+ }
369
+ continue;
370
+ }
371
+ matches.push({ epic, childIds, nativeReadFailed });
372
+ }
373
+ return matches;
374
+ }
375
+
376
+ /**
377
+ * Roll every container Epic listing this Story up from its children.
378
+ *
379
+ * `skipEpicIds` exists for a caller that walks several Stories of one run:
380
+ * siblings share a container, so without it the second Story would re-derive
381
+ * — and re-close — an Epic the first already closed. Live listings filter to
382
+ * open Epics and would eventually hide it, but a caller must not have to rely
383
+ * on a remote read racing its own writes.
384
+ *
385
+ * @param {{
386
+ * storyId: number,
387
+ * provider: object,
388
+ * config?: object,
389
+ * columnSync?: object,
390
+ * owner?: string|null,
391
+ * skipEpicIds?: Iterable<number>,
392
+ * }} opts
393
+ * @returns {Promise<{ epics: object[], closed: number[], pending: number[], reason: string|null }>}
394
+ */
395
+ export async function rollUpEpicForStory({
396
+ storyId,
397
+ provider,
398
+ config,
399
+ columnSync,
400
+ owner,
401
+ skipEpicIds,
402
+ }) {
403
+ const empty = { epics: [], closed: [], pending: [], reason: null };
404
+ const id = Number(storyId);
405
+ if (!Number.isInteger(id) || id <= 0) {
406
+ return { ...empty, reason: 'invalid-story-id' };
407
+ }
408
+ if (
409
+ typeof provider?.listIssuesByLabel !== 'function' ||
410
+ typeof provider?.getTicket !== 'function' ||
411
+ typeof provider?.updateTicket !== 'function'
412
+ ) {
413
+ return { ...empty, reason: 'provider-unsupported' };
414
+ }
415
+
416
+ try {
417
+ const matches = await findEpicsForStory({
418
+ storyId: id,
419
+ provider,
420
+ skipEpicIds: new Set(skipEpicIds ?? []),
421
+ });
422
+ if (matches.length === 0) return { ...empty, reason: 'no-container-epic' };
423
+
424
+ // One ColumnSync across every Epic in the run: it caches the board
425
+ // metadata, so sharing it spends the resolve once instead of per Epic.
426
+ const sync =
427
+ columnSync ??
428
+ (typeof provider.graphql === 'function'
429
+ ? new ColumnSync({ provider, logger: Logger, config })
430
+ : null);
431
+ const resolvedOwner =
432
+ owner === undefined ? resolveEpicOwner(config) : owner;
433
+
434
+ const epics = [];
435
+ for (const { epic, childIds, nativeReadFailed } of matches) {
436
+ epics.push(
437
+ await rollUpOneEpic({
438
+ epic,
439
+ childIds,
440
+ nativeReadFailed,
441
+ provider,
442
+ columnSync: sync,
443
+ owner: resolvedOwner,
444
+ }),
445
+ );
446
+ }
447
+ return {
448
+ epics,
449
+ closed: epics.filter((e) => e.closed).map((e) => e.epicId),
450
+ pending: epics.filter((e) => e.pending).map((e) => e.epicId),
451
+ reason: null,
452
+ };
453
+ } catch (err) {
454
+ // The module-level never-throws contract. Both call sites are lifecycle
455
+ // edges of a Story that is otherwise fine; neither may fail on this.
456
+ const detail = String(err?.message ?? err);
457
+ Logger.warn(`[epic-rollup] Rollup for Story #${id} failed: ${detail}`);
458
+ return { ...empty, reason: detail };
459
+ }
460
+ }