mandrel 2.53.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -129,14 +129,64 @@ export function isEpicTicket(issue) {
129
129
  * really had. Callers skip the read on a `null` instead, the same clean
130
130
  * no-op `providers/github/board-add.js` makes with `reason: 'no-node-id'`.
131
131
  *
132
+ * Module-private since the reader that consumes it moved here: `nativeChildReader`
133
+ * below is the only production caller, and exporting a helper nothing outside
134
+ * imports fails the production dead-export gate. Its behaviour is pinned
135
+ * through that reader.
136
+ *
132
137
  * @param {{ nodeId?: unknown, node_id?: unknown }} epic
133
138
  * @returns {string|null}
134
139
  */
135
- export function resolveEpicNodeId(epic) {
140
+ function resolveEpicNodeId(epic) {
136
141
  const nodeId = epic?.nodeId ?? epic?.node_id;
137
142
  return typeof nodeId === 'string' && nodeId !== '' ? nodeId : null;
138
143
  }
139
144
 
145
+ /**
146
+ * Read an Epic's native sub-issue children as issue numbers.
147
+ *
148
+ * The **one** definition, injected into `readEpicChildIdsFrom` by both the
149
+ * delivery expansion (`resolve-stories.js`) and the rollup
150
+ * (`epic-rollup.js`). It lived in each of them as a private copy, and the two
151
+ * copies are exactly the pair that must not drift: if the expansion sees a
152
+ * child the rollup does not, an Epic becomes expandable but permanently
153
+ * unclosable — the Story #5210 failure, arrived at from the other direction.
154
+ * Sharing the reader makes that class of divergence unrepresentable.
155
+ *
156
+ * It lives *here*, in the module that already describes what a container Epic
157
+ * is, rather than in either consumer: `resolve-stories.js` is a CLI entrypoint
158
+ * and importing one from the lib layer would invert the dependency direction.
159
+ * The provider is a parameter, so this module stays provider-agnostic.
160
+ *
161
+ * `resolveEpicNodeId` is what makes the two callers agree on the *id* as well
162
+ * as the reader: an Epic reached through a mapped read carries `nodeId`, one
163
+ * read raw from REST carries `node_id`, and neither caller can tell from the
164
+ * value it holds. A missing id yields `[]` rather than an `undefined` reaching
165
+ * GraphQL as a rejected `ID!`.
166
+ *
167
+ * @param {object} provider
168
+ * @returns {(epic: object) => Promise<number[]>}
169
+ */
170
+ export function nativeChildReader(provider) {
171
+ return async (epic) => {
172
+ const nodeId = resolveEpicNodeId(epic);
173
+ if (nodeId === null) return [];
174
+ // The declared port first, the legacy private alias second: both forward
175
+ // to the same gateway on the live provider, and the fallback is what keeps
176
+ // test doubles written against the older name working.
177
+ const read =
178
+ provider?.getNativeSubIssues ?? provider?._getNativeSubIssues ?? null;
179
+ if (typeof read !== 'function') return [];
180
+ // Diagnostics-only second argument, and the one place the two shapes are
181
+ // still read together on purpose: this module is the declared bridge
182
+ // between them (see `resolveEpicNodeId` above), and the expression is
183
+ // correct under both — a raw REST issue names the issue number `number`,
184
+ // a mapped ticket names it `id`. Every *consumer* module now receives one
185
+ // declared shape and reads the field directly.
186
+ return (await read.call(provider, nodeId, epic?.number ?? epic?.id)) ?? [];
187
+ };
188
+ }
189
+
140
190
  /**
141
191
  * Render a container Epic's body.
142
192
  *
@@ -230,12 +280,22 @@ export function readEpicChildIds(body) {
230
280
  * supplied none never asked for authority and is not degraded relative to what
231
281
  * it requested.
232
282
  *
283
+ * **`bodyOnlyIds` names the ids the union owes to the checklist alone.** The
284
+ * two sources are not equally trustworthy about a *single* id: a native edge
285
+ * is a link the backend holds, so an id it returns names a real issue, while a
286
+ * checklist row is hand-editable prose and can cite an issue that was deleted,
287
+ * transferred, or simply mistyped. Callers that must decide what an
288
+ * unresolvable id means need to know which source vouched for it — a native id
289
+ * that will not resolve is a failed read, a body-only one is a typo. Empty
290
+ * when the native read failed or never ran: with no authoritative source to
291
+ * contrast against, nothing is "body-only" in the sense that matters.
292
+ *
233
293
  * @param {{
234
294
  * epic: { number?: number, id?: number, body?: string, nodeId?: string },
235
295
  * readNativeChildIds?: (epic: object) => Promise<number[]>,
236
296
  * onWarn?: (message: string) => void,
237
297
  * }} opts
238
- * @returns {Promise<{ ids: number[], nativeReadFailed: boolean }>}
298
+ * @returns {Promise<{ ids: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>}
239
299
  */
240
300
  export async function readEpicChildIdsFrom({
241
301
  epic,
@@ -244,14 +304,16 @@ export async function readEpicChildIdsFrom({
244
304
  } = {}) {
245
305
  const fromBody = readEpicChildIds(epic?.body);
246
306
  if (typeof readNativeChildIds !== 'function') {
247
- return { ids: fromBody, nativeReadFailed: false };
307
+ return { ids: fromBody, nativeReadFailed: false, bodyOnlyIds: [] };
248
308
  }
249
309
 
250
310
  try {
251
311
  const native = normalizeChildIds(await readNativeChildIds(epic));
312
+ const nativeSet = new Set(native);
252
313
  return {
253
314
  ids: normalizeChildIds([...native, ...fromBody]),
254
315
  nativeReadFailed: false,
316
+ bodyOnlyIds: fromBody.filter((id) => !nativeSet.has(id)),
255
317
  };
256
318
  } catch (err) {
257
319
  onWarn?.(
@@ -259,6 +321,6 @@ export async function readEpicChildIdsFrom({
259
321
  `#${epic?.number ?? epic?.id ?? '?'} (${err?.message ?? String(err)}); ` +
260
322
  'using the body checklist alone — the child list may be incomplete.',
261
323
  );
262
- return { ids: fromBody, nativeReadFailed: true };
324
+ return { ids: fromBody, nativeReadFailed: true, bodyOnlyIds: [] };
263
325
  }
264
326
  }
@@ -10,10 +10,15 @@
10
10
  *
11
11
  * This module is the one place that answers "what state is this Epic in?"
12
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.
13
+ * {@link rollUpEpicForStory} is invoked from **every edge that changes a
14
+ * child's state**: the `agent::executing` flip in `single-story-init.js`, the
15
+ * post-land tail, and the supersede close in `plan-persist`. Holding at N=1
16
+ * was never the hard part — the gap was that a container only re-derived on
17
+ * the edges someone had remembered to wire, so a cohort superseded by a
18
+ * re-plan left its Epic open with no child that would ever move again.
19
+ * Because every trigger is a child state change, the fallback scan reads
20
+ * `state: 'all'`: "did this reopen work under a container I already closed?"
21
+ * is always a live question.
17
22
  *
18
23
  * Three invariants shape the writes:
19
24
  *
@@ -40,24 +45,35 @@
40
45
  * @module lib/orchestration/epic-rollup
41
46
  * @see Story #5205
42
47
  * @see Story #5210 — fail closed on a degraded child read.
48
+ * @see Story #5255 — a closed child contributes no `agent::*` state.
49
+ * @see Story #5280 — every child edge derives; the parent resolves in one
50
+ * call; the reads carry one declared shape.
43
51
  */
44
52
 
45
53
  import { Logger } from '../Logger.js';
46
54
  import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
55
+ import { concurrentMap } from '../util/concurrent-map.js';
47
56
  import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
48
57
  import {
49
58
  isEpicTicket,
59
+ nativeChildReader,
50
60
  readEpicChildIdsFrom,
51
- resolveEpicNodeId,
52
61
  } from './epic-container.js';
53
62
  import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
54
- import { deriveParentState } from './ticketing/bulk.js';
63
+ import { anyChildLanded, deriveParentState } from './ticketing/bulk.js';
55
64
 
56
65
  /**
57
66
  * Derived states that mean "children are moving" — the window in which the
58
67
  * Epic carries an owner. `agent::blocked` counts: a blocked child is still
59
68
  * this operator's problem, and dropping the assignee at the moment someone
60
69
  * needs to be found would invert the signal.
70
+ *
71
+ * "Blocked" here means an **open** blocked child. `deriveParentState` stopped
72
+ * reading closed children's `agent::*` labels in Story #5255 — a superseded
73
+ * Story closed while still wearing `agent::blocked` is not someone's problem
74
+ * to pick up, and the stale label used to derive `agent::blocked` forever,
75
+ * which both held an owner on the container and pinned it open past the
76
+ * `derived !== DONE` bail below.
61
77
  */
62
78
  const IN_FLIGHT_STATES = new Set([
63
79
  AGENT_LABELS.EXECUTING,
@@ -65,31 +81,20 @@ const IN_FLIGHT_STATES = new Set([
65
81
  ]);
66
82
 
67
83
  /**
68
- * Read an Epic's native sub-issue children as issue numbers.
69
- *
70
- * A local adapter rather than an import from `resolve-stories.js`: that
71
- * module is a CLI entrypoint, and reaching up into one from the lib layer to
72
- * borrow four lines would invert the dependency direction for no gain. What
73
- * matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
74
- * same on both paths, which is what keeps an Epic from being expandable but
75
- * unclosable. The shared `resolveEpicNodeId` is what makes "the same" true of
76
- * the id itself: the Epics reaching this reader come from
77
- * `listIssuesByLabel`, whose raw REST payload carries `node_id`, while the
78
- * expansion path's come from `getTicket`, whose mapped ticket carries
79
- * `nodeId`.
80
- *
81
- * @param {object} provider
82
- * @returns {(epic: object) => Promise<number[]>}
84
+ * How many child reads a rollup keeps in flight.
85
+ *
86
+ * Matches the cap `resolve-stories.js` uses for the same shape of work — a
87
+ * fan-out of independent single-issue GETs against one repo — because the
88
+ * constraint being respected is GitHub's, not this module's: enough overlap to
89
+ * collapse a 58-child Epic from 58 sequential round-trips, well under the
90
+ * burst threshold that earns a secondary rate limit. The children of one Epic
91
+ * are order-independent, so the serial loop this replaces was paying
92
+ * `sum(round-trips)` for nothing.
93
+ *
94
+ * Deliberately not exported. Nothing outside this module reads the number, and
95
+ * an export only tests import fails the production dead-export gate.
83
96
  */
84
- function nativeChildReader(provider) {
85
- return async (epic) => {
86
- const nodeId = resolveEpicNodeId(epic);
87
- if (nodeId === null) return [];
88
- return (
89
- provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
90
- );
91
- };
92
- }
97
+ const FETCH_CONCURRENCY = 5;
93
98
 
94
99
  /**
95
100
  * Resolve the handle the Epic is assigned to while its children run.
@@ -133,44 +138,97 @@ function isClosed(issue) {
133
138
  }
134
139
 
135
140
  /**
136
- * Read every child of one Epic, freshly.
141
+ * Fetch one child, or say why it does not count as one.
142
+ *
143
+ * Throws on a genuine read failure so the bounded fan-out around it rejects on
144
+ * the first — the conservative answer, not a lazy one: `deriveParentState`
145
+ * reads "all children done" off the list it is handed, so a silently dropped
146
+ * child could close a container with work still open under it.
147
+ *
148
+ * The one id it declines to fail on is a **body-only** one that resolves to
149
+ * nothing. The checklist is hand-editable prose, so a row can cite an issue
150
+ * that was deleted, transferred or mistyped, and no re-run will ever make it
151
+ * resolve — treating that as a failed read pins the container `pending`
152
+ * forever over a typo. A *native* id that will not resolve keeps failing the
153
+ * batch: the backend vouched for that edge, so its absence is a real read
154
+ * problem and next tick may well answer.
155
+ *
156
+ * @param {{ childId: number, droppable: boolean, provider: object }} opts
157
+ * @returns {Promise<{ childId: number, child: object|null }>}
158
+ */
159
+ async function readOneChild({ childId, droppable, provider }) {
160
+ const child = await provider.getTicket(childId);
161
+ if (child) return { childId, child };
162
+ if (droppable) return { childId, child: null };
163
+ throw new Error(`child #${childId} was not found`);
164
+ }
165
+
166
+ /**
167
+ * Read every child of one Epic, freshly, bounded.
137
168
  *
138
- * Returns `null` when any child is unreadable. That is the conservative
139
- * answer, not a lazy one: `deriveParentState` reads "all children done" off
140
- * the list it is handed, so a silently dropped child could close a container
141
- * with work still open under it.
169
+ * Returns `null` when any child the Epic genuinely claims is unreadable, and
170
+ * otherwise the children that count plus the ones refused by name.
142
171
  *
143
172
  * Note the scope: this validates the **readability of the ids it was given**,
144
173
  * never the **completeness of the id list**. Completeness is
145
174
  * `nativeReadFailed`'s job in {@link rollUpOneEpic} — checking only this one
146
175
  * is what let three readable ids stand in for 58 (Story #5210).
147
176
  *
148
- * @param {{ epicId: number, childIds: number[], provider: object }} opts
149
- * @returns {Promise<object[]|null>}
177
+ * @param {{ epicId: number, childIds: number[], bodyOnlyIds?: number[], provider: object }} opts
178
+ * @returns {Promise<{ children: object[], refused: Array<{ childId: number, reason: string }> }|null>}
150
179
  */
151
- async function readChildren({ epicId, childIds, provider }) {
180
+ async function readChildren({ epicId, childIds, bodyOnlyIds = [], provider }) {
181
+ const droppable = new Set(bodyOnlyIds);
182
+ let read;
183
+ try {
184
+ read = await concurrentMap(
185
+ childIds,
186
+ (childId) =>
187
+ readOneChild({
188
+ childId,
189
+ droppable: droppable.has(childId),
190
+ provider,
191
+ }),
192
+ { concurrency: FETCH_CONCURRENCY },
193
+ );
194
+ } catch (err) {
195
+ Logger.warn(
196
+ `[epic-rollup] Epic #${epicId}: could not read every child ` +
197
+ `(${err?.message ?? err}) — leaving the Epic untouched.`,
198
+ );
199
+ return null;
200
+ }
201
+
202
+ // Classified after the fan-out, in input order, so the warnings an operator
203
+ // reads and the `refused` list a caller reports do not depend on which
204
+ // round-trip happened to finish first.
152
205
  const children = [];
153
- for (const childId of childIds) {
154
- let child;
155
- try {
156
- child = await provider.getTicket(childId);
157
- } catch (err) {
206
+ const refused = [];
207
+ for (const { childId, child } of read) {
208
+ if (child === null) {
158
209
  Logger.warn(
159
- `[epic-rollup] Epic #${epicId}: could not read child #${childId} ` +
160
- `(${err?.message ?? err}) — leaving the Epic untouched.`,
210
+ `[epic-rollup] Epic #${epicId}: checklist row cites #${childId}, which ` +
211
+ 'resolves to no issue and is not a native sub-issue edge — dropping ' +
212
+ 'it from the child set. Fix or remove the row.',
161
213
  );
162
- return null;
214
+ continue;
163
215
  }
164
- if (!child) {
216
+ if (isEpicTicket(child)) {
217
+ // A container under a container. It has no `agent::*` label by
218
+ // construction, so `deriveParentState` would read it as neither done nor
219
+ // in flight and stall the parent on a child that is itself derived.
220
+ // Nesting containers is out of scope entirely; say so and move on.
221
+ refused.push({ childId, reason: 'epic-typed-child' });
165
222
  Logger.warn(
166
- `[epic-rollup] Epic #${epicId}: child #${childId} was not found — ` +
167
- 'leaving the Epic untouched.',
223
+ `[epic-rollup] Epic #${epicId}: child #${childId} is itself a container ` +
224
+ 'Epic — refused (epic-typed-child). Nested containers derive no ' +
225
+ "state, so it neither blocks nor advances this Epic's.",
168
226
  );
169
- return null;
227
+ continue;
170
228
  }
171
229
  children.push(child);
172
230
  }
173
- return children;
231
+ return { children, refused };
174
232
  }
175
233
 
176
234
  /**
@@ -216,19 +274,28 @@ async function applyOwner({ epicId, epic, owner, provider }) {
216
274
  }
217
275
 
218
276
  /**
219
- * Close a container whose children have all landed.
277
+ * Close a container whose children are all finished.
220
278
  *
221
- * @param {{ epicId: number, provider: object }} opts
279
+ * `landed` picks the reason and the sentence, and the two must agree. A cohort
280
+ * re-planned out of existence closes every child as superseded — finished, so
281
+ * the container is finished too, but nothing merged. Reporting that as
282
+ * `completed` over "every child Story landed" is a false claim in the one
283
+ * place an operator goes to find out what a run actually delivered.
284
+ *
285
+ * @param {{ epicId: number, landed: boolean, provider: object }} opts
222
286
  * @returns {Promise<{ closed: boolean, detail: string|null }>}
223
287
  */
224
- async function applyClosure({ epicId, provider }) {
288
+ async function applyClosure({ epicId, landed, provider }) {
225
289
  try {
226
290
  await provider.updateTicket(epicId, {
227
291
  state: 'closed',
228
- state_reason: 'completed',
292
+ state_reason: landed ? 'completed' : 'not_planned',
229
293
  });
230
294
  Logger.info(
231
- `[epic-rollup] Closed container Epic #${epicId} — every child Story landed.`,
295
+ landed
296
+ ? `[epic-rollup] Closed container Epic #${epicId} — every child Story landed.`
297
+ : `[epic-rollup] Closed container Epic #${epicId} as not_planned — every ` +
298
+ 'child is closed and none landed.',
232
299
  );
233
300
  return { closed: true, detail: null };
234
301
  } catch (err) {
@@ -246,33 +313,44 @@ async function applyClosure({ epicId, provider }) {
246
313
  * — a reopened child pulls Status back but MUST NOT reopen the issue), so it
247
314
  * requires a child list we know to be complete.
248
315
  *
249
- * @param {{ epic: object, childIds: number[], nativeReadFailed?: boolean, provider: object, columnSync: object, owner: string|null }} opts
316
+ * @param {{ epic: object, childIds: number[], bodyOnlyIds?: number[], nativeReadFailed?: boolean, provider: object, columnSync: object, owner: string|null }} opts
250
317
  * @returns {Promise<object>} Per-Epic outcome record.
251
318
  */
252
319
  async function rollUpOneEpic({
253
320
  epic,
254
321
  childIds,
322
+ bodyOnlyIds = [],
255
323
  nativeReadFailed = false,
256
324
  provider,
257
325
  columnSync,
258
326
  owner,
259
327
  }) {
260
- const epicId = Number(epic?.number ?? epic?.id);
328
+ // `epic.id` and nothing else: every read that reaches here now returns the
329
+ // declared ticket shape, in which `id` IS the issue number.
330
+ const epicId = Number(epic?.id);
261
331
  const outcome = {
262
332
  epicId,
263
333
  column: null,
264
334
  assigned: false,
265
335
  closed: false,
266
336
  pending: false,
337
+ refused: [],
267
338
  detail: null,
268
339
  };
269
340
 
270
- const children = await readChildren({ epicId, childIds, provider });
271
- if (children === null) {
341
+ const read = await readChildren({
342
+ epicId,
343
+ childIds,
344
+ bodyOnlyIds,
345
+ provider,
346
+ });
347
+ if (read === null) {
272
348
  outcome.pending = true;
273
349
  outcome.detail = 'child-read-failed';
274
350
  return outcome;
275
351
  }
352
+ const { children, refused } = read;
353
+ outcome.refused = refused;
276
354
 
277
355
  const derived = deriveParentState(children);
278
356
  const applied = await applyColumn({
@@ -318,7 +396,11 @@ async function rollUpOneEpic({
318
396
  return outcome;
319
397
  }
320
398
 
321
- const closure = await applyClosure({ epicId, provider });
399
+ const closure = await applyClosure({
400
+ epicId,
401
+ landed: anyChildLanded(children),
402
+ provider,
403
+ });
322
404
  outcome.closed = closure.closed;
323
405
  outcome.pending = !closure.closed;
324
406
  if (closure.detail) outcome.detail = closure.detail;
@@ -326,47 +408,116 @@ async function rollUpOneEpic({
326
408
  }
327
409
 
328
410
  /**
329
- * Find the open container Epics that list a given Story, with their children.
411
+ * Resolve this Story's container in **one** request, via the native edge.
330
412
  *
331
- * The lookup runs child→parent by scanning open Epics because linkage is
332
- * parent→child only — a Story body carries no pointer back, and adding one
333
- * would reverse ADR `20260726-v2-story-collapse`. It is cheap: open
334
- * containers are few, and only one listing this Story is ever read further.
413
+ * `getParentIssue` reads the sub-issue link backwards, which is the question
414
+ * this module actually asks. The scan below is what it replaces: listing every
415
+ * candidate Epic and reading each one's children until one of them mentions
416
+ * the Story — O(containers) requests, all but one of them discarded.
335
417
  *
336
- * @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
337
- * @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean }>>}
418
+ * Returns `null` — never throws — when the provider has no such port, when the
419
+ * Story has no parent, or when the parent is not a container Epic. Each of
420
+ * those is "no answer *here*", and the caller's checklist scan is the other
421
+ * half: linkage can legitimately exist as a body row with no native edge
422
+ * behind it.
423
+ *
424
+ * @param {{ storyId: number, provider: object }} opts
425
+ * @returns {Promise<object|null>} Mapped parent Epic, or null.
338
426
  */
339
- async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
427
+ async function parentEpicFor({ storyId, provider }) {
428
+ if (typeof provider?.getParentIssue !== 'function') return null;
429
+ let parent;
430
+ try {
431
+ parent = await provider.getParentIssue(storyId);
432
+ } catch (err) {
433
+ Logger.warn(
434
+ `[epic-rollup] Parent lookup for Story #${storyId} degraded ` +
435
+ `(${err?.message ?? err}); falling back to the label scan.`,
436
+ );
437
+ return null;
438
+ }
439
+ if (!parent || !isEpicTicket(parent)) return null;
440
+ return parent;
441
+ }
442
+
443
+ /**
444
+ * Every container Epic on the board, as the fallback for a Story whose parent
445
+ * edge the native read could not answer.
446
+ *
447
+ * **`state: 'all'`, deliberately.** The scan used to be `state: 'open'`, which
448
+ * made two of this module's documented behaviours unreachable: a closed
449
+ * container was never listed, so a *reopened* child could never pull its
450
+ * Status back to `In Progress`, and the `isClosed(epic)` branches downstream
451
+ * were dead code asserting a rule nothing could exercise. Every edge that
452
+ * invokes this rollup is a child's own state change — init, post-land, the
453
+ * supersede close, the epilogue — so "did this change reopen work under a
454
+ * container I already closed?" is always a live question, and an open-only
455
+ * listing answers it wrongly rather than partially.
456
+ *
457
+ * Closing stays one-way regardless: {@link rollUpOneEpic} corrects a closed
458
+ * Epic's Status and never writes its issue state.
459
+ *
460
+ * @param {{ provider: object }} opts
461
+ * @returns {Promise<object[]>}
462
+ */
463
+ async function scanContainerEpics({ provider }) {
464
+ if (typeof provider?.listTicketsByLabel !== 'function') return [];
340
465
  let epics;
341
466
  try {
342
- epics = await provider.listIssuesByLabel({
343
- state: 'open',
467
+ epics = await provider.listTicketsByLabel({
468
+ state: 'all',
344
469
  labels: TYPE_LABELS.EPIC,
345
470
  });
346
471
  } catch (err) {
347
472
  Logger.warn(
348
- `[epic-rollup] Could not list open Epics (${err?.message ?? err}); ` +
473
+ `[epic-rollup] Could not list Epics (${err?.message ?? err}); ` +
349
474
  'skipping the rollup.',
350
475
  );
351
476
  return [];
352
477
  }
478
+ return (Array.isArray(epics) ? epics : []).filter(isEpicTicket);
479
+ }
480
+
481
+ /**
482
+ * Find the container Epics that hold a given Story, with their children.
483
+ *
484
+ * The lookup runs child→parent because linkage is parent→child only — a Story
485
+ * body carries no pointer back, and adding one would reverse ADR
486
+ * `20260726-v2-story-collapse`. It resolves the native edge first and scans
487
+ * only when that answers nothing.
488
+ *
489
+ * The two paths differ in one thing: an Epic the *native* read named is this
490
+ * Story's parent by construction, so its child list is read to derive state,
491
+ * not to confirm the link. A *scanned* Epic still has to prove it lists the
492
+ * Story.
493
+ *
494
+ * @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
495
+ * @returns {Promise<Array<{ epic: object, childIds: number[], nativeReadFailed: boolean, bodyOnlyIds: number[] }>>}
496
+ */
497
+ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
498
+ const parent = await parentEpicFor({ storyId, provider });
499
+ const epics = parent ? [parent] : await scanContainerEpics({ provider });
500
+ const authoritative = parent !== null;
353
501
 
354
502
  const matches = [];
355
- for (const epic of Array.isArray(epics) ? epics : []) {
356
- if (!isEpicTicket(epic)) continue;
357
- const epicId = Number(epic?.number ?? epic?.id);
503
+ for (const epic of epics) {
504
+ const epicId = Number(epic?.id);
358
505
  if (!Number.isInteger(epicId)) continue;
359
506
  if (skipEpicIds.has(epicId)) continue;
360
507
  // Body checklist UNION native sub-issue edges — the same reader the
361
- // delivery expansion uses. Reading the body alone here is what made an
362
- // Epic whose children were linked in the GitHub UI expandable but
363
- // permanently unclosable.
364
- const { ids: childIds, nativeReadFailed } = await readEpicChildIdsFrom({
508
+ // delivery expansion uses, now literally the same function. Reading the
509
+ // body alone here is what made an Epic whose children were linked in the
510
+ // GitHub UI expandable but permanently unclosable.
511
+ const {
512
+ ids: childIds,
513
+ nativeReadFailed,
514
+ bodyOnlyIds,
515
+ } = await readEpicChildIdsFrom({
365
516
  epic,
366
517
  readNativeChildIds: nativeChildReader(provider),
367
518
  onWarn: (message) => Logger.warn(message),
368
519
  });
369
- if (!childIds.includes(storyId)) {
520
+ if (!authoritative && !childIds.includes(storyId)) {
370
521
  // A degraded read can truncate this Story out of its own container's
371
522
  // child list, which drops the Epic from the run entirely rather than
372
523
  // rolling it up wrongly. Non-destructive, but silent — say so, since it
@@ -379,7 +530,7 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
379
530
  }
380
531
  continue;
381
532
  }
382
- matches.push({ epic, childIds, nativeReadFailed });
533
+ matches.push({ epic, childIds, nativeReadFailed, bodyOnlyIds });
383
534
  }
384
535
  return matches;
385
536
  }
@@ -416,10 +567,15 @@ export async function rollUpEpicForStory({
416
567
  if (!Number.isInteger(id) || id <= 0) {
417
568
  return { ...empty, reason: 'invalid-story-id' };
418
569
  }
570
+ // One of the two lookup ports is enough: `getParentIssue` answers directly
571
+ // and `listTicketsByLabel` answers by scanning, and `findEpicsForStory`
572
+ // degrades from either to the other. Both absent means no way to reach a
573
+ // container at all.
419
574
  if (
420
- typeof provider?.listIssuesByLabel !== 'function' ||
421
575
  typeof provider?.getTicket !== 'function' ||
422
- typeof provider?.updateTicket !== 'function'
576
+ typeof provider?.updateTicket !== 'function' ||
577
+ (typeof provider?.getParentIssue !== 'function' &&
578
+ typeof provider?.listTicketsByLabel !== 'function')
423
579
  ) {
424
580
  return { ...empty, reason: 'provider-unsupported' };
425
581
  }
@@ -443,11 +599,12 @@ export async function rollUpEpicForStory({
443
599
  owner === undefined ? resolveEpicOwner(config) : owner;
444
600
 
445
601
  const epics = [];
446
- for (const { epic, childIds, nativeReadFailed } of matches) {
602
+ for (const { epic, childIds, nativeReadFailed, bodyOnlyIds } of matches) {
447
603
  epics.push(
448
604
  await rollUpOneEpic({
449
605
  epic,
450
606
  childIds,
607
+ bodyOnlyIds,
451
608
  nativeReadFailed,
452
609
  provider,
453
610
  columnSync: sync,