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
@@ -22,9 +22,9 @@ import { applyBudget } from '../../planning-context-budget.js';
22
22
 
23
23
  export function buildDecomposerSystemPrompt(
24
24
  heuristics = [],
25
- { maxTickets } = {},
25
+ { maxTickets, maxTokenBudget } = {},
26
26
  ) {
27
- const base = renderDecomposerSystemPrompt({ maxTickets });
27
+ const base = renderDecomposerSystemPrompt({ maxTickets, maxTokenBudget });
28
28
  const heuristicsStr =
29
29
  heuristics.length > 0
30
30
  ? `### RISK HEURISTICS (planning metadata if any apply):\n- ${heuristics.join('\n- ')}`
@@ -115,9 +115,13 @@ export async function buildDecompositionContext(
115
115
  const heuristics = resolveHeuristics(config);
116
116
  const limits = getLimits(config);
117
117
  const maxTickets = limits.maxTickets;
118
+ const maxTokenBudget = limits.maxTokenBudget;
118
119
  const planningLimits = limits.planningContext;
119
120
  const { fullContext = false } = opts;
120
- const systemPrompt = buildDecomposerSystemPrompt(heuristics, { maxTickets });
121
+ const systemPrompt = buildDecomposerSystemPrompt(heuristics, {
122
+ maxTickets,
123
+ maxTokenBudget,
124
+ });
121
125
 
122
126
  const budgeted = applyBudget(
123
127
  [
@@ -137,8 +141,10 @@ export async function buildDecompositionContext(
137
141
  maxTickets,
138
142
  // Story #3875 — surface the real delivery envelope to the decomposer
139
143
  // so Stories are sized against the hydration budget and the
140
- // configured preflight ceilings rather than guessed.
141
- maxTokenBudget: limits.maxTokenBudget,
144
+ // configured preflight ceilings rather than guessed. Story #4162 also
145
+ // threads this value into the rendered systemPrompt above as a sizing
146
+ // input so the prompt itself names the budget.
147
+ maxTokenBudget,
142
148
  preflightCeilings: resolvePreflightCeilings(config),
143
149
  contextMode: budgeted.mode,
144
150
  // Story #2801 — surface the Phase 7 planning decision so the
@@ -2,17 +2,28 @@
2
2
  * epic-run-state-store — stateless functions for reading and writing the
3
3
  * `epic-run-state` structured comment used by `/deliver`.
4
4
  *
5
+ * Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from
6
+ * the wave-batch scheduler to the continuous ready-set core
7
+ * (`lib/wave-runner/ready-set.js`). The checkpoint shrank with it: it no
8
+ * longer carries `currentWave`, `plan[][]`, `totalWaves`, or the
9
+ * per-wave `waves[]` aggregation. The durable run state is now a flat
10
+ * **per-Story status map** (`stories: { [storyId]: { status, title?,
11
+ * blockerCommentId? } }`) plus the run-level `concurrencyCap` (the
12
+ * GLOBAL in-flight cap the ready-set selector honours), `phase`,
13
+ * `startedAt`, and `manualInterventions[]`. There is no resume-pointer
14
+ * to reconcile — the ready-set core re-derives adjacency and readiness
15
+ * from live Story bodies/labels on every tick, so the checkpoint only
16
+ * records terminal Story outcomes (for the auto-merge predicate, branch
17
+ * cleanup, and the operator rollup) and the run-level knobs.
18
+ *
5
19
  * This module is the function-based replacement for the legacy
6
20
  * `Checkpointer` class that previously lived at
7
- * `./epic-runner/checkpointer.js`. Bodies were lifted verbatim from the
8
- * corresponding `Checkpointer` methods so the structured-comment shape is
9
- * preserved byte-for-byte. Story #2423 (Epic #2307) deleted the class
10
- * file; the class API survives as a tests-only fixture at
21
+ * `./epic-runner/checkpointer.js`. Story #2423 (Epic #2307) deleted the
22
+ * class file; the class API survives as a tests-only fixture at
11
23
  * `tests/fixtures/epic-run-state-store.js`.
12
24
  *
13
25
  * The comment is identified by a stable HTML marker so it can be overwritten
14
- * idempotently across orchestrator restarts. The body is a fenced JSON block
15
- * following the schema in tech spec #323.
26
+ * idempotently across orchestrator restarts. The body is a fenced JSON block.
16
27
  */
17
28
 
18
29
  import { assertValidDeliverPhase } from './epic-runner/deliver-phases.js';
@@ -20,7 +31,15 @@ import { parseFencedJsonComment } from './structured-comment-parser.js';
20
31
  import { findStructuredComment, upsertStructuredComment } from './ticketing.js';
21
32
 
22
33
  export const EPIC_RUN_STATE_TYPE = 'epic-run-state';
23
- export const CHECKPOINT_SCHEMA_VERSION = 1;
34
+ export const CHECKPOINT_SCHEMA_VERSION = 2;
35
+
36
+ /** Terminal / in-progress per-Story statuses persisted on the checkpoint. */
37
+ export const STORY_STATUSES = Object.freeze([
38
+ 'pending',
39
+ 'done',
40
+ 'blocked',
41
+ 'failed',
42
+ ]);
24
43
 
25
44
  // Re-export the phase enum + index helper so downstream importers continue
26
45
  // to use this module as a single import target.
@@ -40,6 +59,56 @@ function assertEpicId(epicId) {
40
59
  }
41
60
  }
42
61
 
62
+ /**
63
+ * Normalize an inbound Story id (accepts the ticket `id` shape, the raw
64
+ * GitHub `number` shape, and a bare integer) to a positive integer, or
65
+ * `null` when it is absent / non-positive / non-integer.
66
+ *
67
+ * @param {object|number|string} entry
68
+ * @returns {number|null}
69
+ */
70
+ function storyIdOf(entry) {
71
+ if (typeof entry === 'number') {
72
+ return Number.isInteger(entry) && entry > 0 ? entry : null;
73
+ }
74
+ if (!entry || typeof entry !== 'object') {
75
+ const n = Number(entry);
76
+ return Number.isInteger(n) && n > 0 ? n : null;
77
+ }
78
+ const raw = entry.id ?? entry.storyId ?? entry.number;
79
+ const id = Number(raw);
80
+ return Number.isInteger(id) && id > 0 ? id : null;
81
+ }
82
+
83
+ /**
84
+ * Build the initial per-Story status map from a list of Story records (or
85
+ * ids). Every Story seeds at `status: 'pending'`; an optional `title` is
86
+ * carried through when the record supplies one so the operator rollup and
87
+ * branch-cleanup surfaces have a label without a second fetch. Keys are the
88
+ * positive-integer Story ids as strings (JSON object keys are strings);
89
+ * shapeless / non-positive entries are dropped.
90
+ *
91
+ * Pure helper — exported for unit tests.
92
+ *
93
+ * @param {Array<object|number>} stories
94
+ * @returns {Record<string, { status: string, title?: string }>}
95
+ */
96
+ export function buildStoryStatusMap(stories) {
97
+ const out = {};
98
+ for (const entry of Array.isArray(stories) ? stories : []) {
99
+ const id = storyIdOf(entry);
100
+ if (id === null) continue;
101
+ const record = { status: 'pending' };
102
+ const title =
103
+ entry && typeof entry === 'object' && typeof entry.title === 'string'
104
+ ? entry.title
105
+ : undefined;
106
+ if (title) record.title = title;
107
+ out[String(id)] = record;
108
+ }
109
+ return out;
110
+ }
111
+
43
112
  /**
44
113
  * Read and parse the checkpoint. Returns null if the comment is missing or
45
114
  * unparseable (callers treat null as "start fresh").
@@ -60,7 +129,7 @@ export async function read({ provider, epicId } = {}) {
60
129
 
61
130
  /**
62
131
  * Overwrite the checkpoint with `state`. Idempotent — callers may invoke
63
- * freely per wave; the marker-scoped upsert deletes the prior comment.
132
+ * freely per tick; the marker-scoped upsert deletes the prior comment.
64
133
  *
65
134
  * @param {{ provider: import('../ITicketingProvider.js').ITicketingProvider, epicId: number, state: object }} opts
66
135
  */
@@ -78,38 +147,48 @@ export async function write({ provider, epicId, state } = {}) {
78
147
  }
79
148
 
80
149
  /**
81
- * Initial checkpoint for a brand-new run. Idempotent against re-dispatch
82
- * when the wave shape is unchanged. When an existing checkpoint is found
83
- * but the incoming `totalWaves` or `concurrencyCap` differs from the
84
- * persisted values, refresh those fields in place preserving
85
- * `currentWave`, `waves[]`, `blockerHistory`, `manualInterventions`,
86
- * `startedAt`, and any other already-persisted fields (e.g., `plan`,
87
- * `phase`). The `plan` field is owned by the prepare caller, which
88
- * overwrites it on every prepare run, so it does not need a delta check
89
- * here.
150
+ * Initial checkpoint for a brand-new run. Idempotent against re-dispatch:
151
+ * when an existing checkpoint is found and the persisted `concurrencyCap`
152
+ * matches the incoming value, the existing state is returned verbatim (no
153
+ * rewrite) so a re-prepare preserves `startedAt`, prior Story statuses, and
154
+ * `manualInterventions`. When the cap differs (an operator re-tuned the
155
+ * global in-flight cap) it is refreshed in place; the Story status map is
156
+ * **merged** so any Story that already reached a terminal status keeps it
157
+ * while newly-discovered Stories are seeded at `pending`. Prepare owns the
158
+ * Story set (it overwrites it on every run) but never clobbers recorded
159
+ * progress.
90
160
  *
91
- * @param {{ provider: import('../ITicketingProvider.js').ITicketingProvider, epicId: number, totalWaves: number, concurrencyCap: number }} opts
161
+ * @param {{
162
+ * provider: import('../ITicketingProvider.js').ITicketingProvider,
163
+ * epicId: number,
164
+ * storyIds: Array<object|number>,
165
+ * concurrencyCap: number,
166
+ * }} opts
167
+ * `concurrencyCap` is the GLOBAL in-flight cap the ready-set selector
168
+ * honours (`selectReadySet({ globalCap })`).
92
169
  */
93
170
  export async function initialize({
94
171
  provider,
95
172
  epicId,
96
- totalWaves,
173
+ storyIds,
97
174
  concurrencyCap,
98
175
  } = {}) {
99
176
  assertProvider(provider);
100
177
  assertEpicId(epicId);
178
+ const seededStories = buildStoryStatusMap(storyIds);
101
179
  const existing = await read({ provider, epicId });
102
180
  if (existing) {
181
+ const mergedStories = mergeStoryStatuses(existing.stories, seededStories);
103
182
  if (
104
- existing.totalWaves === totalWaves &&
105
- existing.concurrencyCap === concurrencyCap
183
+ existing.concurrencyCap === concurrencyCap &&
184
+ storyMapsEqual(existing.stories, mergedStories)
106
185
  ) {
107
186
  return existing;
108
187
  }
109
188
  return write({
110
189
  provider,
111
190
  epicId,
112
- state: { ...existing, totalWaves, concurrencyCap },
191
+ state: { ...existing, concurrencyCap, stories: mergedStories },
113
192
  });
114
193
  }
115
194
  return write({
@@ -118,119 +197,133 @@ export async function initialize({
118
197
  state: {
119
198
  epicId,
120
199
  startedAt: new Date().toISOString(),
121
- currentWave: 0,
122
- totalWaves,
123
200
  concurrencyCap,
124
201
  phase: 'prepare',
125
- waves: [],
126
- blockerHistory: [],
202
+ stories: seededStories,
127
203
  manualInterventions: [],
128
204
  },
129
205
  });
130
206
  }
131
207
 
132
208
  /**
133
- * Reconcile the resume pointer (`currentWave` + `waves[]` history) against
134
- * a freshly-recomputed wave plan.
135
- *
136
- * Story #3358 when `/deliver` is resumed on a partially-complete
137
- * Epic, `epic-deliver-prepare.js` recomputes the wave DAG over only the
138
- * **not-done** Stories (`build-wave-dag.js#discoverOpenStories` drops the
139
- * closed/merged Stories). The recomputed plan is therefore *shorter* and
140
- * **re-indexed from 0** — `plan[0]` is the next ready wave. The preserved
141
- * checkpoint, however, still carries the prior `currentWave` (e.g. `2`)
142
- * and a `waves[]` history keyed to the *old* index space. `wave-tick.js`
143
- * then indexes `plan[currentWave]` into the new plan and dispatches the
144
- * wrong wave — silently skipping the Stories that are actually ready.
145
- *
146
- * Prepare already owns the `plan` field (it overwrites it on every run),
147
- * so it must equally own the pointer that indexes into that plan. This
148
- * helper is the single point of reconciliation:
149
- *
150
- * - When the recomputed `nextPlan` is **structurally identical** to the
151
- * persisted `priorPlan` (an idempotent re-prepare with no Story
152
- * completed since the last run), the pointer is preserved verbatim so
153
- * in-flight wave progress is not lost.
154
- * - When the recomputed `nextPlan` **differs** (a Story merged → the
155
- * plan got shorter / re-indexed), the pointer is reset: `currentWave`
156
- * to `0` (the new plan's index space starts at the first not-done
157
- * wave) and `waves[]` to `[]` (the prior history references the old
158
- * index space and would mis-key `readGateFailures`).
159
- *
160
- * Plan equality is compared on the Story-id matrix only — `title` /
161
- * `worktree` churn on an otherwise-identical plan must not trip a reset.
209
+ * Merge a freshly-seeded Story status map onto a persisted one. Every Story
210
+ * present in either map appears in the result; when a Story exists in the
211
+ * prior map its recorded status / blockerCommentId win (recorded progress is
212
+ * never lost), while its `title` is refreshed from the incoming seed when the
213
+ * seed supplies one. Stories present only in the incoming seed are added at
214
+ * their seeded (`pending`) status. Pure — exported for unit tests.
162
215
  *
163
- * Pure function — no I/O, no provider, no side effects.
164
- *
165
- * @param {{
166
- * currentWave?: number,
167
- * waves?: Array<unknown>,
168
- * }} checkpoint The persisted checkpoint fields to reconcile.
169
- * @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} priorPlan
170
- * The plan currently persisted on the checkpoint (may be undefined on a
171
- * first run).
172
- * @param {Array<Array<{ id?: number, storyId?: number, number?: number }>>} nextPlan
173
- * The freshly-recomputed plan prepare is about to persist.
174
- * @returns {{ currentWave: number, waves: Array<unknown> }} The reconciled
175
- * pointer fields. Always returns concrete values so the caller can spread
176
- * them onto the checkpoint payload unconditionally.
216
+ * @param {Record<string, object>|undefined} prior
217
+ * @param {Record<string, object>} incoming
218
+ * @returns {Record<string, object>}
177
219
  */
178
- export function reconcileResumePointer(checkpoint, priorPlan, nextPlan) {
179
- const safeWaves = Array.isArray(checkpoint?.waves) ? checkpoint.waves : [];
180
- const currentWave = Number.isInteger(checkpoint?.currentWave)
181
- ? checkpoint.currentWave
182
- : 0;
183
- if (planStoryMatrixEqual(priorPlan, nextPlan)) {
184
- return { currentWave, waves: safeWaves };
220
+ export function mergeStoryStatuses(prior, incoming) {
221
+ const priorMap = prior && typeof prior === 'object' ? prior : {};
222
+ const seedMap = incoming && typeof incoming === 'object' ? incoming : {};
223
+ const out = {};
224
+ for (const key of new Set([
225
+ ...Object.keys(priorMap),
226
+ ...Object.keys(seedMap),
227
+ ])) {
228
+ const priorEntry = priorMap[key];
229
+ const seedEntry = seedMap[key];
230
+ if (priorEntry && typeof priorEntry === 'object') {
231
+ const merged = { ...priorEntry };
232
+ if (seedEntry && typeof seedEntry.title === 'string') {
233
+ merged.title = seedEntry.title;
234
+ }
235
+ out[key] = merged;
236
+ } else {
237
+ out[key] = seedEntry;
238
+ }
185
239
  }
186
- // Plan was recomputed (resume after a completed wave): the new plan is
187
- // 0-indexed over the remaining not-done waves. Reset the pointer and
188
- // drop the stale history so `wave-tick.js` reads `plan[0]`.
189
- return { currentWave: 0, waves: [] };
240
+ return out;
190
241
  }
191
242
 
192
243
  /**
193
- * Compare two wave plans on their Story-id matrix only. Each plan is
194
- * `Array<Array<{ id|storyId|number }>>`; equality requires the same wave
195
- * count, the same per-wave Story count, and the same Story ids in the same
196
- * positions. `title` / `worktree` fields are ignored so cosmetic churn on
197
- * an otherwise-identical plan does not register as a change.
244
+ * Structural equality on two Story status maps same key set and, per key,
245
+ * the same `status`, `title`, and `blockerCommentId`. Used by `initialize`
246
+ * to decide whether an idempotent re-prepare needs a rewrite. Pure.
198
247
  *
199
- * Pure helper for {@link reconcileResumePointer}.
200
- *
201
- * @param {Array<Array<object>>|undefined} a
202
- * @param {Array<Array<object>>|undefined} b
248
+ * @param {Record<string, object>|undefined} a
249
+ * @param {Record<string, object>|undefined} b
203
250
  * @returns {boolean}
204
251
  */
205
- function planStoryMatrixEqual(a, b) {
206
- const left = Array.isArray(a) ? a : [];
207
- const right = Array.isArray(b) ? b : [];
208
- if (left.length !== right.length) return false;
209
- for (let i = 0; i < left.length; i += 1) {
210
- const wl = Array.isArray(left[i]) ? left[i] : [];
211
- const wr = Array.isArray(right[i]) ? right[i] : [];
212
- if (wl.length !== wr.length) return false;
213
- for (let j = 0; j < wl.length; j += 1) {
214
- if (storyIdOf(wl[j]) !== storyIdOf(wr[j])) return false;
252
+ function storyMapsEqual(a, b) {
253
+ const left = a && typeof a === 'object' ? a : {};
254
+ const right = b && typeof b === 'object' ? b : {};
255
+ const keys = Object.keys(left);
256
+ if (keys.length !== Object.keys(right).length) return false;
257
+ for (const key of keys) {
258
+ const l = left[key] ?? {};
259
+ const r = right[key];
260
+ if (!r) return false;
261
+ if (
262
+ l.status !== r.status ||
263
+ l.title !== r.title ||
264
+ l.blockerCommentId !== r.blockerCommentId
265
+ ) {
266
+ return false;
215
267
  }
216
268
  }
217
269
  return true;
218
270
  }
219
271
 
220
272
  /**
221
- * Extract the Story id from a plan entry. Mirrors the resolution order
222
- * `wave-runner/tick.js#storyIdOf` uses so the equality check keys on the
223
- * same identity the tick dispatches against. Returns `null` for shapeless
224
- * entries so two `null`s never compare equal by accident.
273
+ * Record a per-Story terminal (or in-progress) status on the checkpoint.
274
+ * Reads the current state first, splices the single Story's record into the
275
+ * `stories` map, and re-writes. Other Stories and all run-level fields are
276
+ * preserved verbatim. Tolerant of a legacy/absent `stories` map (treated as
277
+ * empty) so a checkpoint that predates a field is upgraded in place.
225
278
  *
226
- * @param {object|number|null|undefined} entry
227
- * @returns {number|null}
279
+ * @param {{
280
+ * provider: import('../ITicketingProvider.js').ITicketingProvider,
281
+ * epicId: number,
282
+ * storyId: number,
283
+ * status: string,
284
+ * title?: string,
285
+ * blockerCommentId?: string|number|null,
286
+ * }} opts
287
+ * @returns {Promise<object>} the persisted state
228
288
  */
229
- function storyIdOf(entry) {
230
- if (typeof entry === 'number') return entry;
231
- if (!entry || typeof entry !== 'object') return null;
232
- const id = entry.id ?? entry.storyId ?? entry.number;
233
- return Number.isInteger(id) ? id : null;
289
+ export async function recordStoryStatus({
290
+ provider,
291
+ epicId,
292
+ storyId,
293
+ status,
294
+ title,
295
+ blockerCommentId,
296
+ } = {}) {
297
+ assertProvider(provider);
298
+ assertEpicId(epicId);
299
+ const id = storyIdOf(storyId);
300
+ if (id === null) {
301
+ throw new TypeError(
302
+ 'recordStoryStatus: storyId must be a positive integer',
303
+ );
304
+ }
305
+ if (!STORY_STATUSES.includes(status)) {
306
+ throw new RangeError(
307
+ `recordStoryStatus: status "${status}" must be one of: ${STORY_STATUSES.join(', ')}`,
308
+ );
309
+ }
310
+ const existing = (await read({ provider, epicId })) ?? {};
311
+ const stories =
312
+ existing.stories && typeof existing.stories === 'object'
313
+ ? { ...existing.stories }
314
+ : {};
315
+ const prior = stories[String(id)] ?? {};
316
+ const record = { ...prior, status };
317
+ if (typeof title === 'string' && title) record.title = title;
318
+ if (status === 'blocked' && blockerCommentId != null) {
319
+ record.blockerCommentId = String(blockerCommentId);
320
+ }
321
+ stories[String(id)] = record;
322
+ return write({
323
+ provider,
324
+ epicId,
325
+ state: { ...existing, stories },
326
+ });
234
327
  }
235
328
 
236
329
  /**
@@ -240,42 +240,33 @@ export async function renderProgressBody({
240
240
  /**
241
241
  * Render and upsert the rolled-up `epic-run-progress` comment on the Epic.
242
242
  *
243
- * Called by `/deliver` Step 2b (`epic-execute-record-wave.js`) after
244
- * each wave completes. The caller folds `state.waves[]` from the
245
- * `epic-run-state` checkpoint into the per-wave rows and persists the
246
- * unified rollup as a fenced-JSON payload on the Epic ticket via
247
- * `upsertStructuredComment`. There is no separate per-wave structured
248
- * comment `epic-run-progress` is the single operator-facing summary,
249
- * grouped by wave.
243
+ * Called by `/deliver`'s per-Story status recorder
244
+ * (`epic-execute-record-wave.js`) after each recorder beat. Story #4155
245
+ * (Epic #4151) the Epic `/deliver` runtime cut over from the wave-batch
246
+ * scheduler to the continuous ready-set core, so the rollup is a **flat
247
+ * per-Story table** keyed by the checkpoint's `stories` status map, not a
248
+ * wave-grouped table. There is no `currentWave` / `totalWaves` / `waves[]`
249
+ * in the payload any more.
250
250
  *
251
- * The payload schema is pinned by `epic-execute.md` Step 2b / tech spec
252
- * #902:
251
+ * The payload schema:
253
252
  *
254
253
  * {
255
254
  * "kind": "epic-run-progress",
256
255
  * "epicId": <number>,
257
- * "currentWave": <number>,
258
- * "totalWaves": <number>,
259
- * "waves": [ { wave, concurrencyCap?, stories[] } ],
256
+ * "stories": [ { id, title?, state, blockerCommentId? } ],
260
257
  * "startedAt"?: "<iso8601>",
261
258
  * "updatedAt": "<iso8601>"
262
259
  * }
263
260
  *
264
261
  * The function does not re-derive Story state from labels — it trusts the
265
- * `waves` argument supplied by the caller, which itself is the projection
266
- * of the validated, verified per-Story rows recorded on the checkpoint.
262
+ * `stories` map supplied by the caller (the checkpoint's recorded per-Story
263
+ * statuses).
267
264
  *
268
265
  * @param {{
269
266
  * provider: import('../../../ITicketingProvider.js').ITicketingProvider,
270
267
  * epicId: number,
271
- * waves: Array<{
272
- * wave: number,
273
- * concurrencyCap?: number,
274
- * stories?: Array<{ id: number, title?: string, state?: string,
275
- * blockerCommentId?: string }>,
276
- * }>,
277
- * currentWave: number,
278
- * totalWaves: number,
268
+ * stories: Record<string, { status?: string, title?: string,
269
+ * blockerCommentId?: string }>,
279
270
  * startedAt?: string,
280
271
  * now?: () => Date,
281
272
  * }} args
@@ -285,9 +276,7 @@ export async function renderProgressBody({
285
276
  export async function upsertEpicRunProgress({
286
277
  provider,
287
278
  epicId,
288
- waves,
289
- currentWave,
290
- totalWaves,
279
+ stories,
291
280
  startedAt,
292
281
  now = () => new Date(),
293
282
  } = {}) {
@@ -300,73 +289,44 @@ export async function upsertEpicRunProgress({
300
289
  if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) {
301
290
  throw new TypeError('upsertEpicRunProgress requires a numeric epicId');
302
291
  }
303
- const totalWavesNum = Number(totalWaves);
304
- if (!Number.isInteger(totalWavesNum) || totalWavesNum < 0) {
305
- throw new TypeError(
306
- 'upsertEpicRunProgress requires a non-negative integer totalWaves',
307
- );
308
- }
309
- const currentWaveNum = Number(currentWave);
310
- if (!Number.isInteger(currentWaveNum) || currentWaveNum < 0) {
311
- throw new TypeError(
312
- 'upsertEpicRunProgress requires a non-negative integer currentWave',
313
- );
314
- }
315
- const wavesArr = Array.isArray(waves) ? waves : [];
292
+ const statusMap = stories && typeof stories === 'object' ? stories : {};
316
293
 
317
294
  const updatedAt = now().toISOString();
318
- const normalizedWaves = wavesArr.map((w) => {
319
- const stories = Array.isArray(w?.stories) ? w.stories : [];
320
- const out = {
321
- wave: Number(w?.wave),
322
- stories,
323
- };
324
- if (Number.isInteger(w?.concurrencyCap)) {
325
- out.concurrencyCap = Number(w.concurrencyCap);
326
- }
327
- return out;
328
- });
295
+ const rows = Object.entries(statusMap)
296
+ .map(([key, rec]) => {
297
+ const id = Number(key);
298
+ const state = String(rec?.status ?? 'pending');
299
+ const row = { id, title: String(rec?.title ?? ''), state };
300
+ if (rec?.blockerCommentId != null) {
301
+ row.blockerCommentId = String(rec.blockerCommentId);
302
+ }
303
+ return row;
304
+ })
305
+ .filter((r) => Number.isInteger(r.id) && r.id > 0)
306
+ .sort((a, b) => a.id - b.id);
329
307
 
330
308
  const payload = {
331
309
  kind: EPIC_RUN_PROGRESS_TYPE,
332
310
  epicId: epicIdNum,
333
- currentWave: currentWaveNum,
334
- totalWaves: totalWavesNum,
335
- waves: normalizedWaves,
311
+ stories: rows,
336
312
  updatedAt,
337
313
  };
338
314
  if (typeof startedAt === 'string' && startedAt) {
339
315
  payload.startedAt = startedAt;
340
316
  }
341
317
 
342
- const totalStories = normalizedWaves.reduce(
343
- (acc, w) => acc + w.stories.length,
344
- 0,
345
- );
346
- const doneStories = normalizedWaves.reduce(
347
- (acc, w) => acc + w.stories.filter((s) => s?.state === 'done').length,
348
- 0,
349
- );
350
- const header = `### 📊 Epic Progress — Wave ${Math.min(currentWaveNum + 1, Math.max(totalWavesNum, 1))}/${totalWavesNum || '?'} · ${doneStories}/${totalStories} stories done`;
318
+ const totalStories = rows.length;
319
+ const doneStories = rows.filter((s) => s.state === 'done').length;
320
+ const header = `### 📊 Epic Progress — ${doneStories}/${totalStories} stories done`;
351
321
 
352
- const tableLines = ['| Wave | ID | State | Title |', '|---|---|---|---|'];
353
- if (normalizedWaves.length === 0) {
354
- tableLines.push('| — | — | _(no waves yet)_ | — |');
322
+ const tableLines = ['| ID | State | Title |', '|---|---|---|'];
323
+ if (rows.length === 0) {
324
+ tableLines.push('| — | _(no stories yet)_ | — |');
355
325
  } else {
356
- for (const w of normalizedWaves) {
357
- if (w.stories.length === 0) {
358
- tableLines.push(`| ${w.wave + 1} | — | _(empty wave)_ | — |`);
359
- continue;
360
- }
361
- for (const s of w.stories) {
362
- const state = String(s?.state ?? 'unknown');
363
- const emoji = STATE_EMOJI[state] ?? '';
364
- const id = Number(s?.id ?? 0);
365
- const title = escapePipes(truncate(String(s?.title ?? ''), 60));
366
- tableLines.push(
367
- `| ${w.wave + 1} | #${id} | ${emoji} ${state} | ${title} |`,
368
- );
369
- }
326
+ for (const s of rows) {
327
+ const emoji = STATE_EMOJI[s.state] ?? '';
328
+ const title = escapePipes(truncate(s.title, 60));
329
+ tableLines.push(`| #${s.id} | ${emoji} ${s.state} | ${title} |`);
370
330
  }
371
331
  }
372
332