mandrel 2.39.0 → 2.40.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 (28) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/agents/auditor.md +5 -0
  3. package/.agents/docs/SDLC.md +21 -12
  4. package/.agents/instructions.md +17 -16
  5. package/.agents/scripts/audit-to-stories.js +510 -66
  6. package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
  7. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
  8. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
  9. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
  10. package/.agents/scripts/lib/label-constants.js +18 -0
  11. package/.agents/scripts/lib/label-taxonomy.js +18 -5
  12. package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
  13. package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
  14. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
  15. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
  16. package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
  17. package/.agents/scripts/plan-persist.js +39 -1
  18. package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
  19. package/.agents/scripts/resolve-stories.js +42 -2
  20. package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
  21. package/.agents/workflows/audit-to-stories.md +85 -7
  22. package/.agents/workflows/helpers/audit-lens-core.md +24 -4
  23. package/.agents/workflows/helpers/deliver-reference.md +8 -0
  24. package/.agents/workflows/helpers/plan-reference.md +28 -0
  25. package/.agents/workflows/mandrel-deliver.md +47 -43
  26. package/.agents/workflows/mandrel-plan.md +44 -38
  27. package/docs/CHANGELOG.md +16 -0
  28. package/package.json +1 -1
@@ -7,6 +7,8 @@
7
7
  * 2. Rolls up friction follow-ups across every Story in the run and
8
8
  * files/posts them on the primary Story.
9
9
  * 3. Checks sibling Spec/acceptance coherence across Story bodies.
10
+ * 4. Closes any container Epic whose children all landed (Story #5139) —
11
+ * the only completion cascade v2 reintroduces.
10
12
  *
11
13
  * There is no inert planner-only path: `planRunEpilogue` enumerates steps
12
14
  * and `runPlanRunEpilogue` executes them. Single-Story runs skip the
@@ -19,6 +21,8 @@ import { selectAudits } from '../audit-suite/index.js';
19
21
  import { graduateRetroProposals } from '../feedback-loop/retro-proposals-graduator.js';
20
22
  import { gitSpawn } from '../git-utils.js';
21
23
  import { Logger } from '../Logger.js';
24
+ import { AGENT_LABELS, TYPE_LABELS } from '../label-constants.js';
25
+ import { isEpicTicket, readEpicChildIds } from './epic-container.js';
22
26
  import { composeRoutedProposals } from './retro-proposals.js';
23
27
  import {
24
28
  assessRollupOutcome,
@@ -31,14 +35,130 @@ import { upsertStructuredComment } from './ticketing.js';
31
35
 
32
36
  /**
33
37
  * Canonical epilogue step kinds, in execution order.
34
- * @type {readonly ['audit-roster', 'follow-up-rollup', 'sibling-coherence']}
38
+ * @type {readonly ['audit-roster', 'follow-up-rollup', 'sibling-coherence', 'epic-close']}
35
39
  */
36
40
  export const RUN_EPILOGUE_STEP_KINDS = Object.freeze([
37
41
  'audit-roster',
38
42
  'follow-up-rollup',
39
43
  'sibling-coherence',
44
+ 'epic-close',
40
45
  ]);
41
46
 
47
+ /**
48
+ * Close a container Epic once every child Story has landed.
49
+ *
50
+ * This is the **only** completion cascade v2 reintroduces (Story #5139), and
51
+ * it is deliberately one-directional: closing the container, never touching a
52
+ * child's state, never reopening.
53
+ *
54
+ * The lookup runs child→parent by scanning open Epics, because linkage is
55
+ * parent→child only — a Story body carries no pointer back. That is the
56
+ * price of leaving Story bodies untouched, and it is cheap: open Epics are
57
+ * few, and the scan is scoped to Epics that actually contain one of this
58
+ * run's delivered Stories, so an unrelated Epic is never swept.
59
+ *
60
+ * Non-fatal throughout: the epilogue is a reporting tail, and a container
61
+ * left open costs tidiness, not correctness.
62
+ *
63
+ * @param {{ stories: string[], provider: object }} opts
64
+ * @returns {Promise<{ kind: string, closed: number[], pending: number[] }>}
65
+ */
66
+ async function executeEpicClose({ stories, provider }) {
67
+ const result = { kind: 'epic-close', closed: [], pending: [] };
68
+ if (
69
+ typeof provider?.listIssuesByLabel !== 'function' ||
70
+ typeof provider?.updateTicket !== 'function'
71
+ ) {
72
+ return result;
73
+ }
74
+
75
+ const delivered = new Set(stories.map((id) => Number(id)));
76
+ let epics;
77
+ try {
78
+ epics = await provider.listIssuesByLabel({
79
+ state: 'open',
80
+ labels: TYPE_LABELS.EPIC,
81
+ });
82
+ } catch (err) {
83
+ Logger.warn(
84
+ `[run-epilogue] Could not list open Epics (${err?.message ?? err}); skipping the Epic close.`,
85
+ );
86
+ return result;
87
+ }
88
+
89
+ for (const epic of Array.isArray(epics) ? epics : []) {
90
+ if (!isEpicTicket(epic)) continue;
91
+ const epicId = Number(epic?.number ?? epic?.id);
92
+ if (!Number.isInteger(epicId)) continue;
93
+
94
+ const childIds = readEpicChildIds(epic?.body);
95
+ if (childIds.length === 0) continue;
96
+ // Only Epics this run actually advanced. Sweeping every open Epic would
97
+ // make a delivery close containers it had nothing to do with.
98
+ if (!childIds.some((c) => delivered.has(c))) continue;
99
+
100
+ let allLanded = true;
101
+ for (const childId of childIds) {
102
+ try {
103
+ const child = await provider.getTicket(childId);
104
+ if (!isSatisfiedChild(child)) {
105
+ allLanded = false;
106
+ break;
107
+ }
108
+ } catch (err) {
109
+ Logger.warn(
110
+ `[run-epilogue] Epic #${epicId}: could not read child #${childId} ` +
111
+ `(${err?.message ?? err}) — leaving the Epic open.`,
112
+ );
113
+ allLanded = false;
114
+ break;
115
+ }
116
+ }
117
+
118
+ if (!allLanded) {
119
+ result.pending.push(epicId);
120
+ continue;
121
+ }
122
+
123
+ try {
124
+ await provider.updateTicket(epicId, {
125
+ state: 'closed',
126
+ state_reason: 'completed',
127
+ });
128
+ Logger.info(
129
+ `[run-epilogue] Closed container Epic #${epicId} — all ${childIds.length} child Story(ies) landed.`,
130
+ );
131
+ result.closed.push(epicId);
132
+ } catch (err) {
133
+ Logger.warn(
134
+ `[run-epilogue] Could not close Epic #${epicId} (${err?.message ?? err}).`,
135
+ );
136
+ result.pending.push(epicId);
137
+ }
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ /**
144
+ * A child no longer holds its Epic open once it is closed or `agent::done`.
145
+ *
146
+ * Mirrors `isSatisfiedBlocker` in `lib/orchestration/resolve-stories.js`
147
+ * rather than importing it: that module is the delivery-resolution path and
148
+ * pulling it in here would drag the whole story-body parser into the
149
+ * epilogue for a two-line predicate.
150
+ *
151
+ * @param {{ state?: string, labels?: unknown }} issue
152
+ * @returns {boolean}
153
+ */
154
+ function isSatisfiedChild(issue) {
155
+ if (String(issue?.state ?? '').toLowerCase() === 'closed') return true;
156
+ const labels = Array.isArray(issue?.labels)
157
+ ? issue.labels.map((l) => (typeof l === 'string' ? l : l?.name))
158
+ : [];
159
+ return labels.includes(AGENT_LABELS.DONE);
160
+ }
161
+
42
162
  /**
43
163
  * @param {string|number|{ id?: string|number, slug?: string }} entry
44
164
  * @returns {string|null}
@@ -123,6 +243,11 @@ export function planRunEpilogue({ planRunId, stories } = {}) {
123
243
  description: `Sibling-coherence check across the ${ids.length} Story specs of run ${effectiveRunId}`,
124
244
  stories: ids,
125
245
  },
246
+ {
247
+ kind: 'epic-close',
248
+ description: `Close any container Epic whose children all landed in run ${effectiveRunId}`,
249
+ stories: ids,
250
+ },
126
251
  ];
127
252
 
128
253
  return {
@@ -825,6 +950,10 @@ export async function runPlanRunEpilogue({
825
950
  provider,
826
951
  }),
827
952
  );
953
+ } else if (step.kind === 'epic-close') {
954
+ results.push(
955
+ await executeEpicClose({ stories: plan.stories, provider }),
956
+ );
828
957
  }
829
958
  } catch (err) {
830
959
  const message = err?.message ?? String(err);
@@ -124,6 +124,8 @@ const CLI_OPTIONS = {
124
124
  'force-review': { type: 'boolean', default: false },
125
125
  'allow-over-budget': { type: 'boolean', default: false },
126
126
  'allow-large-fan-out': { type: 'boolean', default: false },
127
+ 'epic-title': { type: 'string' },
128
+ 'epic-goal': { type: 'string' },
127
129
  };
128
130
 
129
131
  const USAGE =
@@ -133,7 +135,8 @@ const USAGE =
133
135
  '[--source-tickets <ids>] [--no-close-superseded] ' +
134
136
  '[--route-downgrade-reason <text>] ' +
135
137
  '[--dry-run] [--chain-on-clean] [--force-review] ' +
136
- '[--allow-over-budget] [--allow-large-fan-out]';
138
+ '[--allow-over-budget] [--allow-large-fan-out] ' +
139
+ '[--epic-title <text> --epic-goal <text>]';
137
140
 
138
141
  async function readOptional(filePath, { required }) {
139
142
  try {
@@ -196,6 +199,32 @@ async function loadArtifacts(paths) {
196
199
  };
197
200
  }
198
201
 
202
+ /**
203
+ * Resolve the optional container-Epic request from the CLI flags.
204
+ *
205
+ * Both halves are required together: an Epic with a title and no goal is a
206
+ * container with nothing explaining the grouping, and a goal with no title
207
+ * cannot be opened at all. Supplying exactly one is a **usage error**, not a
208
+ * silent no-Epic run — the operator asked for a container and would otherwise
209
+ * never learn they did not get one.
210
+ *
211
+ * @param {object} values Parsed `parseArgs` values.
212
+ * @returns {{ title: string, goal: string }|null} `null` when no Epic was requested.
213
+ */
214
+ export function resolveEpicRequest(values) {
215
+ const title = (values['epic-title'] ?? '').trim();
216
+ const goal = (values['epic-goal'] ?? '').trim();
217
+ if (title === '' && goal === '') return null;
218
+ if (title === '' || goal === '') {
219
+ throw new Error(
220
+ '[plan-persist] --epic-title and --epic-goal must be supplied together ' +
221
+ '(a container Epic needs both a name and a one-paragraph reason it ' +
222
+ 'groups these Stories).',
223
+ );
224
+ }
225
+ return { title, goal };
226
+ }
227
+
199
228
  /**
200
229
  * Assemble the `runPlanPersist` opts bag from parsed CLI values.
201
230
  *
@@ -224,6 +253,7 @@ export function buildPersistOptions(values, paths, planContextEnvelope) {
224
253
  sourceTicketIds: source.ids,
225
254
  sourceTicketOrigin: source.origin,
226
255
  routeDowngradeReason: values['route-downgrade-reason'] ?? null,
256
+ epic: resolveEpicRequest(values),
227
257
  // Default-on: `--no-close-superseded` is the explicit escape and always
228
258
  // wins over the (default `true`) `--close-superseded`.
229
259
  closeSuperseded:
@@ -468,6 +498,14 @@ runAsCli(import.meta.url, main, {
468
498
  ],
469
499
  ['--allow-over-budget', 'Permit a Spec over the context budget.'],
470
500
  ['--allow-large-fan-out', 'Permit a Story count above the fan-out gate.'],
501
+ [
502
+ '--epic-title <text>',
503
+ 'Group the persisted Stories under a container Epic with this title (needs --epic-goal).',
504
+ ],
505
+ [
506
+ '--epic-goal <text>',
507
+ 'The container Epic’s one-paragraph goal (needs --epic-title).',
508
+ ],
471
509
  ],
472
510
  },
473
511
  });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * GitHub Provider — shared "link child issue to a parent" helper.
3
+ *
4
+ * Story #5139 — a container Epic holds its children as native GitHub
5
+ * sub-issue edges. The read side has existed since v1
6
+ * (`sub-issues.js` → `getNativeSubIssues`, and the three-strategy
7
+ * aggregator in `issues.js` → `getSubTickets`); this is the missing write.
8
+ *
9
+ * API surface used:
10
+ * Read: GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues
11
+ * Write: POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues
12
+ * body: { "sub_issue_id": <integer db id of the CHILD issue> }
13
+ *
14
+ * **`sub_issue_id` is the child's database id, not its issue number.** They
15
+ * are different integers and both are plausible, so a mix-up does not throw
16
+ * — it silently links the wrong issue, or a nonexistent one. This mirrors
17
+ * `blocked-by-add.js`, whose `issue_id` carries the same trap.
18
+ *
19
+ * Contract (deliberately identical to `blocked-by-add.js`):
20
+ * - **Idempotent** — reads existing edges first; only POSTs missing ones.
21
+ * - **Non-fatal** — catches all errors per edge, warns, and continues.
22
+ * The function never throws; failures are returned in the summary.
23
+ * The Epic body's checklist is the durable mirror, so a lost edge
24
+ * degrades discoverability rather than losing the child.
25
+ * - **No-op on empty input.**
26
+ */
27
+
28
+ import { Logger } from '../../lib/Logger.js';
29
+ import { concurrentMap } from '../../lib/util/concurrent-map.js';
30
+ import { paginateRest } from './request-helpers.js';
31
+
32
+ /**
33
+ * Bounded concurrency for the sub-issue round-trips. Matches the
34
+ * dependency-edge writer's cap: modest enough for GitHub's secondary rate
35
+ * limits while collapsing wall-clock from `sum(round-trips)` toward
36
+ * `sum(round-trips) / concurrency`.
37
+ */
38
+ const EDGE_CONCURRENCY = 5;
39
+
40
+ /**
41
+ * Fetch the database ids of a parent's existing sub-issues, **paginated to
42
+ * exhaustion**.
43
+ *
44
+ * This read is the idempotency check: an edge it fails to see is re-POSTed.
45
+ * Reading only the first page would therefore make the writer non-idempotent
46
+ * past the page boundary — the same defect Story #5046 fixed in
47
+ * `blocked-by-add.js`.
48
+ *
49
+ * Returns `[]` on any error so the caller falls back to posting the full
50
+ * set. Worst case is a duplicate POST, which GitHub rejects harmlessly and
51
+ * the per-edge catch absorbs.
52
+ *
53
+ * @param {{ gh: object, owner: string, repo: string, issueNumber: number, paginate?: Function }} opts
54
+ * @returns {Promise<number[]>} Database ids of the parent's current children.
55
+ */
56
+ async function fetchExistingSubIssueIds({
57
+ gh,
58
+ owner,
59
+ repo,
60
+ issueNumber,
61
+ paginate = paginateRest,
62
+ }) {
63
+ try {
64
+ const data = await paginate(
65
+ gh,
66
+ `/repos/${owner}/${repo}/issues/${issueNumber}/sub_issues`,
67
+ { label: `[sub-issue-add] sub_issues #${issueNumber}` },
68
+ );
69
+ if (!Array.isArray(data)) return [];
70
+ return data.map((item) => item?.id).filter((id) => typeof id === 'number');
71
+ } catch (err) {
72
+ Logger.warn(
73
+ `[sub-issue-add] Could not fetch existing sub-issues for #${issueNumber}: ${err.message}`,
74
+ );
75
+ return [];
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Link a set of child issues to one parent as native sub-issues.
81
+ *
82
+ * For each entry in `childInternalIds`, checks whether the edge already
83
+ * exists and POSTs only the missing ones. Every individual POST failure is
84
+ * caught, logged and counted — the function never throws.
85
+ *
86
+ * @param {{
87
+ * gh: object,
88
+ * owner: string,
89
+ * repo: string,
90
+ * issueNumber: number,
91
+ * childInternalIds: number[],
92
+ * paginate?: Function,
93
+ * }} opts
94
+ * @returns {Promise<{ added: number, skipped: number, failed: number }>}
95
+ */
96
+ export async function addSubIssueEdges({
97
+ gh,
98
+ owner,
99
+ repo,
100
+ issueNumber,
101
+ childInternalIds,
102
+ paginate = paginateRest,
103
+ }) {
104
+ const ids = Array.isArray(childInternalIds) ? childInternalIds : [];
105
+ if (ids.length === 0) return { added: 0, skipped: 0, failed: 0 };
106
+
107
+ const existing = await fetchExistingSubIssueIds({
108
+ gh,
109
+ owner,
110
+ repo,
111
+ issueNumber,
112
+ paginate,
113
+ });
114
+ const existingSet = new Set(existing);
115
+
116
+ // Partition up front so the skip count is deterministic regardless of the
117
+ // concurrent POST dispatch order.
118
+ const missing = ids.filter((id) => !existingSet.has(id));
119
+ const skipped = ids.length - missing.length;
120
+
121
+ const perEdge = await concurrentMap(
122
+ missing,
123
+ async (childId) => {
124
+ try {
125
+ await gh.api({
126
+ method: 'POST',
127
+ endpoint: `/repos/${owner}/${repo}/issues/${issueNumber}/sub_issues`,
128
+ body: { sub_issue_id: childId },
129
+ });
130
+ return { added: 1, failed: 0 };
131
+ } catch (err) {
132
+ Logger.warn(
133
+ `[sub-issue-add] Failed to link child(id=${childId}) under #${issueNumber}: ${err.message}`,
134
+ );
135
+ return { added: 0, failed: 1 };
136
+ }
137
+ },
138
+ { concurrency: EDGE_CONCURRENCY },
139
+ );
140
+
141
+ let added = 0;
142
+ let failed = 0;
143
+ for (const r of perEdge) {
144
+ added += r.added;
145
+ failed += r.failed;
146
+ }
147
+
148
+ return { added, skipped, failed };
149
+ }
150
+
151
+ /**
152
+ * Link child Stories to a container Epic, resolving each child's **database
153
+ * id** from its issue number via the injected `getTicket` hook.
154
+ *
155
+ * Callers hold issue numbers (that is what `plan-persist` creates and what
156
+ * an operator types); the API wants database ids. Doing the translation here
157
+ * keeps that trap in one place instead of at every call site.
158
+ *
159
+ * Never throws: a child whose id cannot be resolved is counted as failed and
160
+ * the remaining edges still go out.
161
+ *
162
+ * @param {{
163
+ * epicNumber: number,
164
+ * childIssueNumbers: number[],
165
+ * getTicket: (issueNumber: number) => Promise<{ internalId: number }>,
166
+ * owner: string,
167
+ * repo: string,
168
+ * gh: object,
169
+ * paginate?: Function,
170
+ * }} opts
171
+ * @returns {Promise<{ added: number, skipped: number, failed: number }>}
172
+ */
173
+ export async function linkStoriesToEpic({
174
+ epicNumber,
175
+ childIssueNumbers,
176
+ getTicket,
177
+ owner,
178
+ repo,
179
+ gh,
180
+ paginate = paginateRest,
181
+ }) {
182
+ const numbers = Array.isArray(childIssueNumbers) ? childIssueNumbers : [];
183
+ if (numbers.length === 0) return { added: 0, skipped: 0, failed: 0 };
184
+
185
+ let failed = 0;
186
+ const childInternalIds = [];
187
+
188
+ for (const childNumber of numbers) {
189
+ try {
190
+ const ticket = await getTicket(childNumber);
191
+ const internalId = ticket?.internalId;
192
+ if (typeof internalId !== 'number') {
193
+ Logger.warn(
194
+ `[sub-issue-add] Child #${childNumber} has no resolvable database id; skipping edge.`,
195
+ );
196
+ failed++;
197
+ continue;
198
+ }
199
+ childInternalIds.push(internalId);
200
+ } catch (err) {
201
+ Logger.warn(
202
+ `[sub-issue-add] Could not resolve child #${childNumber}: ${err.message}`,
203
+ );
204
+ failed++;
205
+ }
206
+ }
207
+
208
+ const summary = await addSubIssueEdges({
209
+ gh,
210
+ owner,
211
+ repo,
212
+ issueNumber: epicNumber,
213
+ childInternalIds,
214
+ paginate,
215
+ });
216
+
217
+ return { ...summary, failed: summary.failed + failed };
218
+ }
@@ -39,6 +39,7 @@ import { parseArgs } from 'node:util';
39
39
  import { runAsCli } from './lib/cli-utils.js';
40
40
  import { resolveConfig } from './lib/config-resolver.js';
41
41
  import { Logger, routeAllOutputToStderr } from './lib/Logger.js';
42
+ import { expandEpicIds } from './lib/orchestration/epic-expansion.js';
42
43
  import {
43
44
  buildStoriesEnvelope,
44
45
  isSatisfiedBlocker,
@@ -71,7 +72,9 @@ real issue state.
71
72
  Options:
72
73
  --ids <csv> Comma-separated Story issue numbers. Required. A token may be
73
74
  a single id (4922) or an inclusive dash range (4922-4926);
74
- ranges expand in place and dedupe against the rest.
75
+ ranges expand in place and dedupe against the rest. A
76
+ container Epic id expands to its open child Stories, and may
77
+ be mixed with Story ids.
75
78
  --pretty Pretty-print the JSON envelope.
76
79
  --no-native Skip the native blocked_by read (body edges only).
77
80
  --help Show this help.
@@ -89,17 +92,54 @@ export function resolveStoriesProvider({
89
92
  return { provider: createProviderFn(config), config };
90
93
  }
91
94
 
95
+ /**
96
+ * Read an Epic's native sub-issue children as issue numbers.
97
+ *
98
+ * Injected into `expandEpicIds` so the lib layer stays provider-agnostic,
99
+ * exactly as `paginate` is injected into `readNativeBlockedBy`. A provider
100
+ * without the GraphQL surface yields `[]`, and the Epic body's checklist
101
+ * carries the children on its own.
102
+ *
103
+ * @param {object} provider
104
+ * @returns {(epic: object) => Promise<number[]>}
105
+ */
106
+ export function nativeChildReader(provider) {
107
+ return async (epic) => {
108
+ if (typeof provider?._getNativeSubIssues !== 'function') return [];
109
+ return provider._getNativeSubIssues(epic?.nodeId, epic?.number ?? epic?.id);
110
+ };
111
+ }
112
+
92
113
  /**
93
114
  * Fetch every requested id and map it to a Story record, failing on the first
94
115
  * id that is not a deliverable Story.
95
116
  *
117
+ * Container Epics are expanded to their open child Stories **first**, so
118
+ * everything downstream sees a plain Story-id list (Story #5139). The
119
+ * expansion walk is sequential because it is id-by-id conditional; the Story
120
+ * fetch that follows stays under the bounded concurrency.
121
+ *
96
122
  * @param {object} provider
97
123
  * @param {number[]} ids
98
124
  * @returns {Promise<object[]>}
99
125
  */
100
126
  export async function fetchStories(provider, ids) {
101
- return concurrentMap(
127
+ const { ids: resolvedIds, expansions } = await expandEpicIds({
102
128
  ids,
129
+ getTicket: (id) => provider.getTicket(id),
130
+ readNativeChildIds: nativeChildReader(provider),
131
+ warn: (m) => Logger.warn(m),
132
+ });
133
+
134
+ for (const { epicId, childIds } of expansions) {
135
+ Logger.info(
136
+ `[resolve-stories] Epic #${epicId} → ${childIds.length} open Story(ies): ` +
137
+ childIds.map((c) => `#${c}`).join(', '),
138
+ );
139
+ }
140
+
141
+ return concurrentMap(
142
+ resolvedIds,
103
143
  async (id) => {
104
144
  const issue = await provider.getTicket(id);
105
145
  if (!issue) {