mandrel 1.68.0 → 1.70.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 (75) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/agentrc-reference.json +1 -2
  3. package/.agents/docs/configuration.md +2 -4
  4. package/.agents/docs/workflows.md +1 -1
  5. package/.agents/schemas/agentrc.schema.json +1 -5
  6. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  7. package/.agents/scripts/agents-update-preflight.js +235 -0
  8. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  9. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  10. package/.agents/scripts/audit-to-stories.js +54 -0
  11. package/.agents/scripts/bootstrap.js +13 -3
  12. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  13. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  14. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  15. package/.agents/scripts/generate-config-docs.js +189 -94
  16. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  17. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  18. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  19. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  20. package/.agents/scripts/lib/baselines/refresh-service.js +13 -5
  21. package/.agents/scripts/lib/config/baselines.js +0 -20
  22. package/.agents/scripts/lib/config/explain.js +0 -2
  23. package/.agents/scripts/lib/config/limits.js +19 -8
  24. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  25. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  26. package/.agents/scripts/lib/crap-utils.js +281 -0
  27. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  28. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  29. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  30. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  31. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  32. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  33. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -162
  34. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  35. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  36. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  37. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  38. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  39. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  40. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  41. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  42. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  43. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  44. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  45. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  46. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  47. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  48. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  49. package/.agents/scripts/lib/story-adjacency.js +14 -10
  50. package/.agents/scripts/lib/story-body/story-body.js +142 -65
  51. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  52. package/.agents/scripts/lib/test-tiers.js +13 -7
  53. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  54. package/.agents/scripts/lib/wave-runner/tick.js +446 -216
  55. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  56. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  57. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  58. package/.agents/scripts/providers/github/issues.js +48 -0
  59. package/.agents/scripts/providers/github.js +1 -0
  60. package/.agents/scripts/stories-wave-tick.js +262 -161
  61. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  62. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  63. package/.agents/skills/skills.index.json +2 -2
  64. package/.agents/workflows/agents-update.md +205 -28
  65. package/.agents/workflows/deliver.md +12 -9
  66. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  67. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  68. package/.agents/workflows/helpers/plan-epic.md +13 -10
  69. package/.agents/workflows/plan.md +1 -1
  70. package/README.md +20 -0
  71. package/docs/CHANGELOG.md +46 -0
  72. package/lib/cli/registry.js +49 -6
  73. package/lib/cli/update.js +335 -332
  74. package/package.json +16 -11
  75. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -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
  /**
@@ -37,20 +37,6 @@ export function escapePipes(s) {
37
37
  return String(s).replace(/\|/g, '\\|');
38
38
  }
39
39
 
40
- /**
41
- * Render a millisecond duration as a compact human-readable string. Used
42
- * in the wave-elapsed header to keep the snapshot tight.
43
- */
44
- export function formatElapsed(ms) {
45
- const s = Math.max(0, Math.floor(ms / 1000));
46
- const h = Math.floor(s / 3600);
47
- const m = Math.floor((s % 3600) / 60);
48
- const sec = s % 60;
49
- if (h) return `${h}h ${m}m`;
50
- if (m) return `${m}m ${sec}s`;
51
- return `${sec}s`;
52
- }
53
-
54
40
  /**
55
41
  * Derive the high-level state classification for a single ticket. Reads
56
42
  * the canonical `agent::*` label set first, then falls back to the GitHub
@@ -69,35 +55,6 @@ export function deriveState(ticket, AGENT_LABELS) {
69
55
  return 'unknown';
70
56
  }
71
57
 
72
- /**
73
- * Build the markdown table for the per-poll progress snapshot. Switches
74
- * between a 3-column (ID / State / Title) and a 4-column (Wave / ID /
75
- * State / Title) form depending on whether any row carries a wave index —
76
- * the wider form fires when the reporter has a plan set and is rendering
77
- * every wave instead of just the current one.
78
- */
79
- export function renderProgressTable(rows) {
80
- const includeWaveCol = rows.some((r) => Number.isInteger(r.wave));
81
- if (includeWaveCol) {
82
- return [
83
- '| Wave | ID | State | Title |',
84
- '|---|---|---|---|',
85
- ...rows.map(
86
- (r) =>
87
- `| ${r.wave + 1} | #${r.id} | ${STATE_EMOJI[r.state] ?? ''} ${r.state} | ${escapePipes(r.title)} |`,
88
- ),
89
- ].join('\n');
90
- }
91
- return [
92
- '| ID | State | Title |',
93
- '|---|---|---|',
94
- ...rows.map(
95
- (r) =>
96
- `| #${r.id} | ${STATE_EMOJI[r.state] ?? ''} ${r.state} | ${escapePipes(r.title)} |`,
97
- ),
98
- ].join('\n');
99
- }
100
-
101
58
  /**
102
59
  * Declarative descriptor table that drives the Notable bullet block. Each
103
60
  * descriptor names a row state, the emoji prefix to render, and the
@@ -196,86 +153,36 @@ export async function renderNotable({ rows, detectors = [], wave, logger }) {
196
153
  return items.join('\n');
197
154
  }
198
155
 
199
- /**
200
- * Compose the full per-poll snapshot body. Pure with respect to the
201
- * supplied state — the caller passes the resolved `rows`, the plan/wave
202
- * context, the optional aggregated phase-timings block, and the now()
203
- * clock; the renderer assembles header + table + Notable + phase-timings.
204
- */
205
- export async function renderProgressBody({
206
- rows,
207
- plan,
208
- currentWave,
209
- epicStartedAt,
210
- now,
211
- detectors,
212
- phaseSummariesBlock,
213
- logger,
214
- }) {
215
- const done = rows.filter((r) => r.state === 'done').length;
216
- const total = rows.length;
217
- const totalWaves = plan?.length ?? currentWave?.totalWaves ?? '?';
218
- const currentWaveNum = currentWave
219
- ? currentWave.index + 1
220
- : (plan?.length ?? '?');
221
- const waveLabel = `Wave ${currentWaveNum}/${totalWaves}`;
222
- const elapsedSrc = epicStartedAt ?? currentWave?.startedAt ?? null;
223
- const elapsed = elapsedSrc
224
- ? ` · ${formatElapsed(now() - new Date(elapsedSrc))} elapsed`
225
- : '';
226
-
227
- const header = `### 📊 Progress — ${waveLabel} · ${done}/${total} closed${elapsed}`;
228
- const table = renderProgressTable(rows);
229
- const notable = await renderNotable({
230
- rows,
231
- detectors,
232
- wave: currentWave,
233
- logger,
234
- });
235
- const parts = [header, '', table, '', '**Notable**', notable];
236
- if (phaseSummariesBlock) parts.push('', phaseSummariesBlock);
237
- return parts.join('\n');
238
- }
239
-
240
156
  /**
241
157
  * Render and upsert the rolled-up `epic-run-progress` comment on the Epic.
242
158
  *
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.
159
+ * Called by `/deliver`'s per-Story status recorder
160
+ * (`epic-execute-record-wave.js`) after each recorder beat. Story #4155
161
+ * (Epic #4151) the Epic `/deliver` runtime cut over from the wave-batch
162
+ * scheduler to the continuous ready-set core, so the rollup is a **flat
163
+ * per-Story table** keyed by the checkpoint's `stories` status map, not a
164
+ * wave-grouped table. There is no `currentWave` / `totalWaves` / `waves[]`
165
+ * in the payload any more.
250
166
  *
251
- * The payload schema is pinned by `epic-execute.md` Step 2b / tech spec
252
- * #902:
167
+ * The payload schema:
253
168
  *
254
169
  * {
255
170
  * "kind": "epic-run-progress",
256
171
  * "epicId": <number>,
257
- * "currentWave": <number>,
258
- * "totalWaves": <number>,
259
- * "waves": [ { wave, concurrencyCap?, stories[] } ],
172
+ * "stories": [ { id, title?, state, blockerCommentId? } ],
260
173
  * "startedAt"?: "<iso8601>",
261
174
  * "updatedAt": "<iso8601>"
262
175
  * }
263
176
  *
264
177
  * 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.
178
+ * `stories` map supplied by the caller (the checkpoint's recorded per-Story
179
+ * statuses).
267
180
  *
268
181
  * @param {{
269
182
  * provider: import('../../../ITicketingProvider.js').ITicketingProvider,
270
183
  * 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,
184
+ * stories: Record<string, { status?: string, title?: string,
185
+ * blockerCommentId?: string }>,
279
186
  * startedAt?: string,
280
187
  * now?: () => Date,
281
188
  * }} args
@@ -285,9 +192,7 @@ export async function renderProgressBody({
285
192
  export async function upsertEpicRunProgress({
286
193
  provider,
287
194
  epicId,
288
- waves,
289
- currentWave,
290
- totalWaves,
195
+ stories,
291
196
  startedAt,
292
197
  now = () => new Date(),
293
198
  } = {}) {
@@ -300,73 +205,44 @@ export async function upsertEpicRunProgress({
300
205
  if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) {
301
206
  throw new TypeError('upsertEpicRunProgress requires a numeric epicId');
302
207
  }
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 : [];
208
+ const statusMap = stories && typeof stories === 'object' ? stories : {};
316
209
 
317
210
  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
- });
211
+ const rows = Object.entries(statusMap)
212
+ .map(([key, rec]) => {
213
+ const id = Number(key);
214
+ const state = String(rec?.status ?? 'pending');
215
+ const row = { id, title: String(rec?.title ?? ''), state };
216
+ if (rec?.blockerCommentId != null) {
217
+ row.blockerCommentId = String(rec.blockerCommentId);
218
+ }
219
+ return row;
220
+ })
221
+ .filter((r) => Number.isInteger(r.id) && r.id > 0)
222
+ .sort((a, b) => a.id - b.id);
329
223
 
330
224
  const payload = {
331
225
  kind: EPIC_RUN_PROGRESS_TYPE,
332
226
  epicId: epicIdNum,
333
- currentWave: currentWaveNum,
334
- totalWaves: totalWavesNum,
335
- waves: normalizedWaves,
227
+ stories: rows,
336
228
  updatedAt,
337
229
  };
338
230
  if (typeof startedAt === 'string' && startedAt) {
339
231
  payload.startedAt = startedAt;
340
232
  }
341
233
 
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`;
234
+ const totalStories = rows.length;
235
+ const doneStories = rows.filter((s) => s.state === 'done').length;
236
+ const header = `### 📊 Epic Progress — ${doneStories}/${totalStories} stories done`;
351
237
 
352
- const tableLines = ['| Wave | ID | State | Title |', '|---|---|---|---|'];
353
- if (normalizedWaves.length === 0) {
354
- tableLines.push('| — | — | _(no waves yet)_ | — |');
238
+ const tableLines = ['| ID | State | Title |', '|---|---|---|'];
239
+ if (rows.length === 0) {
240
+ tableLines.push('| — | _(no stories yet)_ | — |');
355
241
  } 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
- }
242
+ for (const s of rows) {
243
+ const emoji = STATE_EMOJI[s.state] ?? '';
244
+ const title = escapePipes(truncate(s.title, 60));
245
+ tableLines.push(`| #${s.id} | ${emoji} ${s.state} | ${title} |`);
370
246
  }
371
247
  }
372
248