mandrel 2.54.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 +233 -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 +63 -0
  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 +30 -0
  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 +35 -14
  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 +7 -0
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +27 -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
  *
@@ -41,18 +46,21 @@
41
46
  * @see Story #5205
42
47
  * @see Story #5210 — fail closed on a degraded child read.
43
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.
44
51
  */
45
52
 
46
53
  import { Logger } from '../Logger.js';
47
54
  import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
55
+ import { concurrentMap } from '../util/concurrent-map.js';
48
56
  import { ColumnSync, LABEL_TO_COLUMN } from './column-sync.js';
49
57
  import {
50
58
  isEpicTicket,
59
+ nativeChildReader,
51
60
  readEpicChildIdsFrom,
52
- resolveEpicNodeId,
53
61
  } from './epic-container.js';
54
62
  import { resolveOperatorFromCandidates } from './lease-guard-shared.js';
55
- import { deriveParentState } from './ticketing/bulk.js';
63
+ import { anyChildLanded, deriveParentState } from './ticketing/bulk.js';
56
64
 
57
65
  /**
58
66
  * Derived states that mean "children are moving" — the window in which the
@@ -73,31 +81,20 @@ const IN_FLIGHT_STATES = new Set([
73
81
  ]);
74
82
 
75
83
  /**
76
- * Read an Epic's native sub-issue children as issue numbers.
77
- *
78
- * A local adapter rather than an import from `resolve-stories.js`: that
79
- * module is a CLI entrypoint, and reaching up into one from the lib layer to
80
- * borrow four lines would invert the dependency direction for no gain. What
81
- * matters is that the *reader* handed to `readEpicChildIdsFrom` behaves the
82
- * same on both paths, which is what keeps an Epic from being expandable but
83
- * unclosable. The shared `resolveEpicNodeId` is what makes "the same" true of
84
- * the id itself: the Epics reaching this reader come from
85
- * `listIssuesByLabel`, whose raw REST payload carries `node_id`, while the
86
- * expansion path's come from `getTicket`, whose mapped ticket carries
87
- * `nodeId`.
88
- *
89
- * @param {object} provider
90
- * @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.
91
96
  */
92
- function nativeChildReader(provider) {
93
- return async (epic) => {
94
- const nodeId = resolveEpicNodeId(epic);
95
- if (nodeId === null) return [];
96
- return (
97
- provider?._getNativeSubIssues?.(nodeId, epic?.number ?? epic?.id) ?? []
98
- );
99
- };
100
- }
97
+ const FETCH_CONCURRENCY = 5;
101
98
 
102
99
  /**
103
100
  * Resolve the handle the Epic is assigned to while its children run.
@@ -141,44 +138,97 @@ function isClosed(issue) {
141
138
  }
142
139
 
143
140
  /**
144
- * 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.
145
147
  *
146
- * Returns `null` when any child is unreadable. That is the conservative
147
- * answer, not a lazy one: `deriveParentState` reads "all children done" off
148
- * the list it is handed, so a silently dropped child could close a container
149
- * with work still open under it.
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.
168
+ *
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.
150
171
  *
151
172
  * Note the scope: this validates the **readability of the ids it was given**,
152
173
  * never the **completeness of the id list**. Completeness is
153
174
  * `nativeReadFailed`'s job in {@link rollUpOneEpic} — checking only this one
154
175
  * is what let three readable ids stand in for 58 (Story #5210).
155
176
  *
156
- * @param {{ epicId: number, childIds: number[], provider: object }} opts
157
- * @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>}
158
179
  */
159
- 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.
160
205
  const children = [];
161
- for (const childId of childIds) {
162
- let child;
163
- try {
164
- child = await provider.getTicket(childId);
165
- } catch (err) {
206
+ const refused = [];
207
+ for (const { childId, child } of read) {
208
+ if (child === null) {
166
209
  Logger.warn(
167
- `[epic-rollup] Epic #${epicId}: could not read child #${childId} ` +
168
- `(${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.',
169
213
  );
170
- return null;
214
+ continue;
171
215
  }
172
- 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' });
173
222
  Logger.warn(
174
- `[epic-rollup] Epic #${epicId}: child #${childId} was not found — ` +
175
- '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.",
176
226
  );
177
- return null;
227
+ continue;
178
228
  }
179
229
  children.push(child);
180
230
  }
181
- return children;
231
+ return { children, refused };
182
232
  }
183
233
 
184
234
  /**
@@ -224,19 +274,28 @@ async function applyOwner({ epicId, epic, owner, provider }) {
224
274
  }
225
275
 
226
276
  /**
227
- * Close a container whose children have all landed.
277
+ * Close a container whose children are all finished.
228
278
  *
229
- * @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
230
286
  * @returns {Promise<{ closed: boolean, detail: string|null }>}
231
287
  */
232
- async function applyClosure({ epicId, provider }) {
288
+ async function applyClosure({ epicId, landed, provider }) {
233
289
  try {
234
290
  await provider.updateTicket(epicId, {
235
291
  state: 'closed',
236
- state_reason: 'completed',
292
+ state_reason: landed ? 'completed' : 'not_planned',
237
293
  });
238
294
  Logger.info(
239
- `[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.',
240
299
  );
241
300
  return { closed: true, detail: null };
242
301
  } catch (err) {
@@ -254,33 +313,44 @@ async function applyClosure({ epicId, provider }) {
254
313
  * — a reopened child pulls Status back but MUST NOT reopen the issue), so it
255
314
  * requires a child list we know to be complete.
256
315
  *
257
- * @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
258
317
  * @returns {Promise<object>} Per-Epic outcome record.
259
318
  */
260
319
  async function rollUpOneEpic({
261
320
  epic,
262
321
  childIds,
322
+ bodyOnlyIds = [],
263
323
  nativeReadFailed = false,
264
324
  provider,
265
325
  columnSync,
266
326
  owner,
267
327
  }) {
268
- 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);
269
331
  const outcome = {
270
332
  epicId,
271
333
  column: null,
272
334
  assigned: false,
273
335
  closed: false,
274
336
  pending: false,
337
+ refused: [],
275
338
  detail: null,
276
339
  };
277
340
 
278
- const children = await readChildren({ epicId, childIds, provider });
279
- if (children === null) {
341
+ const read = await readChildren({
342
+ epicId,
343
+ childIds,
344
+ bodyOnlyIds,
345
+ provider,
346
+ });
347
+ if (read === null) {
280
348
  outcome.pending = true;
281
349
  outcome.detail = 'child-read-failed';
282
350
  return outcome;
283
351
  }
352
+ const { children, refused } = read;
353
+ outcome.refused = refused;
284
354
 
285
355
  const derived = deriveParentState(children);
286
356
  const applied = await applyColumn({
@@ -326,7 +396,11 @@ async function rollUpOneEpic({
326
396
  return outcome;
327
397
  }
328
398
 
329
- const closure = await applyClosure({ epicId, provider });
399
+ const closure = await applyClosure({
400
+ epicId,
401
+ landed: anyChildLanded(children),
402
+ provider,
403
+ });
330
404
  outcome.closed = closure.closed;
331
405
  outcome.pending = !closure.closed;
332
406
  if (closure.detail) outcome.detail = closure.detail;
@@ -334,47 +408,116 @@ async function rollUpOneEpic({
334
408
  }
335
409
 
336
410
  /**
337
- * 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.
338
412
  *
339
- * The lookup runs child→parent by scanning open Epics because linkage is
340
- * parent→child only — a Story body carries no pointer back, and adding one
341
- * would reverse ADR `20260726-v2-story-collapse`. It is cheap: open
342
- * 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.
343
417
  *
344
- * @param {{ storyId: number, provider: object, skipEpicIds: Set<number> }} opts
345
- * @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.
346
426
  */
347
- 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 [];
348
465
  let epics;
349
466
  try {
350
- epics = await provider.listIssuesByLabel({
351
- state: 'open',
467
+ epics = await provider.listTicketsByLabel({
468
+ state: 'all',
352
469
  labels: TYPE_LABELS.EPIC,
353
470
  });
354
471
  } catch (err) {
355
472
  Logger.warn(
356
- `[epic-rollup] Could not list open Epics (${err?.message ?? err}); ` +
473
+ `[epic-rollup] Could not list Epics (${err?.message ?? err}); ` +
357
474
  'skipping the rollup.',
358
475
  );
359
476
  return [];
360
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;
361
501
 
362
502
  const matches = [];
363
- for (const epic of Array.isArray(epics) ? epics : []) {
364
- if (!isEpicTicket(epic)) continue;
365
- const epicId = Number(epic?.number ?? epic?.id);
503
+ for (const epic of epics) {
504
+ const epicId = Number(epic?.id);
366
505
  if (!Number.isInteger(epicId)) continue;
367
506
  if (skipEpicIds.has(epicId)) continue;
368
507
  // Body checklist UNION native sub-issue edges — the same reader the
369
- // delivery expansion uses. Reading the body alone here is what made an
370
- // Epic whose children were linked in the GitHub UI expandable but
371
- // permanently unclosable.
372
- 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({
373
516
  epic,
374
517
  readNativeChildIds: nativeChildReader(provider),
375
518
  onWarn: (message) => Logger.warn(message),
376
519
  });
377
- if (!childIds.includes(storyId)) {
520
+ if (!authoritative && !childIds.includes(storyId)) {
378
521
  // A degraded read can truncate this Story out of its own container's
379
522
  // child list, which drops the Epic from the run entirely rather than
380
523
  // rolling it up wrongly. Non-destructive, but silent — say so, since it
@@ -387,7 +530,7 @@ async function findEpicsForStory({ storyId, provider, skipEpicIds }) {
387
530
  }
388
531
  continue;
389
532
  }
390
- matches.push({ epic, childIds, nativeReadFailed });
533
+ matches.push({ epic, childIds, nativeReadFailed, bodyOnlyIds });
391
534
  }
392
535
  return matches;
393
536
  }
@@ -424,10 +567,15 @@ export async function rollUpEpicForStory({
424
567
  if (!Number.isInteger(id) || id <= 0) {
425
568
  return { ...empty, reason: 'invalid-story-id' };
426
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.
427
574
  if (
428
- typeof provider?.listIssuesByLabel !== 'function' ||
429
575
  typeof provider?.getTicket !== 'function' ||
430
- typeof provider?.updateTicket !== 'function'
576
+ typeof provider?.updateTicket !== 'function' ||
577
+ (typeof provider?.getParentIssue !== 'function' &&
578
+ typeof provider?.listTicketsByLabel !== 'function')
431
579
  ) {
432
580
  return { ...empty, reason: 'provider-unsupported' };
433
581
  }
@@ -451,11 +599,12 @@ export async function rollUpEpicForStory({
451
599
  owner === undefined ? resolveEpicOwner(config) : owner;
452
600
 
453
601
  const epics = [];
454
- for (const { epic, childIds, nativeReadFailed } of matches) {
602
+ for (const { epic, childIds, nativeReadFailed, bodyOnlyIds } of matches) {
455
603
  epics.push(
456
604
  await rollUpOneEpic({
457
605
  epic,
458
606
  childIds,
607
+ bodyOnlyIds,
459
608
  nativeReadFailed,
460
609
  provider,
461
610
  columnSync: sync,