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
@@ -38,10 +38,10 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
38
38
 
39
39
  /**
40
40
  * Fire a curated `epic-progress` webhook event. Event-driven only — called
41
- * at wave boundaries and after blocker raise/clear transitions. Carries
42
- * the rollup payload `{ pct, done, total, currentWave, totalWaves, phase,
43
- * openBlockers }`, which Slack consumers and downstream subscribers use to
44
- * track epic progress without subscribing to per-story chatter.
41
+ * per recorder beat and after blocker raise/clear transitions. Carries the
42
+ * rollup payload `{ pct, done, total, phase, openBlockers }`, which Slack
43
+ * consumers and downstream subscribers use to track epic progress without
44
+ * subscribing to per-story chatter.
45
45
  *
46
46
  * The dispatch passes `skipComment: true` — the operator-facing GitHub
47
47
  * comment is owned by `ProgressReporter.fire()` and `upsertEpicRunProgress`,
@@ -50,13 +50,17 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
50
50
  * Failures are swallowed by design: the runner must keep moving even if
51
51
  * the webhook URL is misconfigured or the network is flaky.
52
52
  *
53
+ * Story #4155 — the Epic `/deliver` runtime cut over to the continuous
54
+ * ready-set scheduler, which has **no wave index**. The wave segment was
55
+ * dropped from the message and the rollup payload entirely (rather than
56
+ * rendered as `Wave undefined/undefined`); the sole live caller
57
+ * (`wave-record-notifications.js`) never supplied wave coordinates.
58
+ *
53
59
  * @param {{
54
60
  * notify: Function|null,
55
61
  * epicId: number,
56
62
  * done: number,
57
63
  * total: number,
58
- * currentWave: number,
59
- * totalWaves: number,
60
64
  * phase?: string,
61
65
  * openBlockers?: Array<{ reason: string, storyId?: number }>,
62
66
  * logger?: { warn?: Function },
@@ -68,8 +72,6 @@ export async function emitEpicProgress({
68
72
  epicId,
69
73
  done,
70
74
  total,
71
- currentWave,
72
- totalWaves,
73
75
  phase,
74
76
  openBlockers = [],
75
77
  logger,
@@ -85,7 +87,7 @@ export async function emitEpicProgress({
85
87
  blockerCount > 0
86
88
  ? ` · 🚧 ${blockerCount} blocker${blockerCount === 1 ? '' : 's'}`
87
89
  : '';
88
- const message = `Epic #${epicIdNum} progress · Wave ${currentWave}/${totalWaves} · ${doneN}/${totalN} stories done (${pct}%)${blockerSuffix}`;
90
+ const message = `Epic #${epicIdNum} progress · ${doneN}/${totalN} stories done (${pct}%)${blockerSuffix}`;
89
91
 
90
92
  const payload = {
91
93
  severity: blockerCount > 0 ? 'high' : 'medium',
@@ -109,8 +111,6 @@ export async function emitEpicProgress({
109
111
  pct,
110
112
  done: doneN,
111
113
  total: totalN,
112
- currentWave,
113
- totalWaves,
114
114
  phase,
115
115
  openBlockers: openBlockers ?? [],
116
116
  },
@@ -121,11 +121,14 @@ export async function emitEpicProgress({
121
121
  * Fire a curated `epic-started` webhook event at /deliver kickoff.
122
122
  * The Slack consumer anchors the rest of the epic narrative to this fire.
123
123
  * Failures are swallowed.
124
+ *
125
+ * Story #4155 — the ready-set runtime has no wave count, so the wave
126
+ * segment was dropped from the message (rather than rendered as
127
+ * `undefined wave(s)`); the sole live caller never supplied one.
124
128
  */
125
129
  export async function emitEpicStarted({
126
130
  notify,
127
131
  epicId,
128
- totalWaves,
129
132
  totalStories,
130
133
  title,
131
134
  logger,
@@ -133,7 +136,7 @@ export async function emitEpicStarted({
133
136
  if (typeof notify !== 'function') return null;
134
137
  const epicIdNum = Number(epicId);
135
138
  if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) return null;
136
- const message = `Epic #${epicIdNum} started · ${totalWaves} wave${totalWaves === 1 ? '' : 's'} · ${totalStories} stor${totalStories === 1 ? 'y' : 'ies'}${title ? ` — ${title}` : ''}`;
139
+ const message = `Epic #${epicIdNum} started · ${totalStories} stor${totalStories === 1 ? 'y' : 'ies'}${title ? ` — ${title}` : ''}`;
137
140
  try {
138
141
  await notify(
139
142
  epicIdNum,
@@ -241,26 +241,29 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
241
241
 
242
242
  /**
243
243
  * Render a single friction-comment body listing every malformed sub-agent
244
- * return for a given wave. Pure helper — no provider call. Exposed so tests
245
- * can pin the body shape.
244
+ * return for a recorder beat. Pure helper — no provider call. Exposed so
245
+ * tests can pin the body shape.
246
+ *
247
+ * Story #4155 — under the ready-set runtime there is no wave index; the
248
+ * recorder records the Stories it was handed, so the body is keyed by Epic
249
+ * only.
246
250
  *
247
251
  * @param {{
248
252
  * epicId: number,
249
- * wave: number,
250
253
  * failures: Array<{ storyId: number, error: string, returnText: string }>,
251
254
  * }} args
252
255
  * @returns {string}
253
256
  */
254
- export function renderMalformedReturnsFriction({ epicId, wave, failures }) {
257
+ export function renderMalformedReturnsFriction({ epicId, failures }) {
255
258
  const lines = [
256
- `### 🚧 epic-execute friction — Epic #${epicId}, wave ${wave}`,
259
+ `### 🚧 epic-execute friction — Epic #${epicId}`,
257
260
  '',
258
261
  `**Reason:** \`malformed-subagent-return\``,
259
262
  '',
260
263
  `${failures.length} sub-agent return(s) did not match the /deliver return contract.`,
261
264
  'Each Story below was reconciled from GitHub (labels + `story-run-progress`)',
262
- 'and its wave-row downgraded to `failed` unless the live ticket already carried',
263
- '`agent::done`.',
265
+ 'and its recorded status downgraded to `failed` unless the live ticket',
266
+ 'already carried `agent::done`.',
264
267
  '',
265
268
  ];
266
269
  for (const f of failures) {
@@ -21,7 +21,7 @@
21
21
  * is a hard block evaluated BEFORE the structured-signal evaluator.
22
22
  *
23
23
  * Either trigger evaluates the same verdict: if `evaluateAutoMergePredicate`
24
- * reports `clean: true` (no manual interventions, no incomplete waves,
24
+ * reports `clean: true` (no manual interventions, every Story done,
25
25
  * no story blockers, no critical/high review findings, machine-readable
26
26
  * "clean sprint" retro trailer), emit `epic.merge.ready`. Otherwise emit
27
27
  * `epic.merge.blocked` with a non-empty reason.
@@ -172,38 +172,51 @@ function evaluateStateSignals(state, reasons) {
172
172
  .join('; ')}${interventionCount > 3 ? '; …' : ''}`,
173
173
  );
174
174
  }
175
- const waves = Array.isArray(state?.waves) ? state.waves : [];
176
- const waveStatuses = waves.map((w) => w.status ?? 'unknown');
177
- const nonCompleteWaves = waveStatuses.filter((s) => s !== 'complete');
178
- if (nonCompleteWaves.length > 0) {
175
+ // Story #4155 the ready-set runtime records a flat per-Story status map
176
+ // on the checkpoint (`stories: { [id]: { status, blockerCommentId? } }`)
177
+ // instead of a per-wave `waves[]` history. The clean-run certification
178
+ // reads it directly: a run is clean only when every Story reached `done`
179
+ // and none carries a recorded blocker comment.
180
+ const stories =
181
+ state?.stories && typeof state.stories === 'object' ? state.stories : {};
182
+ const storyStatuses = Object.values(stories).map(
183
+ (s) => s?.status ?? 'pending',
184
+ );
185
+ const nonDoneStatuses = storyStatuses.filter((s) => s !== 'done');
186
+ if (nonDoneStatuses.length > 0) {
179
187
  reasons.push(
180
- `${nonCompleteWaves.length} wave(s) not complete (statuses: ${nonCompleteWaves.join(', ')})`,
188
+ `${nonDoneStatuses.length} story(ies) not done (statuses: ${nonDoneStatuses.join(', ')})`,
181
189
  );
182
190
  }
183
- const storyBlockers = countStoryBlockers(waves);
191
+ const storyBlockers = countStoryBlockers(stories);
184
192
  if (storyBlockers > 0) {
185
193
  reasons.push(
186
194
  `${storyBlockers} story-level blocker(s) recorded in run-state`,
187
195
  );
188
196
  }
189
- return { interventionCount, waveStatuses, storyBlockers };
197
+ return { interventionCount, storyStatuses, storyBlockers };
190
198
  }
191
199
 
192
- function countStoryBlockers(waves) {
200
+ /**
201
+ * Count blockers in the flat per-Story `stories` status map: each Story with
202
+ * a recorded `blockerCommentId` and each Story whose status is not `done`
203
+ * contributes one blocker (matching the prior per-wave count semantics).
204
+ *
205
+ * @param {Record<string, { status?: string, blockerCommentId?: string }>} stories
206
+ * @returns {number}
207
+ */
208
+ function countStoryBlockers(stories) {
193
209
  let blockers = 0;
194
- for (const w of waves) {
195
- if (!Array.isArray(w.stories)) continue;
196
- for (const s of w.stories) {
197
- if (
198
- s &&
199
- typeof s.blockerCommentId === 'string' &&
200
- s.blockerCommentId.length > 0
201
- ) {
202
- blockers += 1;
203
- }
204
- if (s?.status && s.status !== 'done') {
205
- blockers += 1;
206
- }
210
+ for (const s of Object.values(stories ?? {})) {
211
+ if (
212
+ s &&
213
+ typeof s.blockerCommentId === 'string' &&
214
+ s.blockerCommentId.length > 0
215
+ ) {
216
+ blockers += 1;
217
+ }
218
+ if (s?.status && s.status !== 'done') {
219
+ blockers += 1;
207
220
  }
208
221
  }
209
222
  return blockers;
@@ -271,7 +284,7 @@ function evaluateRetroSignals(retro, reasons) {
271
284
  * reasons: string[],
272
285
  * signals: {
273
286
  * manualInterventions: number,
274
- * waveStatuses: string[],
287
+ * storyStatuses: string[],
275
288
  * storyBlockers: number,
276
289
  * severity: { critical: number|null, high: number|null, medium: number|null, suggestion: number|null },
277
290
  * retroCompact: boolean,
@@ -292,7 +305,7 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
292
305
  reasons,
293
306
  signals: {
294
307
  manualInterventions: stateSig.interventionCount,
295
- waveStatuses: stateSig.waveStatuses,
308
+ storyStatuses: stateSig.storyStatuses,
296
309
  storyBlockers: stateSig.storyBlockers,
297
310
  severity: reviewSig.severity,
298
311
  retroCompact: retroSig.retroCompact,
@@ -139,6 +139,12 @@ function buildStoryOnlyManifest(stories, epicId) {
139
139
  type: 'story',
140
140
  branchName: getStoryBranch(epicId, story.id),
141
141
  earliestWave,
142
+ // Carry the resolved cross-Story dependency edges on the entry so the
143
+ // presentation layer can derive grouping depth at render time via
144
+ // `assignLayers` (Story #4157) instead of trusting the persisted
145
+ // `earliestWave`. This is the same `explicitStoryDeps` set the wave
146
+ // computation consumed, already closed over the scheduled Story set.
147
+ dependsOn: explicitStoryDeps.get(story.id) ?? [],
142
148
  tasks: [],
143
149
  };
144
150
  });
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
20
20
  * uniform relaxed profile):
21
- * - Flat knobs: `softFiles` (~8), `hardFiles` (~30), `maxAcceptance` (~14),
21
+ * - Flat knobs: `softFiles` (~15), `hardFiles` (~30), `maxAcceptance` (~14),
22
22
  * `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
23
23
  * `testSurface` axis, no selector and no second profile.
24
24
  * - The four-profile `sizingProfile` enum is replaced by a single optional
@@ -32,7 +32,11 @@
32
32
 
33
33
  export const DEFAULT_TASK_SIZING = Object.freeze({
34
34
  // Typical-Story warning thresholds (soft — emit advisory findings).
35
- softFiles: 8,
35
+ // Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
36
+ // touches a dozen-plus files for one cohesive reason, so the advisory width
37
+ // nudge fired far too eagerly and biased the decomposer toward over-slicing.
38
+ // The hard `hardFiles` rejection (30) is unchanged.
39
+ softFiles: 15,
36
40
  softAcceptanceCount: 10,
37
41
  // Hard ceilings (rejection unless lifted).
38
42
  hardFiles: 30,
@@ -1,25 +1,23 @@
1
1
  /**
2
- * wave-record-io.js — impure helpers for the record-wave CLI: ticket
3
- * verification, manifest title lookup, and returns reconciliation.
2
+ * wave-record-io.js — impure helpers for the per-Story status recorder CLI
3
+ * (`epic-execute-record-wave.js`): ticket verification, manifest title
4
+ * lookup, and returns reconciliation.
4
5
  *
5
6
  * These functions all hit the provider and are intentionally kept out of
6
- * `wave-record-projection.js`, which is the pure projection layer. The
7
- * parent CLI imports both modules and threads the I/O results through
8
- * the projection.
7
+ * `wave-record-projection.js`, which is the pure helper layer. The parent
8
+ * CLI imports both modules and threads the I/O results through the
9
+ * projection.
9
10
  *
10
- * Every entry point here is fire-and-forget on the "best-effort" surfaces
11
- * (manifest title lookup) and explicit-throw on the authoritative ones
12
- * (`verifyWaveResults` and `resolveResolvedResults` decide what `complete`
13
- * actually means after the network call lands).
11
+ * Story #4155 (Epic #4151) the wave-batch livelock-recovery path
12
+ * (`planStoryIdsForWave` + the empty-mode-B reconcile) was deleted along
13
+ * with the wave checkpoint it keyed off. Under the ready-set runtime a
14
+ * Story's terminal state is always re-derivable from its live label on the
15
+ * next `tick`, so there is no falsely-`complete` empty wave to recover from
16
+ * — the recorder records exactly the Stories it was handed.
14
17
  *
15
- * Story #3909 — the per-wave dispatch-manifest refresh hop
16
- * (`refreshDispatchManifest`) was deleted. It re-ran the full dispatch
17
- * pipeline (re-fetch every ticket, recompute waves) on every wave tick
18
- * only to re-render the `dispatch-manifest` comment, which nothing reads
19
- * for control flow — `loadManifestTitleMap` reads it for rollup-row titles,
20
- * and those are fixed at plan time. The manifest is now written once at
21
- * `epic-deliver-prepare` time and left frozen; the surviving operator-facing
22
- * surface is the `epic-run-progress` rollup the record-wave CLI re-renders.
18
+ * Story #3909 — the per-wave dispatch-manifest refresh hop was deleted; the
19
+ * manifest is written once at `epic-deliver-prepare` time and left frozen.
20
+ * `loadManifestTitleMap` reads it for rollup-row titles.
23
21
  */
24
22
 
25
23
  import { Logger } from '../Logger.js';
@@ -112,14 +110,14 @@ async function verifySingleResult(r, provider) {
112
110
  * stale cache cannot mask the discrepancy. A network failure during
113
111
  * verification cannot prove the claim either way, so the row is
114
112
  * downgraded to `failed` and a `verify-error` discrepancy is recorded —
115
- * an unverifiable `done` must not let the wave aggregate to `complete`,
116
- * which is what callers read as "GitHub agrees everything is done."
113
+ * an unverifiable `done` must not be recorded as `done`, which is what the
114
+ * auto-merge predicate reads as "GitHub agrees everything is done."
117
115
  *
118
116
  * Story #3024 — verification runs through {@link concurrentMap} under a
119
117
  * bounded cap (default 4, override via
120
118
  * `delivery.deliverRunner.verifyConcurrencyCap`). Per-row failures are
121
119
  * captured inside the mapper so one Story's `getTicket` throw cannot
122
- * abort the whole wave — the per-row try/catch lives in
120
+ * abort the whole batch — the per-row try/catch lives in
123
121
  * {@link verifySingleResult} and turns into a `verify-error`
124
122
  * discrepancy rather than a rejected mapper. Input order is preserved
125
123
  * (mapper-index → output-index), matching the previous serial behaviour.
@@ -183,84 +181,27 @@ export async function loadManifestTitleMap({ provider, epicId }) {
183
181
  }
184
182
  }
185
183
 
186
- /**
187
- * Extract the Story IDs planned for `wave` from the checkpoint `plan`
188
- * (`Story[][]` indexed by wave). Returns `[]` when the plan is missing or
189
- * the wave index is out of range. Pure helper — exported for unit tests.
190
- *
191
- * Story #3907 — the wave-complete livelock recovery (below) keys off this:
192
- * when mode B records a wave with **no** child returns (the host crashed
193
- * after the children finished but before `record-wave` ran), every Story in
194
- * `plan[wave]` is reconciled from GitHub so the wave can record and
195
- * `currentWave` can advance instead of returning `wave-complete` for the same
196
- * index forever.
197
- *
198
- * @param {object} existing Checkpoint state.
199
- * @param {number} wave
200
- * @returns {number[]}
201
- */
202
- export function planStoryIdsForWave(existing, wave) {
203
- const plan = Array.isArray(existing?.plan) ? existing.plan : [];
204
- const entries = Array.isArray(plan[wave]) ? plan[wave] : [];
205
- const ids = [];
206
- for (const entry of entries) {
207
- const id =
208
- typeof entry === 'number'
209
- ? entry
210
- : Number(entry?.id ?? entry?.storyId ?? entry?.number);
211
- if (Number.isInteger(id) && id > 0) ids.push(id);
212
- }
213
- return ids;
214
- }
215
-
216
184
  /**
217
185
  * Parse / reconcile the per-Story returns (or pass `results` through). Posts
218
186
  * a single rolled-up friction comment listing every malformed return on
219
187
  * failure — non-fatal if the post itself fails.
220
188
  *
221
- * Story #3907 — mode B with an **empty** `returns` array is the
222
- * wave-complete-livelock recovery path: the host crashed after the wave's
223
- * children finished but before `record-wave` ran, so no return text survives.
224
- * Rather than recording an empty (falsely-`complete`) wave, every Story in
225
- * `plan[wave]` is reconciled from GitHub via {@link reconcileStoryFromGitHub}
226
- * so the recorded wave reflects the live ticket state. This requires the
227
- * caller to thread the checkpoint `existing` so the wave's planned Story set
228
- * is known; without it the empty array degrades to the previous behaviour.
229
- *
230
189
  * @returns {Promise<{ resolvedResults: Array, parseFailures: Array }>}
231
190
  */
232
191
  export async function resolveResolvedResults({
233
192
  provider,
234
193
  epicId,
235
- wave,
236
194
  results,
237
195
  returns,
238
- existing,
239
196
  }) {
240
197
  if (returns == null) {
241
198
  return { resolvedResults: results, parseFailures: [] };
242
199
  }
243
- if (Array.isArray(returns) && returns.length === 0 && existing) {
244
- const ids = planStoryIdsForWave(existing, wave);
245
- if (ids.length > 0) {
246
- const resolvedResults = await concurrentMap(
247
- ids,
248
- (storyId) => reconcileStoryFromGitHub({ provider, storyId }),
249
- { concurrency: DEFAULT_VERIFY_CONCURRENCY_CAP },
250
- );
251
- Logger.warn(
252
- `[wave-record-io] Wave ${wave} recorded with no child returns; ` +
253
- `reconciled ${ids.length} Story(ies) from GitHub (livelock recovery).`,
254
- );
255
- return { resolvedResults, parseFailures: [] };
256
- }
257
- }
258
200
  const normalized = await normalizeReturns({ provider, returns });
259
201
  if (normalized.parseFailures.length > 0) {
260
202
  try {
261
203
  const body = renderMalformedReturnsFriction({
262
204
  epicId,
263
- wave,
264
205
  failures: normalized.parseFailures,
265
206
  });
266
207
  await postStructuredComment(provider, epicId, 'friction', body);
@@ -1,14 +1,15 @@
1
1
  /**
2
- * wave-record-notifications.js — webhook-emit helpers extracted from
3
- * `epic-execute-record-wave.js`.
2
+ * wave-record-notifications.js — webhook-emit helpers for the per-Story
3
+ * status recorder CLI (`epic-execute-record-wave.js`).
4
4
  *
5
- * The CLI fires curated webhook events at every wave boundary (started,
6
- * progress, blocked, unblocked) so the host-LLM-driven `/deliver` path
7
- * mirrors the wave-loop emits in
8
- * `lib/orchestration/epic-runner/phases/iterate-waves.js`. Each helper here
9
- * is fire-and-forget webhook misconfig or a transient Slack outage must
10
- * not block the wave loop so the impure surface is small (the inbound
11
- * `notifyFn` closure) and the rest is plain control flow.
5
+ * Story #4155 (Epic #4151) the Epic `/deliver` runtime cut over from the
6
+ * wave-batch scheduler to the continuous ready-set core, so these emits are
7
+ * no longer keyed to a wave boundary. The recorder fires curated webhook
8
+ * events per recorder beat: `epic-started` once (on the first recorded
9
+ * Story), `epic-progress` with the run's done/total counts (re-derived from
10
+ * the checkpoint's flat per-Story `stories` map), and `epic-blocked` when a
11
+ * Story in this beat blocked or failed. Each helper is fire-and-forget
12
+ * webhook misconfig or a transient Slack outage must not block the loop.
12
13
  *
13
14
  * These helpers stay in their own module to keep the parent CLI a thin
14
15
  * runner shell. They are not part of the pure projection layer; they
@@ -20,9 +21,7 @@ import {
20
21
  emitEpicBlocked,
21
22
  emitEpicProgress,
22
23
  emitEpicStarted,
23
- emitEpicUnblocked,
24
24
  } from './epic-runner/progress-reporter/transport.js';
25
- import { countDoneStories } from './wave-record-projection.js';
26
25
 
27
26
  /**
28
27
  * Build the notify-bound closure used by the curated webhook emitters. When
@@ -41,25 +40,53 @@ export function buildNotifyFn(injectedNotify, config, provider, defaultNotify) {
41
40
  }
42
41
 
43
42
  /**
44
- * Fire the curated webhook events for a wave boundary. Each emit is
43
+ * Count Stories in a terminal `done` state across the checkpoint's flat
44
+ * per-Story `stories` status map. Pure helper.
45
+ *
46
+ * @param {Record<string, { status?: string }>|undefined} stories
47
+ * @returns {number}
48
+ */
49
+ export function countDoneStories(stories) {
50
+ const map = stories && typeof stories === 'object' ? stories : {};
51
+ let done = 0;
52
+ for (const rec of Object.values(map)) {
53
+ if (rec?.status === 'done') done += 1;
54
+ }
55
+ return done;
56
+ }
57
+
58
+ /**
59
+ * Fire the curated webhook events for a recorder beat. Each emit is
45
60
  * fire-and-forget (the emit helpers swallow webhook misconfiguration), but
46
- * we still serialise them so the order matches the wave-loop emits in
47
- * `lib/orchestration/epic-runner/phases/iterate-waves.js` for the host-LLM
48
- * driven /deliver path.
61
+ * we still serialise them so the order is deterministic.
62
+ *
63
+ * - `epic-started` fires exactly once: on the very first recorded Story
64
+ * (signalled by `firstRecord === true`), before any Story has been
65
+ * recorded on a prior beat.
66
+ * - `epic-progress` always fires with the run's done/total counts.
67
+ * - `epic-blocked` fires when this beat recorded at least one blocked or
68
+ * failed Story.
69
+ *
70
+ * @param {{
71
+ * injectedNotify?: Function,
72
+ * defaultNotify: Function,
73
+ * config: object,
74
+ * provider: object,
75
+ * epicId: number,
76
+ * firstRecord: boolean,
77
+ * stories: Record<string, { status?: string }>,
78
+ * verified: Array<{ storyId: number, status: string }>,
79
+ * blockedStoryIds: number[],
80
+ * }} args
49
81
  */
50
- export async function emitWaveBoundaryNotifications({
82
+ export async function emitRecordNotifications({
51
83
  injectedNotify,
52
84
  defaultNotify,
53
85
  config,
54
86
  provider,
55
87
  epicId,
56
- wave,
57
- status,
58
- priorWaves,
59
- nextWaves,
60
- titleById,
61
- totalWaves,
62
- nextCurrentWave,
88
+ firstRecord,
89
+ stories,
63
90
  verified,
64
91
  blockedStoryIds,
65
92
  }) {
@@ -69,121 +96,50 @@ export async function emitWaveBoundaryNotifications({
69
96
  provider,
70
97
  defaultNotify,
71
98
  );
72
- const totalStoriesEstimate = titleById.size;
73
- const doneStoriesSoFar = countDoneStories(nextWaves);
74
- const priorWaveRecord = priorWaves.find(
75
- (w) => Number(w?.index) === Number(wave),
76
- );
77
- if (priorWaves.length === 0 && wave === 0) {
99
+ const map = stories && typeof stories === 'object' ? stories : {};
100
+ const totalStories = Object.keys(map).length;
101
+ const doneStories = countDoneStories(map);
102
+
103
+ if (firstRecord) {
78
104
  await emitEpicStarted({
79
105
  notify: notifyFn,
80
106
  epicId,
81
- totalWaves,
82
- totalStories: totalStoriesEstimate,
107
+ totalStories,
83
108
  logger: Logger,
84
109
  });
85
110
  }
86
- if (status === 'complete') {
87
- await emitCompleteWaveNotifications({
88
- notifyFn,
89
- epicId,
90
- priorWaveRecord,
91
- doneStoriesSoFar,
92
- totalStoriesEstimate,
93
- nextCurrentWave,
94
- totalWaves,
95
- });
96
- return;
97
- }
98
- await emitFailingWaveNotifications({
99
- notifyFn,
100
- epicId,
101
- status,
102
- blockedStoryIds,
103
- verified,
104
- doneStoriesSoFar,
105
- totalStoriesEstimate,
106
- nextCurrentWave,
107
- totalWaves,
108
- });
109
- }
110
111
 
111
- /** Emit the unblocked-then-progress pair for a `complete` wave. */
112
- async function emitCompleteWaveNotifications({
113
- notifyFn,
114
- epicId,
115
- priorWaveRecord,
116
- doneStoriesSoFar,
117
- totalStoriesEstimate,
118
- nextCurrentWave,
119
- totalWaves,
120
- }) {
121
- const resumedFromHalt =
122
- priorWaveRecord &&
123
- (priorWaveRecord.status === 'blocked' ||
124
- priorWaveRecord.status === 'failed');
125
- if (resumedFromHalt) {
126
- await emitEpicUnblocked({
112
+ const blockedIds = Array.isArray(blockedStoryIds) ? blockedStoryIds : [];
113
+ const failedStoryId = (verified ?? []).find(
114
+ (r) => r.status === 'failed',
115
+ )?.storyId;
116
+ const failingStoryId = blockedIds[0] ?? failedStoryId;
117
+ const hasFailure = blockedIds.length > 0 || failedStoryId != null;
118
+
119
+ if (hasFailure) {
120
+ await emitEpicBlocked({
127
121
  notify: notifyFn,
128
122
  epicId,
129
- resolvedBlocker: {
130
- reason:
131
- priorWaveRecord.status === 'blocked'
132
- ? 'story_blocked'
133
- : 'story_failed',
134
- },
123
+ reason: blockedIds.length > 0 ? 'story_blocked' : 'story_failed',
124
+ storyId: failingStoryId,
135
125
  logger: Logger,
136
126
  });
137
127
  }
138
- await emitEpicProgress({
139
- notify: notifyFn,
140
- epicId,
141
- done: doneStoriesSoFar,
142
- total: totalStoriesEstimate,
143
- currentWave: nextCurrentWave,
144
- totalWaves,
145
- phase: 'iterate-waves',
146
- openBlockers: [],
147
- logger: Logger,
148
- });
149
- // The `epic-complete` webhook used to fire here, at the post-final-wave
150
- // / pre-finalize boundary. That preceded `gh pr create` by minutes — the
151
- // operator got an "Epic complete" ping with no PR to click. The fire
152
- // moved to `epic-deliver-finalize.js`, which emits it after the PR URL
153
- // is captured. See that script for the new emit point.
154
- }
155
128
 
156
- /** Emit blocked + progress (with open-blocker context) for a non-complete wave. */
157
- async function emitFailingWaveNotifications({
158
- notifyFn,
159
- epicId,
160
- status,
161
- blockedStoryIds,
162
- verified,
163
- doneStoriesSoFar,
164
- totalStoriesEstimate,
165
- nextCurrentWave,
166
- totalWaves,
167
- }) {
168
- const reason = status === 'blocked' ? 'story_blocked' : 'story_failed';
169
- const failingStoryId =
170
- blockedStoryIds[0] ?? verified.find((r) => r.status === 'failed')?.storyId;
171
- await emitEpicBlocked({
172
- notify: notifyFn,
173
- epicId,
174
- reason,
175
- storyId: failingStoryId,
176
- logger: Logger,
177
- });
178
129
  await emitEpicProgress({
179
130
  notify: notifyFn,
180
131
  epicId,
181
- done: doneStoriesSoFar,
182
- total: totalStoriesEstimate,
183
- currentWave: nextCurrentWave,
184
- totalWaves,
185
- phase: 'iterate-waves',
186
- openBlockers: [{ reason, storyId: failingStoryId }],
132
+ done: doneStories,
133
+ total: totalStories,
134
+ phase: 'wave-loop',
135
+ openBlockers: hasFailure
136
+ ? [
137
+ {
138
+ reason: blockedIds.length > 0 ? 'story_blocked' : 'story_failed',
139
+ storyId: failingStoryId,
140
+ },
141
+ ]
142
+ : [],
187
143
  logger: Logger,
188
144
  });
189
145
  }