mandrel 2.54.0 → 2.56.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 (134) 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 +8 -2
  5. package/.agents/docs/configuration.md +5 -0
  6. package/.agents/rules/ci-remediation.md +39 -21
  7. package/.agents/schemas/agentrc.schema.json +34 -1
  8. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  9. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  10. package/.agents/scripts/audit-to-stories.js +374 -76
  11. package/.agents/scripts/check-audit-attribution.js +119 -62
  12. package/.agents/scripts/check-test-portability.js +512 -0
  13. package/.agents/scripts/coverage-capture.js +17 -10
  14. package/.agents/scripts/evidence-gate.js +31 -4
  15. package/.agents/scripts/file-ci-gap.js +306 -0
  16. package/.agents/scripts/generate-workflows-doc.js +65 -14
  17. package/.agents/scripts/git-cleanup.js +4 -0
  18. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  19. package/.agents/scripts/lib/audit-advisories.js +195 -0
  20. package/.agents/scripts/lib/audit-attribution.js +22 -0
  21. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +80 -29
  23. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  24. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  25. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  26. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  27. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +61 -115
  28. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  29. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  30. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  32. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  33. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  34. package/.agents/scripts/lib/cli-args.js +26 -0
  35. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  36. package/.agents/scripts/lib/close-validation/process.js +7 -3
  37. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  38. package/.agents/scripts/lib/config/ci.js +28 -9
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  40. package/.agents/scripts/lib/config-settings-schema.js +52 -1
  41. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  42. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  43. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  44. package/.agents/scripts/lib/coverage-capture.js +77 -3
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +42 -2
  50. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  51. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  52. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  53. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  54. package/.agents/scripts/lib/label-constants.js +6 -1
  55. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  56. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  57. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  58. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  59. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  60. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  61. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  62. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  63. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  64. package/.agents/scripts/lib/orchestration/epic-rollup.js +233 -84
  65. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  66. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  67. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  68. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  69. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  70. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  71. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  72. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  73. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  74. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  75. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  76. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +39 -3
  77. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  78. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  79. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +63 -0
  80. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  81. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  82. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  83. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  84. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  85. package/.agents/scripts/lib/orchestration/run-epilogue.js +63 -42
  86. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  92. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  93. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  94. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  95. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  96. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  97. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +30 -0
  98. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  99. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  100. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  102. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  103. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  104. package/.agents/scripts/lib/test-temp.js +167 -30
  105. package/.agents/scripts/lib/validation-evidence.js +37 -0
  106. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  107. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  108. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  109. package/.agents/scripts/merge-baseline.js +175 -21
  110. package/.agents/scripts/pr-watch-with-update.js +3 -2
  111. package/.agents/scripts/providers/github/errors.js +22 -1
  112. package/.agents/scripts/providers/github/issues.js +106 -1
  113. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  114. package/.agents/scripts/providers/github.js +6 -0
  115. package/.agents/scripts/resolve-stories.js +44 -34
  116. package/.agents/scripts/single-story-close.js +5 -0
  117. package/.agents/scripts/stories-wave-tick.js +37 -13
  118. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  119. package/.agents/workflows/audit-accessibility.md +16 -31
  120. package/.agents/workflows/audit-mobile.md +20 -37
  121. package/.agents/workflows/audit-to-stories.md +63 -27
  122. package/.agents/workflows/git-cleanup.md +17 -3
  123. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  124. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  125. package/.agents/workflows/helpers/deliver-reference.md +35 -14
  126. package/.agents/workflows/helpers/deliver-story-reference.md +26 -8
  127. package/.agents/workflows/helpers/deliver-story.md +15 -12
  128. package/.agents/workflows/helpers/plan-reference.md +30 -0
  129. package/.agents/workflows/mandrel-plan.md +10 -13
  130. package/.agents/workflows/memory-consolidate.md +14 -9
  131. package/docs/CHANGELOG.md +37 -0
  132. package/lib/cli/registry.js +64 -21
  133. package/lib/cli/sync.js +27 -2
  134. package/package.json +7 -4
@@ -13,8 +13,12 @@
13
13
  *
14
14
  * The strand shapes the table resolves, and why each is real:
15
15
  *
16
- * - `executing` with no PR → resume implementation. The work never reached
17
- * close.
16
+ * - `executing`, branch UNPUSHED, no PR → resume implementation. The work
17
+ * never reached close. Since Story #5267 the worker pushes before its
18
+ * creditable capture, so an unpushed branch means this and nothing else.
19
+ * - `executing`, branch PUSHED, no PR → run close. The worker's hand-off
20
+ * landed; only the close-and-land tail is owed, and re-initializing here
21
+ * would re-open finished work.
18
22
  * - `closing` with a pending PR → resume the land. The overwhelmingly
19
23
  * common shape now that the merge wait is bounded: the wait returned
20
24
  * `pending` and something has to pick it back up.
@@ -315,6 +319,80 @@ function closeInFlightVerdict({ storyId, artifacts, evidence }) {
315
319
  };
316
320
  }
317
321
 
322
+ /**
323
+ * The `agent::executing` rows of the table (Story #4543; split on push state
324
+ * by Story #5267).
325
+ *
326
+ * Lifted out of {@link decideRecovery} because this label alone fans out into
327
+ * five distinct strands, and because the push-state split below only reads
328
+ * correctly next to the artifact probes it is ordered after.
329
+ *
330
+ * @param {{ storyId: number, branch: object, pr: object|null, closeArtifacts?: object, evidence: string[] }} args
331
+ * @returns {{ shape: string, nextCommand: string|null, detail: string, evidence: string[] }}
332
+ */
333
+ function decideExecuting({ storyId, branch, pr, closeArtifacts, evidence }) {
334
+ if (pr?.number) {
335
+ return {
336
+ shape: 'executing-with-pr',
337
+ nextCommand: NEXT_COMMANDS.close(storyId),
338
+ detail:
339
+ `PR #${pr.number} exists but the Story is still \`agent::executing\` — the close ` +
340
+ `opened the PR and then died before the label flip. Re-run close; it reuses the ` +
341
+ `open PR rather than opening a duplicate.`,
342
+ evidence,
343
+ };
344
+ }
345
+ // Story #4816 — the close artifacts get the first word here, and ONLY
346
+ // here. Every other row of this table describes a state whose evidence is
347
+ // already unambiguous; `executing` + no PR is the one row that reads
348
+ // identically for a dead implementation and for a close that is halfway
349
+ // through its gate chain, and answering it from labels alone is what sent
350
+ // operators to re-init on top of a live close.
351
+ if (closeLooksLive(closeArtifacts)) {
352
+ return closeInFlightVerdict({
353
+ storyId,
354
+ artifacts: closeArtifacts,
355
+ evidence,
356
+ });
357
+ }
358
+ if (closeArtifacts?.envelope) {
359
+ return envelopeOnDiskVerdict({
360
+ storyId,
361
+ artifacts: closeArtifacts,
362
+ evidence,
363
+ });
364
+ }
365
+ // Story #5267 — push state is what separates the two remaining strands, and
366
+ // it separates them cleanly now that the worker pushes BEFORE its creditable
367
+ // capture. Before that ordering, a worker whose turn ended on the
368
+ // backgrounded capture left an unpushed branch that was indistinguishable
369
+ // from work that never got started; now an unpushed branch means exactly one
370
+ // thing, and a pushed one means the hand-off happened and only close is
371
+ // owed.
372
+ if (branch?.remote) {
373
+ return {
374
+ shape: 'executing-pushed-no-pr',
375
+ nextCommand: NEXT_COMMANDS.close(storyId),
376
+ detail:
377
+ `\`story-${storyId}\` is PUSHED to origin but no PR exists and no close left an ` +
378
+ `artifact behind — the worker finished and handed off, and the close never ran (or ` +
379
+ `died before its first gate). Nothing needs re-implementing: run close, which is ` +
380
+ `idempotent. Do NOT re-init — the branch already carries the finished work.`,
381
+ evidence,
382
+ };
383
+ }
384
+ return {
385
+ shape: 'executing-no-pr',
386
+ nextCommand: NEXT_COMMANDS.implement(storyId),
387
+ detail:
388
+ `Story is \`agent::executing\`, \`story-${storyId}\` is UNPUSHED, there is no PR, and ` +
389
+ `no close left an artifact behind (no persisted terminal envelope, no recent gate ` +
390
+ `log) — implementation never finished. Re-init (idempotent — it reuses the existing ` +
391
+ `branch and worktree) and resume in the worktree it prints.`,
392
+ evidence,
393
+ };
394
+ }
395
+
318
396
  /**
319
397
  * The decision table. Pure: every input is an already-observed probe, so the
320
398
  * mapping is testable without git, GitHub, or a clock.
@@ -434,47 +512,7 @@ export function decideRecovery({
434
512
  }
435
513
 
436
514
  if (label === STATE_LABELS.EXECUTING) {
437
- if (pr?.number) {
438
- return {
439
- shape: 'executing-with-pr',
440
- nextCommand: NEXT_COMMANDS.close(storyId),
441
- detail:
442
- `PR #${pr.number} exists but the Story is still \`agent::executing\` — the close ` +
443
- `opened the PR and then died before the label flip. Re-run close; it reuses the ` +
444
- `open PR rather than opening a duplicate.`,
445
- evidence,
446
- };
447
- }
448
- // Story #4816 — the close artifacts get the first word here, and ONLY
449
- // here. Every other row of this table describes a state whose evidence is
450
- // already unambiguous; `executing` + no PR is the one row that reads
451
- // identically for a dead implementation and for a close that is halfway
452
- // through its gate chain, and answering it from labels alone is what sent
453
- // operators to re-init on top of a live close.
454
- if (closeLooksLive(closeArtifacts)) {
455
- return closeInFlightVerdict({
456
- storyId,
457
- artifacts: closeArtifacts,
458
- evidence,
459
- });
460
- }
461
- if (closeArtifacts?.envelope) {
462
- return envelopeOnDiskVerdict({
463
- storyId,
464
- artifacts: closeArtifacts,
465
- evidence,
466
- });
467
- }
468
- return {
469
- shape: 'executing-no-pr',
470
- nextCommand: NEXT_COMMANDS.implement(storyId),
471
- detail:
472
- `Story is \`agent::executing\` with no PR, and no close left an artifact behind (no ` +
473
- `persisted terminal envelope, no recent gate log) — implementation never finished. ` +
474
- `Re-init (idempotent — it reuses the existing branch and worktree) and resume in the ` +
475
- `worktree it prints.`,
476
- evidence,
477
- };
515
+ return decideExecuting({ storyId, branch, pr, closeArtifacts, evidence });
478
516
  }
479
517
 
480
518
  return {
@@ -500,6 +538,7 @@ export function decideRecovery({
500
538
  */
501
539
  const TRANSIENT_SHAPES = new Set([
502
540
  'executing-no-pr',
541
+ 'executing-pushed-no-pr',
503
542
  'executing-with-pr',
504
543
  'closing-no-pr',
505
544
  'closing-pr-pending',
@@ -92,7 +92,7 @@ export async function findDependencyCandidates({
92
92
  (p) => typeof p === 'string' && p.trim() !== '',
93
93
  );
94
94
  if (wanted.length === 0) return [];
95
- if (typeof provider?.listIssuesByLabel !== 'function') return [];
95
+ if (typeof provider?.listTicketsByLabel !== 'function') return [];
96
96
 
97
97
  const excluded = new Set(
98
98
  [...excludeIds].map((id) => Number(id)).filter((n) => Number.isFinite(n)),
@@ -100,7 +100,7 @@ export async function findDependencyCandidates({
100
100
 
101
101
  let issues;
102
102
  try {
103
- issues = await provider.listIssuesByLabel({
103
+ issues = await provider.listTicketsByLabel({
104
104
  state: 'open',
105
105
  labels: TYPE_LABELS.STORY,
106
106
  });
@@ -113,7 +113,11 @@ export async function findDependencyCandidates({
113
113
 
114
114
  const out = [];
115
115
  for (const issue of Array.isArray(issues) ? issues : []) {
116
- const id = Number(issue?.number ?? issue?.id);
116
+ // The declared ticket shape: `id` is the issue number. Reading it through
117
+ // the old `number`-then-`id` fallback was the bug in waiting — on this
118
+ // shape the fallback never fires, and on a raw REST payload it silently
119
+ // produced database ids for every candidate the planner offered.
120
+ const id = Number(issue?.id);
117
121
  if (!Number.isInteger(id) || id <= 0 || excluded.has(id)) continue;
118
122
 
119
123
  const footprint = footprintOf(issue);
@@ -125,7 +129,7 @@ export async function findDependencyCandidates({
125
129
  out.push({
126
130
  id,
127
131
  title: typeof issue?.title === 'string' ? issue.title : '',
128
- url: issue?.html_url ?? issue?.url ?? buildStoryUrl(id, { owner, repo }),
132
+ url: issue?.url ?? buildStoryUrl(id, { owner, repo }),
129
133
  state: typeof issue?.state === 'string' ? issue.state : 'open',
130
134
  overlappingPaths,
131
135
  });
@@ -81,7 +81,12 @@ async function readChildTitles({ childIds, provider }) {
81
81
  * @returns {Promise<{ id: number, title: string, url: string, score: number, childIds: number[] }|null>}
82
82
  */
83
83
  async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
84
- const id = Number(epic?.number ?? epic?.id);
84
+ // `listTicketsByLabel` hands back the declared ticket shape, in which `id`
85
+ // is the issue number. The `number`-then-`id` fallback this replaced read as
86
+ // defensive and was not: on that shape it never fired, and on a raw REST
87
+ // payload it was the only thing standing between this and scoring an Epic
88
+ // under its database id.
89
+ const id = Number(epic?.id);
85
90
  if (!Number.isInteger(id) || id <= 0) return null;
86
91
 
87
92
  const title = typeof epic?.title === 'string' ? epic.title : '';
@@ -98,7 +103,7 @@ async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
98
103
  return {
99
104
  id,
100
105
  title,
101
- url: epic?.html_url ?? epic?.url ?? buildEpicUrl(id, { owner, repo }),
106
+ url: epic?.url ?? buildEpicUrl(id, { owner, repo }),
102
107
  score: Number(score.toFixed(4)),
103
108
  childIds,
104
109
  };
@@ -126,14 +131,14 @@ async function scoreEpic({ epic, seedTokens, provider, owner, repo }) {
126
131
  */
127
132
  export async function findOpenEpicCandidates({ seed, provider, owner, repo }) {
128
133
  if (typeof seed !== 'string' || seed.trim() === '') return [];
129
- if (typeof provider?.listIssuesByLabel !== 'function') return [];
134
+ if (typeof provider?.listTicketsByLabel !== 'function') return [];
130
135
 
131
136
  const seedTokens = tokenize(seed);
132
137
  if (seedTokens.size === 0) return [];
133
138
 
134
139
  let issues;
135
140
  try {
136
- issues = await provider.listIssuesByLabel({
141
+ issues = await provider.listTicketsByLabel({
137
142
  state: 'open',
138
143
  labels: TYPE_LABELS.EPIC,
139
144
  });
@@ -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
  }