mandrel 1.68.0 → 1.69.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 (46) hide show
  1. package/.agents/docs/agentrc-reference.json +1 -2
  2. package/.agents/docs/configuration.md +2 -4
  3. package/.agents/schemas/agentrc.schema.json +1 -5
  4. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  5. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  6. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  7. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  8. package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
  9. package/.agents/scripts/lib/config/explain.js +0 -2
  10. package/.agents/scripts/lib/config/limits.js +19 -8
  11. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  12. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  13. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  14. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  16. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  17. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  18. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  19. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  21. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  23. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  24. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  25. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  26. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  27. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  28. package/.agents/scripts/lib/story-adjacency.js +14 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  30. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  31. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  32. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  33. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  34. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  35. package/.agents/scripts/stories-wave-tick.js +262 -161
  36. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  37. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  38. package/.agents/skills/skills.index.json +2 -2
  39. package/.agents/workflows/deliver.md +12 -9
  40. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  41. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  42. package/.agents/workflows/helpers/plan-epic.md +13 -10
  43. package/.agents/workflows/plan.md +1 -1
  44. package/docs/CHANGELOG.md +14 -0
  45. package/package.json +1 -1
  46. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -15,11 +15,28 @@ import {
15
15
  * 2-tier is the only published hierarchy after Story #4041 removed the
16
16
  * Feature tier: the prompt emits Stories only (direct Epic children) and
17
17
  * asks the planner to carry acceptance/verify as top-level ticket arrays.
18
+ *
19
+ * **Single source of the prompt body (Story #4162).** This module is the sole
20
+ * carrier of the full decomposer system-prompt body. The
21
+ * `epic-plan-decompose-author` SKILL no longer embeds a second verbatim copy —
22
+ * it references this rendered prompt (delivered to the host LLM in the
23
+ * `systemPrompt` field of the authoring context envelope built by
24
+ * `epic-plan-decompose/phases/context.js`) instead, so the two surfaces cannot
25
+ * drift. A guard test (`tests/ticket-decomposer.test.js`) fails if the SKILL
26
+ * re-grows a full copy of the prompt preamble.
27
+ *
28
+ * **Token-budget sizing input (Story #4162).** `maxTokenBudget` is the real
29
+ * one-pass delivery envelope (the task-prompt hydration cap surfaced into the
30
+ * authoring envelope by `context.js`, Story #3875). It is threaded into the
31
+ * rendered prompt as a sizing input so the planner sizes Stories against the
32
+ * envelope a single agent can actually deliver in one pass, rather than leading
33
+ * with the file-count proxy alone.
18
34
  */
19
35
  export function renderDecomposerSystemPrompt({
20
36
  maxTickets = LIMITS_DEFAULTS.maxTickets,
37
+ maxTokenBudget = LIMITS_DEFAULTS.maxTokenBudget,
21
38
  } = {}) {
22
- return render2TierPrompt({ maxTickets });
39
+ return render2TierPrompt({ maxTickets, maxTokenBudget });
23
40
  }
24
41
 
25
42
  /**
@@ -28,7 +45,7 @@ export function renderDecomposerSystemPrompt({
28
45
  * on the Story body so the executing agent has everything it needs in one
29
46
  * ticket. Thematic grouping lives as prose in the Epic body / Tech Spec.
30
47
  */
31
- function render2TierPrompt({ maxTickets }) {
48
+ function render2TierPrompt({ maxTickets, maxTokenBudget }) {
32
49
  // Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
33
50
  // (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
34
51
  const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
@@ -103,6 +120,7 @@ The serialized \`body\` string renders these markdown sections (in order):
103
120
  - **changes** (in body string): Each entry is an object \`{ path, assumption }\` where \`assumption\` is one of \`creates | refactors-existing | deletes\`. Acceptable path shapes include explicit files (\`src/components/Foo.tsx\`), glob patterns (\`tests/e2e/*.spec.ts\`, \`**/*.astro\`), and module identifiers that resolve to files. Use \`refactors-existing\` for in-place edits to a file already on \`main\`; \`creates\` for net-new files; \`deletes\` for removals.
104
121
  - **acceptance** (top-level array on the ticket object): Items MUST be observable from outside the agent. Acceptable shapes: a specific command exits 0, a file exists at a given path, a snapshot test matches, a \`data-testid\` resolves under a given selector, a row count in a fixture matches. UNACCEPTABLE: "verify by reading the diff", "looks good", "matches the spec" — push these down into a \`verify\` command instead.
105
122
  - **verify** (top-level array on the ticket object): Each entry MUST name a testing tier in parentheses, drawn from \`unit\` / \`contract\` / \`e2e\` / \`validate\`. Example: \`npm run test -- src/x.test.ts (unit)\`, \`npm run validate (validate)\`. Stories with zero verify entries SHOULD fail validation; if a story is genuinely unverifiable in isolation (e.g., a copy edit auditor will eyeball), the literal entry \`manual:<reason>\` is allowed so the absence is intentional, not lazy. Manual entries without a reason are rejected.
123
+ - **reason to exist** (REQUIRED, encoded as the \`reason_to_exist\` field of the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from its broader \`## Goal\` prose. Every Story MUST carry a non-empty \`reason_to_exist\`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**) and the \`epic-plan-consolidate\` critic flags any Story whose body carries no non-empty reason to exist. Encode it as \`<!-- meta: {"reason_to_exist": "..."} -->\`.
106
124
  - **estimated_test_files** (optional, encoded in the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): Integer estimate of how many test files this Story creates or modifies. Omit when the number is not estimable. Informational only — it does not gate the decompose.
107
125
 
108
126
  #### STORY SIZING — COHESION FIRST (the numeric ceiling is only a backstop):
@@ -111,7 +129,9 @@ The serialized \`body\` string renders these markdown sections (in order):
111
129
 
112
130
  The primary question is **cohesion, not count**: *is this one coherent change with one reason to exist?* File count cannot tell a trivial ${softFiles}-file rename from a hard 3-file parser+caller+config change — so lead with the change's reason, not its size.
113
131
 
114
- - **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories or two Stories that should be one.
132
+ **Size against the real one-pass delivery envelope.** Each Story is delivered and self-verified by a single agent in one pass, whose context is capped by the delivery token budget \`maxTokenBudget = ${maxTokenBudget}\` tokens (the task-prompt hydration cap). Use that envelope not the file count alone — as the leading sizing input: a Story is correctly sized when one agent can hold its full change, acceptance, and verification in a single pass within \`maxTokenBudget\`. The numeric file thresholds below are a coarse backstop on top of this envelope, not the primary signal.
133
+
134
+ - **One Story = one coherent change with one reason to exist.** If you cannot state that reason in a sentence, the Story is probably two Stories — or two Stories that should be one. State that sentence explicitly in the Story's \`reason_to_exist\` meta field (see STORY BODY RULES) so the consolidate critic can check it.
115
135
  - ${singleConsumerRule}
116
136
  - **Split independent, parallelizable work** into sibling Stories — but only when the pieces genuinely have separate reasons to exist.
117
137
  - **Declare \`wide\` with a one-line reason when a change is legitimately broad** (a cohesive cutover that spans many files for one reason). Declaring \`wide\` lifts the hard file-width ceiling — see below.
@@ -0,0 +1,295 @@
1
+ /**
2
+ * lib/wave-runner/ready-set.js — the path-agnostic ready-set scheduling
3
+ * core.
4
+ *
5
+ * This module is the scheduling kernel both the Epic and standalone
6
+ * delivery paths dispatch through. It replaces wave-*batch* selection
7
+ * (group N must fully drain before group N+1 opens) with *continuous*,
8
+ * dependency-driven selection: a Story becomes dispatchable the instant
9
+ * **its own** dependencies are satisfied, regardless of whether unrelated
10
+ * Stories in some nominal wave are still running. There is no false
11
+ * barrier — a Story C that depends only on a done Story A is selected even
12
+ * while an unrelated Story B is still pending.
13
+ *
14
+ * It is deliberately **path-agnostic and side-effect-free**: it neither
15
+ * reads GitHub, the lifecycle ledger, nor a checkpoint, and it dispatches
16
+ * nothing. Callers supply the live Story records (already fetched), the
17
+ * resolved `inFlight` count, and the `globalCap`, and receive back the set
18
+ * of Stories that are safe to dispatch on this beat. Later Stories wire the
19
+ * Epic / standalone adapters on top of this core; this Story ships the core
20
+ * alone and does not modify `tick.js` or `stories-wave-tick.js`.
21
+ *
22
+ * Three exports:
23
+ * - `classifyStory(story)` — live-label classifier mapping a Story
24
+ * record's labels + issue state to one of `done | blocked | executing |
25
+ * ready`. Mirrors the done-predicate `tick.js` already uses
26
+ * (`agent::done` OR closed issue) so a Story closed manually through
27
+ * the GitHub UI is recognised as done.
28
+ * - `storiesOverlap(a, b)` — the file-overlap co-dispatch guard: true
29
+ * when two Stories' declared file footprints intersect. Two Stories
30
+ * that would touch the same file MUST NOT be dispatched onto parallel
31
+ * `story-<id>` branches in the same beat (they would race the same
32
+ * path and produce a merge conflict at close).
33
+ * - `selectReadySet({ stories, doneIds, inFlight, globalCap })` — the
34
+ * scheduler. Returns the deterministic, overlap-free set of ready
35
+ * Stories, capped at `globalCap − inFlight`.
36
+ *
37
+ * Adjacency is re-derived from the supplied records via the shared
38
+ * `buildStoryAdjacency` builder (`lib/story-adjacency.js`) — the same
39
+ * `blocked by #NNN` / `dependencies[]` source order the dispatch manifest
40
+ * and the existing wave wrappers use — so this core never disagrees with
41
+ * the manifest about what depends on what.
42
+ *
43
+ * @module lib/wave-runner/ready-set
44
+ */
45
+
46
+ import { AGENT_LABELS } from '../label-constants.js';
47
+ import { buildStoryAdjacency } from '../story-adjacency.js';
48
+
49
+ /**
50
+ * @typedef {object} StoryRecord
51
+ * @property {number|string} [id] Story id (preferred).
52
+ * @property {number} [number] Story id (GitHub issue-number shape).
53
+ * @property {string} [title]
54
+ * @property {string} [body] Used by `buildStoryAdjacency` to parse
55
+ * `blocked by #NNN` / `depends on #NNN` references.
56
+ * @property {string[]} [labels] Live `agent::*` labels.
57
+ * @property {string} [state] GitHub issue state (`open` | `closed`).
58
+ * @property {Array<number|string>} [dependencies] Explicit dependency ids.
59
+ * @property {Array<number|string>} [dependsOn] Operator-DAG dependency ids.
60
+ * @property {string[]} [files] Declared file footprint (one of the
61
+ * accepted footprint shapes — see `storyFootprint`).
62
+ * @property {string[]} [changes] Alternate footprint shape.
63
+ * @property {Array<{path?: string}>} [changeset] Alternate footprint shape.
64
+ */
65
+
66
+ /** @typedef {'done'|'blocked'|'executing'|'ready'} StoryClass */
67
+
68
+ /**
69
+ * Normalize a Story record's id to a positive integer, or `null` when it is
70
+ * absent / non-integer. Accepts both the ticket shape (`id`) and the raw
71
+ * GitHub issue shape (`number`), matching `buildStoryAdjacency`.
72
+ *
73
+ * @param {StoryRecord|number|string} story
74
+ * @returns {number|null}
75
+ */
76
+ export function storyIdOf(story) {
77
+ if (typeof story === 'number') {
78
+ return Number.isInteger(story) && story > 0 ? story : null;
79
+ }
80
+ const raw = story?.id ?? story?.number;
81
+ const id = Number(raw);
82
+ return Number.isInteger(id) && id > 0 ? id : null;
83
+ }
84
+
85
+ /**
86
+ * Classify a Story from its **live** labels and issue state.
87
+ *
88
+ * Precedence (highest first):
89
+ * 1. `done` — carries `agent::done` OR the issue is `state === 'closed'`.
90
+ * The closed-state arm aligns with `tick.js#isStoryDone`
91
+ * so a Story closed manually in the GitHub UI (issue
92
+ * closed, label not flipped) still reads as done and is
93
+ * never re-dispatched.
94
+ * 2. `blocked` — carries `agent::blocked`.
95
+ * 3. `executing` — carries `agent::executing` OR `agent::closing` (both
96
+ * are in-flight: an executing or closing Story occupies a
97
+ * slot and must not be re-dispatched).
98
+ * 4. `ready` — none of the above; the Story is eligible for dispatch
99
+ * once its dependencies are satisfied.
100
+ *
101
+ * `done` wins over every in-progress label so a stale `agent::executing`
102
+ * left behind on an issue that has since closed never masks completion.
103
+ *
104
+ * @param {StoryRecord} story
105
+ * @returns {StoryClass}
106
+ */
107
+ export function classifyStory(story) {
108
+ const labels = Array.isArray(story?.labels) ? story.labels : [];
109
+ if (labels.includes(AGENT_LABELS.DONE) || story?.state === 'closed') {
110
+ return 'done';
111
+ }
112
+ if (labels.includes(AGENT_LABELS.BLOCKED)) return 'blocked';
113
+ if (
114
+ labels.includes(AGENT_LABELS.EXECUTING) ||
115
+ labels.includes(AGENT_LABELS.CLOSING)
116
+ ) {
117
+ return 'executing';
118
+ }
119
+ return 'ready';
120
+ }
121
+
122
+ /**
123
+ * Extract a Story's declared file footprint as a normalized set of path
124
+ * strings. Accepts the three footprint shapes a Story record can carry:
125
+ *
126
+ * - `files: string[]` — explicit footprint.
127
+ * - `changes: string[]` — string-array sketch.
128
+ * - `changeset: Array<{ path }>` / — object-array sketch (the
129
+ * `changes: Array<{ path }>` `{ path, assumption }`
130
+ * shape from a Story body).
131
+ *
132
+ * Paths are trimmed; empty / non-string entries are dropped. A Story with
133
+ * no declared footprint yields an empty set, which (by `storiesOverlap`'s
134
+ * contract) means it overlaps with nothing and is never withheld by the
135
+ * co-dispatch guard.
136
+ *
137
+ * @param {StoryRecord} story
138
+ * @returns {Set<string>}
139
+ */
140
+ export function storyFootprint(story) {
141
+ const out = new Set();
142
+ const push = (entry) => {
143
+ const path =
144
+ typeof entry === 'string'
145
+ ? entry
146
+ : typeof entry?.path === 'string'
147
+ ? entry.path
148
+ : null;
149
+ if (!path) return;
150
+ const trimmed = path.trim();
151
+ if (trimmed) out.add(trimmed);
152
+ };
153
+ if (Array.isArray(story?.files)) for (const e of story.files) push(e);
154
+ if (Array.isArray(story?.changes)) for (const e of story.changes) push(e);
155
+ if (Array.isArray(story?.changeset)) for (const e of story.changeset) push(e);
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * File-overlap co-dispatch guard. Returns `true` when two Stories' declared
161
+ * file footprints intersect on at least one path — meaning they would race
162
+ * the same file if dispatched onto parallel `story-<id>` branches in the
163
+ * same beat. Two Stories that overlap MUST NOT both appear in one dispatch
164
+ * set; one is withheld until the other clears.
165
+ *
166
+ * An empty footprint on either side means "no known overlap" → `false`. A
167
+ * Story that declares no files is therefore never withheld by this guard.
168
+ *
169
+ * @param {StoryRecord} a
170
+ * @param {StoryRecord} b
171
+ * @returns {boolean}
172
+ */
173
+ export function storiesOverlap(a, b) {
174
+ const fa = storyFootprint(a);
175
+ if (fa.size === 0) return false;
176
+ const fb = storyFootprint(b);
177
+ if (fb.size === 0) return false;
178
+ for (const path of fa) {
179
+ if (fb.has(path)) return true;
180
+ }
181
+ return false;
182
+ }
183
+
184
+ /**
185
+ * Select the set of Stories safe to dispatch on this beat.
186
+ *
187
+ * Algorithm (continuous, dependency-driven — no wave barrier):
188
+ *
189
+ * 1. **Adjacency.** Re-derive `Map<id, depIds[]>` from the supplied
190
+ * records via `buildStoryAdjacency`. The `dropForeign` flag controls
191
+ * how a dependency on an id **outside** the supplied set is treated:
192
+ * - `dropForeign: false` (default, standalone-path semantics) — the
193
+ * foreign dependency still gates the dependent: an absent dependency
194
+ * is treated as not-yet-done and withholds the dependent until it
195
+ * completes (preserves the operator-DAG contract).
196
+ * - `dropForeign: true` (Epic-path semantics) — a foreign edge is
197
+ * pruned so the DAG stays closed over the scheduled Story set. An
198
+ * Epic's Stories depend only on siblings, so a `blocked by #N` whose
199
+ * target is out-of-scope (a foreign id, or a typo) must be dropped,
200
+ * not treated as a permanent unsatisfiable gate — otherwise the
201
+ * dependent Story is never schedulable and the run silently strands
202
+ * it. This matches `build-wave-dag.js`, which builds the Epic
203
+ * wave DAG with the same default-`dropForeign` builder.
204
+ * 2. **Done set.** Union the caller-supplied `doneIds` with every record
205
+ * that classifies as `done` (live label / closed issue). A Story's
206
+ * dependency counts as satisfied iff it is in this union.
207
+ * 3. **Eligibility.** A Story is *eligible* when it classifies as `ready`
208
+ * (not done / blocked / executing) **and** every one of its
209
+ * dependencies is in the done set. This is the no-false-barrier
210
+ * property: C depending only on A is eligible the instant A is done,
211
+ * even while an unrelated B is still pending.
212
+ * 4. **Capacity.** The dispatch set never exceeds `slots = max(0,
213
+ * globalCap − inFlight)`. `inFlight` is the caller's count of Stories
214
+ * already occupying a slot (executing / closing / dispatched-not-yet-
215
+ * labelled). When `slots <= 0`, the result is empty.
216
+ * 5. **Overlap guard.** Greedily admit eligible Stories in ascending-id
217
+ * order, skipping any whose file footprint overlaps an
218
+ * already-admitted Story (`storiesOverlap`). A withheld Story stays
219
+ * eligible and is naturally re-considered on the next beat once its
220
+ * overlapping peer has cleared.
221
+ *
222
+ * The result is deterministic: eligible Stories are considered in
223
+ * ascending-id order, so the same inputs always yield the same set.
224
+ *
225
+ * @param {object} args
226
+ * @param {StoryRecord[]} args.stories Live Story records in scope.
227
+ * @param {Array<number|string>|Set<number|string>} [args.doneIds]
228
+ * Ids the caller already knows are done (e.g. from a prior beat). Merged
229
+ * with records that classify as done.
230
+ * @param {number} [args.inFlight=0] Count of Stories already occupying a
231
+ * slot. Subtracted from `globalCap` to compute remaining capacity.
232
+ * @param {number} args.globalCap Hard ceiling on total concurrent
233
+ * Stories.
234
+ * @param {boolean} [args.dropForeign=false] Adjacency closure policy (see
235
+ * step 1 above). `false` keeps a foreign dependency as a gate
236
+ * (standalone / operator-DAG semantics); `true` prunes foreign edges so
237
+ * the DAG stays closed over the scheduled set (Epic semantics).
238
+ * @returns {StoryRecord[]} The dispatch set: a subset of `stories`,
239
+ * ascending by id, overlap-free, length ≤ `globalCap − inFlight`.
240
+ */
241
+ export function selectReadySet({
242
+ stories,
243
+ doneIds = [],
244
+ inFlight = 0,
245
+ globalCap,
246
+ dropForeign = false,
247
+ } = {}) {
248
+ const records = Array.isArray(stories) ? stories : [];
249
+ const cap = Number.isInteger(globalCap) ? globalCap : 0;
250
+ const inFlightCount =
251
+ Number.isInteger(inFlight) && inFlight > 0 ? inFlight : 0;
252
+ const slots = Math.max(0, cap - inFlightCount);
253
+ if (slots <= 0 || records.length === 0) return [];
254
+
255
+ // Step 1 — adjacency keyed by id. The `dropForeign` policy decides whether
256
+ // a dependency on an id outside the supplied set gates the dependent
257
+ // (false) or is pruned (true). See the JSDoc above for the per-path
258
+ // rationale.
259
+ const adjacency = buildStoryAdjacency(records, { dropForeign });
260
+
261
+ // Step 2 — done set = caller-supplied ids ∪ records that classify done.
262
+ const done = new Set();
263
+ for (const raw of doneIds instanceof Set ? doneIds : (doneIds ?? [])) {
264
+ const id = Number(raw);
265
+ if (Number.isInteger(id)) done.add(id);
266
+ }
267
+ const byId = new Map();
268
+ for (const rec of records) {
269
+ const id = storyIdOf(rec);
270
+ if (id === null) continue;
271
+ byId.set(id, rec);
272
+ if (classifyStory(rec) === 'done') done.add(id);
273
+ }
274
+
275
+ // Step 3 — eligible: ready AND all dependencies done. Ascending id for
276
+ // deterministic admission order.
277
+ const eligibleIds = [];
278
+ for (const id of [...byId.keys()].sort((a, b) => a - b)) {
279
+ const rec = byId.get(id);
280
+ if (classifyStory(rec) !== 'ready') continue;
281
+ const deps = adjacency.get(id) ?? [];
282
+ if (deps.every((dep) => done.has(dep))) eligibleIds.push(id);
283
+ }
284
+
285
+ // Steps 4 + 5 — greedily admit up to `slots`, skipping file-overlap
286
+ // collisions against the already-admitted set.
287
+ const selected = [];
288
+ for (const id of eligibleIds) {
289
+ if (selected.length >= slots) break;
290
+ const rec = byId.get(id);
291
+ if (selected.some((picked) => storiesOverlap(picked, rec))) continue;
292
+ selected.push(rec);
293
+ }
294
+ return selected;
295
+ }