mandrel 1.67.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 (50) 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-delivery.js +17 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -2
  13. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  14. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  17. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +32 -1
  18. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  19. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  20. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  21. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +13 -8
  24. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  25. package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  27. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  28. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  29. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  30. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  31. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  32. package/.agents/scripts/lib/story-adjacency.js +14 -10
  33. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  34. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  35. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  36. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  37. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  38. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  39. package/.agents/scripts/stories-wave-tick.js +262 -161
  40. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  41. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  42. package/.agents/skills/skills.index.json +2 -2
  43. package/.agents/workflows/deliver.md +12 -9
  44. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  45. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  46. package/.agents/workflows/helpers/plan-epic.md +13 -10
  47. package/.agents/workflows/plan.md +1 -1
  48. package/docs/CHANGELOG.md +26 -0
  49. package/package.json +1 -1
  50. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -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
  }
@@ -1,37 +1,31 @@
1
1
  /**
2
- * wave-record-projection.js — pure projection helpers for the record-wave CLI.
2
+ * wave-record-projection.js — pure helpers for the per-Story status
3
+ * recorder CLI (`epic-execute-record-wave.js`).
3
4
  *
4
- * This module is the post-wave-record projection layer extracted from
5
- * `.agents/scripts/epic-execute-record-wave.js`. Every export here is pure:
6
- * no network I/O, no filesystem reads, no spawning. The parent CLI handles
7
- * the impure work (provider calls, checkpoint reads/writes, webhook emits,
8
- * manifest refresh) and threads the resolved inputs through these helpers
9
- * to produce the wave's new checkpoint record, rollup rows, and envelope
10
- * fields.
5
+ * Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from the
6
+ * wave-batch scheduler to the continuous ready-set core. The post-dispatch
7
+ * persistence shrank with it: there is no longer a wave-level aggregation
8
+ * (`aggregateWaveStatus`), a `currentWave` advance (`nextCurrentWave`), a
9
+ * next-action classifier (`classifyWaveOutcome`), or a `projectWaveRecord`
10
+ * splice into a `waves[]` history. The recorder now writes each returned
11
+ * Story's **terminal status** into the checkpoint's flat per-Story `stories`
12
+ * map and re-renders the operator rollup from that map.
13
+ *
14
+ * Every export here is pure: no network I/O, no filesystem reads, no
15
+ * spawning. The parent CLI handles the impure work (provider calls,
16
+ * checkpoint reads/writes, webhook emits) and threads the resolved inputs
17
+ * through these helpers.
11
18
  *
12
19
  * Group the exports by responsibility:
13
20
  *
14
21
  * - Input validation: `validateResults`, `validateReturnsEntry`,
15
- * `classifyParsedReturn`, `validateEpicWave`, `validateResultsReturnsXor`,
16
- * `selectInputFlag`, `resolveConcurrencyCap`.
17
- * - Aggregation: `aggregateWaveStatus`, `countDoneStories`,
18
- * `classifyWaveOutcome`.
19
- * - Projection: `toRollupRow`, `projectWaveRecord`.
20
- *
21
- * The aggregator `projectWaveRecord` is the entry point: given the verified
22
- * per-Story rows, the prior checkpoint, the wave index, the resolved
23
- * concurrency cap, and a `titleById` map, it returns every derived shape the
24
- * CLI needs to (a) write the next checkpoint, (b) render `epic-run-progress`,
25
- * (c) classify the next slash-command action, and (d) assemble the stdout
26
- * envelope. It is the single source of truth for "what does the wave look
27
- * like after these results land?"
22
+ * `classifyParsedReturn`, `validateEpic`, `selectInputFlag`.
23
+ * - Normalization: `normalizeReturnsPure`.
24
+ * - Projection: `toRollupRow`.
28
25
  */
29
26
 
30
27
  import { parseStoryAgentReturn } from './epic-runner/sub-agent-return.js';
31
28
 
32
- /** Valid wave-level rollup statuses. */
33
- export const VALID_RESULT_STATUSES = new Set(['complete', 'blocked', 'failed']);
34
-
35
29
  /** Per-Story return statuses we accept off `/deliver` sub-agents. */
36
30
  export const VALID_STORY_STATUSES = new Set(['done', 'blocked', 'failed']);
37
31
 
@@ -130,57 +124,6 @@ export function classifyParsedReturn(parsed, storyId) {
130
124
  return { ok: false, error };
131
125
  }
132
126
 
133
- /**
134
- * Aggregate validated per-Story rows into the wave-level outcome. Pure.
135
- *
136
- * @param {Array<{ storyId: number, status: string }>} results
137
- */
138
- export function aggregateWaveStatus(results) {
139
- const rows = Array.isArray(results) ? results : [];
140
- const failed = rows.filter((r) => r.status === 'failed');
141
- const blocked = rows.filter((r) => r.status === 'blocked');
142
- let status;
143
- if (failed.length > 0) {
144
- status = 'failed';
145
- } else if (blocked.length > 0) {
146
- status = 'blocked';
147
- } else {
148
- status = 'complete';
149
- }
150
- return {
151
- status,
152
- blockedStoryIds: blocked.map((r) => r.storyId),
153
- };
154
- }
155
-
156
- /**
157
- * Classify the wave outcome into the next operator action. Pure helper —
158
- * exported so tests can pin each branch without touching the provider.
159
- *
160
- * @param {{ resultStatus: string, currentWave: number, totalWaves: number }} args
161
- */
162
- export function classifyWaveOutcome({ resultStatus, currentWave, totalWaves }) {
163
- const remainingWaves = Math.max(
164
- 0,
165
- Number(totalWaves) - (Number(currentWave) + 1),
166
- );
167
- if (resultStatus === 'blocked') {
168
- return { nextAction: 'halt-blocked', remainingWaves };
169
- }
170
- if (resultStatus === 'failed') {
171
- return { nextAction: 'halt-failed', remainingWaves };
172
- }
173
- if (resultStatus === 'complete') {
174
- return {
175
- nextAction: remainingWaves > 0 ? 'dispatch-next' : 'finalize',
176
- remainingWaves,
177
- };
178
- }
179
- throw new RangeError(
180
- `classifyWaveOutcome: resultStatus "${resultStatus}" must be one of: ${[...VALID_RESULT_STATUSES].join(', ')}`,
181
- );
182
- }
183
-
184
127
  /**
185
128
  * Build the rollup-row shape the unified `epic-run-progress` writer
186
129
  * consumes. Returns `{ id, title, state, blockerCommentId? }`.
@@ -197,64 +140,13 @@ export function toRollupRow(verified, titleById) {
197
140
  return row;
198
141
  }
199
142
 
200
- /** Pure: count Stories already marked `done` across every recorded wave. */
201
- export function countDoneStories(waves) {
202
- return waves.reduce(
203
- (acc, w) =>
204
- acc +
205
- (Array.isArray(w.stories)
206
- ? w.stories.filter((s) => s?.state === 'done').length
207
- : 0),
208
- 0,
209
- );
210
- }
211
-
212
- /** Validate the core `{ epicId, wave }` invariants. Throws on bad input. */
213
- export function validateEpicWave(epicId, wave) {
143
+ /** Validate the core `{ epicId }` invariant. Throws on bad input. */
144
+ export function validateEpic(epicId) {
214
145
  if (!Number.isInteger(epicId) || epicId <= 0) {
215
146
  throw new TypeError(
216
147
  'runEpicExecuteRecordWave: --epic must be a positive integer',
217
148
  );
218
149
  }
219
- if (!Number.isInteger(wave) || wave < 0) {
220
- throw new TypeError(
221
- 'runEpicExecuteRecordWave: --wave must be a non-negative integer',
222
- );
223
- }
224
- }
225
-
226
- /** Validate the `results`/`returns` XOR. Throws on bad input. */
227
- export function validateResultsReturnsXor(results, returns) {
228
- if (results == null && returns == null) {
229
- throw new TypeError(
230
- 'runEpicExecuteRecordWave: either `results` or `returns` is required',
231
- );
232
- }
233
- if (results != null && returns != null) {
234
- throw new TypeError(
235
- 'runEpicExecuteRecordWave: pass `results` OR `returns`, not both',
236
- );
237
- }
238
- }
239
-
240
- /** Resolve the effective concurrency cap, honouring CLI > checkpoint > config. */
241
- export function resolveConcurrencyCap(
242
- concurrencyCapOverride,
243
- existing,
244
- deliverRunner,
245
- ) {
246
- const cap =
247
- concurrencyCapOverride ??
248
- Number(existing.concurrencyCap) ??
249
- Number(deliverRunner.concurrencyCap) ??
250
- 1;
251
- if (!Number.isInteger(cap) || cap < 1) {
252
- throw new RangeError(
253
- `runEpicExecuteRecordWave: resolved concurrencyCap "${cap}" must be a positive integer; ` +
254
- 'pass --concurrency-cap or set `orchestration.runners.deliverRunner.concurrencyCap`.',
255
- );
256
- }
257
- return cap;
258
150
  }
259
151
 
260
152
  /**
@@ -292,7 +184,7 @@ export function selectInputFlag(hasResults, hasReturns) {
292
184
  * @param {(args: { storyId: number }) => Promise<object> | object} [args.reconcile]
293
185
  * Optional async hook used to fetch a fallback row when parsing fails. If
294
186
  * omitted, parse failures push a placeholder `{ storyId, status: 'failed' }`
295
- * row so the caller can still aggregate without I/O.
187
+ * row so the caller can still record without I/O.
296
188
  */
297
189
  export async function normalizeReturnsPure({ returns, reconcile } = {}) {
298
190
  if (!Array.isArray(returns)) {
@@ -318,100 +210,3 @@ export async function normalizeReturnsPure({ returns, reconcile } = {}) {
318
210
  }
319
211
  return { results, parseFailures };
320
212
  }
321
-
322
- /**
323
- * Project the post-wave-record state from verified per-Story rows. Pure
324
- * end-to-end: every derived field the CLI needs after `verifyWaveResults`
325
- * lands here. The CLI hands us the verified rows, the existing checkpoint
326
- * (so we can splice this wave's record), the wave index, the resolved cap,
327
- * the manifest title map, and a `now` clock.
328
- *
329
- * Returns the union of:
330
- * - the new `epic-run-state` shape (`nextWaves`, `nextCurrentWave`,
331
- * `totalWaves`),
332
- * - the rollup payload (`rollupRows`, `rollupWaves`),
333
- * - the wave-level outcome (`status`, `blockedStoryIds`),
334
- * - the slash-command next action (`nextAction`, `remainingWaves`).
335
- *
336
- * The CLI handles I/O around this projection: writing the checkpoint,
337
- * upserting `epic-run-progress`, emitting webhooks, refreshing the local
338
- * manifest. None of those impure side-effects live here.
339
- *
340
- * @param {object} args
341
- * @param {number} args.wave
342
- * @param {Array<object>} args.verified
343
- * @param {object} args.existing
344
- * @param {number} args.concurrencyCap
345
- * @param {Map<number, string>} args.titleById
346
- * @param {() => Date} [args.now]
347
- */
348
- export function projectWaveRecord({
349
- wave,
350
- verified,
351
- existing,
352
- concurrencyCap,
353
- titleById,
354
- now = () => new Date(),
355
- } = {}) {
356
- if (!Number.isInteger(wave) || wave < 0) {
357
- throw new TypeError(
358
- 'projectWaveRecord: wave must be a non-negative integer',
359
- );
360
- }
361
- if (!existing || typeof existing !== 'object') {
362
- throw new TypeError('projectWaveRecord: existing checkpoint is required');
363
- }
364
- if (!(titleById instanceof Map)) {
365
- throw new TypeError('projectWaveRecord: titleById must be a Map');
366
- }
367
-
368
- const totalWaves = Number(existing.totalWaves ?? 0);
369
- const verifiedRows = Array.isArray(verified) ? verified : [];
370
-
371
- const { status, blockedStoryIds } = aggregateWaveStatus(verifiedRows);
372
- const rollupRows = verifiedRows.map((r) => toRollupRow(r, titleById));
373
-
374
- const priorWaves = Array.isArray(existing.waves) ? existing.waves : [];
375
- const newRecord = {
376
- index: wave,
377
- status,
378
- concurrencyCap,
379
- stories: rollupRows,
380
- completedAt: now().toISOString(),
381
- };
382
- const filtered = priorWaves.filter((w) => Number(w?.index) !== Number(wave));
383
- const nextWaves = [...filtered, newRecord].sort(
384
- (a, b) => Number(a.index) - Number(b.index),
385
- );
386
-
387
- const nextCurrentWave =
388
- status === 'complete'
389
- ? Math.min(totalWaves, wave + 1)
390
- : Number(existing.currentWave ?? wave);
391
-
392
- const rollupWaves = nextWaves.map((w) => ({
393
- wave: Number(w.index),
394
- concurrencyCap: Number(w.concurrencyCap) || concurrencyCap,
395
- stories: Array.isArray(w.stories) ? w.stories : [],
396
- }));
397
-
398
- const { nextAction, remainingWaves } = classifyWaveOutcome({
399
- resultStatus: status,
400
- currentWave: wave,
401
- totalWaves,
402
- });
403
-
404
- return {
405
- status,
406
- blockedStoryIds,
407
- rollupRows,
408
- newRecord,
409
- priorWaves,
410
- nextWaves,
411
- nextCurrentWave,
412
- totalWaves,
413
- rollupWaves,
414
- nextAction,
415
- remainingWaves,
416
- };
417
- }