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
@@ -1,13 +1,47 @@
1
1
  /**
2
2
  * `tick({ epic, collaborators })` — single callable entry point for
3
- * "advance this wave one step." Stateless planner: rebuilds wave state
4
- * from the `epic-run-state` checkpoint plus fresh Story labels on every
5
- * call, then returns a `WaveTickResult` describing the next action.
3
+ * "advance this Epic one beat." Stateless adapter over the continuous
4
+ * ready-set scheduling core (`lib/wave-runner/ready-set.js`).
6
5
  *
7
- * Contract (Story #1430): stateless; caller owns concurrency,
8
- * worktrees, and checkpointing. Expected failures (blocked stories,
9
- * gate failures) flow back through result fields; unexpected failures
10
- * (GH 5xx, malformed checkpoint) throw `WaveRunnerError`.
6
+ * Story #4155 (Epic #4151) the Epic `/deliver` runtime cut over from
7
+ * the wave-batch scheduler to the ready-set core. Each tick:
8
+ *
9
+ * 1. reads the shrunk `epic-run-state` checkpoint (per-Story status map
10
+ * + the GLOBAL in-flight `concurrencyCap`),
11
+ * 2. re-fetches the **live** Story records (body + labels + issue
12
+ * state) for every Story in scope,
13
+ * 3. classifies each by live label (`classifyStory`), re-derives
14
+ * adjacency from the live bodies (`buildStoryAdjacency`, inside
15
+ * `selectReadySet`) and selects the ready set under a global
16
+ * in-flight cap with the file-overlap co-dispatch guard
17
+ * (`storiesOverlap`),
18
+ * 4. returns a `WaveTickResult` describing the next action.
19
+ *
20
+ * There is **no wave barrier**: a Story whose own dependencies are all
21
+ * done is dispatched the instant a slot is free, even while an unrelated
22
+ * sibling Story is still `agent::executing`. The selector neither reads
23
+ * GitHub nor a checkpoint nor the ledger — this adapter supplies the live
24
+ * records, the `inFlight` count (from the lifecycle ledger), and the
25
+ * `globalCap`, then maps its return into the `WaveTickResult` envelope.
26
+ *
27
+ * Contract (Story #1430, refined by #4155): stateless; caller owns
28
+ * concurrency, worktrees, and the checkpoint. Expected failures (blocked
29
+ * stories) flow back through result fields; unexpected failures (GH 5xx,
30
+ * malformed / old-shape checkpoint) throw `WaveRunnerError`.
31
+ *
32
+ * Story #4183 — the `tick(args)` orchestrator was a 252-line SRP /
33
+ * cognitive-load hotspot carrying six distinct responsibilities in one
34
+ * body. It is now a thin coordinator (Coordinator-plus-Phases pattern,
35
+ * `docs/patterns.md`) that wires four extracted stages:
36
+ * `resolveTickCollaborators` (collaborator/fallback resolution),
37
+ * `readAndValidateCheckpoint` (checkpoint read + shape validation, folding
38
+ * in `assertNotOldShape`), `refetchStoryRecords` (force-fresh re-fetch),
39
+ * and the **pure** `planTick` (classification → cycle detection → ready-set
40
+ * selection → dispatch decision, returning the signals to emit rather than
41
+ * emitting them, so it carries no I/O). The exported `tick(args)`
42
+ * signature, the `tickResult` / `withInFlight` envelope shapes, and every
43
+ * `WaveRunnerError` code are preserved verbatim — callers and tests are
44
+ * unchanged.
11
45
  *
12
46
  * @module lib/wave-runner/tick
13
47
  */
@@ -15,29 +49,57 @@
15
49
  import { existsSync, readFileSync } from 'node:fs';
16
50
 
17
51
  import { epicLedgerPath } from '../config/temp-paths.js';
52
+ import { detectCycle } from '../Graph.js';
18
53
  import { AGENT_LABELS } from '../label-constants.js';
19
54
  import { appendEpicSignal } from '../observability/signals-writer.js';
20
55
  import * as epicRunStateStoreModule from '../orchestration/epic-run-state-store.js';
21
56
  import { detectRecurringFailures } from '../orchestration/recurring-failure-detector.js';
22
57
  import { upsertStructuredComment as defaultUpsertStructuredComment } from '../orchestration/ticketing.js';
58
+ import { buildStoryAdjacency } from '../story-adjacency.js';
23
59
 
24
- import { collectHaltedStoryIds } from './wave-checkpoint.js';
60
+ import { classifyStory, selectReadySet, storyIdOf } from './ready-set.js';
25
61
  import { WaveRunnerError } from './wave-runner-error.js';
26
62
 
27
63
  /**
28
- * Advance the wave loop one step. Returns a `WaveTickResult`:
64
+ * The checkpoint fields whose presence marks an **old-shape** (wave-batch)
65
+ * `epic-run-state` comment. The ready-set runtime cannot mis-schedule
66
+ * against a wave-indexed plan — indexing the old wave grouping would silently
67
+ * dispatch the wrong stories — so the tick fails closed on any of these
68
+ * fields with an explicit operator message rather than guessing.
69
+ */
70
+ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
71
+
72
+ /**
73
+ * Advance the Epic one beat. Returns a `WaveTickResult`:
29
74
  *
30
- * nextAction: { kind: 'dispatch', stories: [{ id, title?, worktree? }, ...] }
31
- * | { kind: 'observe', waitingOn: number[] }
32
- * | { kind: 'wave-complete', index: number }
75
+ * nextAction: { kind: 'dispatch', stories: [{ id, title? }, ...] }
76
+ * | { kind: 'observe', waitingOn: number[] }
77
+ * | { kind: 'halt', reason: string, stuckStories: number[],
78
+ * cycle?: number[] }
33
79
  * | { kind: 'epic-complete' }
34
80
  * blockedStories: [{ storyId, reason, detail? }, ...]
35
81
  * gateFailures: [{ storyId, gate, detail? }, ...]
36
- * currentWave: number
37
- * totalWaves: number
82
+ * readyCount: number // size of the ready set this beat
83
+ * inFlight: number[] // ledger-derived dispatched-not-yet-ended ids
84
+ *
85
+ * Readiness comes entirely from the **live** Story bodies + labels — the
86
+ * checkpoint contributes only the Story set in scope and the global cap.
38
87
  *
39
- * Wave grouping comes from the checkpoint's `state.plan` (the GH-derived
40
- * dependency-DAG grouping originally seeded by /plan).
88
+ * `epic-complete` is returned **only** when every in-scope Story is done.
89
+ * If the ready set is empty and nothing is in flight but at least one Story
90
+ * is still not done — a Story gated on an unsatisfiable dependency
91
+ * (a dependency cycle, or a `blocked by #N` that survived adjacency closure)
92
+ * — the tick returns a non-terminal `halt` naming the stuck Story ids rather
93
+ * than silently reporting the Epic complete and stranding the Story. A
94
+ * dependency cycle among the in-scope Stories is likewise surfaced as a
95
+ * `halt` (with the offending `cycle`), never collapsed to `epic-complete`.
96
+ *
97
+ * Coordinator (Story #4183): this function is a thin dispatcher. It resolves
98
+ * collaborators, reads + validates the checkpoint, re-fetches the live Story
99
+ * records, runs the best-effort recurring-failure scan, delegates the pure
100
+ * dispatch decision to `planTick`, then drains the `signals` `planTick`
101
+ * returned through the configured emitter. Each stage is an independently
102
+ * testable helper below.
41
103
  *
42
104
  * @typedef {object} WaveTickArgs
43
105
  * @property {number | { id: number }} epic
@@ -53,33 +115,123 @@ import { WaveRunnerError } from './wave-runner-error.js';
53
115
  * @param {WaveTickArgs} args
54
116
  */
55
117
  export async function tick(args = {}) {
118
+ const { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx } =
119
+ resolveTickCollaborators(args);
120
+
121
+ const state = await readAndValidateCheckpoint(epicRunStateStore, epicId);
122
+
123
+ const storyIds = checkpointStoryIds(state);
124
+
125
+ if (storyIds.length === 0) {
126
+ // No Stories in scope — the Epic has nothing to dispatch.
127
+ return tickResult({
128
+ nextAction: withInFlight({ kind: 'epic-complete' }, []),
129
+ readyCount: 0,
130
+ inFlight: [],
131
+ });
132
+ }
133
+
134
+ // Re-fetch the live Story records (body + labels + issue state) for every
135
+ // Story in scope. In-flight Stories are force-fresh-fetched so a label that
136
+ // flipped since the last tick is observed; every other Story serves from
137
+ // the provider's in-process cache.
138
+ const inFlight = await safeReadInFlight(inFlightReader);
139
+ const inFlightSet = new Set(inFlight);
140
+ const records = await refetchStoryRecords(provider, storyIds, inFlightSet);
141
+
142
+ // Best-effort recurring-failure scan (≥2 distinct Stories sharing the same
143
+ // `close-validate.end` failedGate). Idempotent across re-ticks; a reporter
144
+ // throw must not crash the planner.
145
+ const recurringFailureReporter =
146
+ args.collaborators?.recurringFailureReporter ??
147
+ defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
148
+ await safeReportRecurringFailures(recurringFailureReporter);
149
+
150
+ // Decide the next action from the live records + ledger in-flight set. The
151
+ // decision is pure (no I/O); the signals it wants emitted come back in
152
+ // `plan.signals` and are drained by the coordinator below.
153
+ const plan = planTick(state, records, inFlight);
154
+ for (const signal of plan.signals) {
155
+ await emit(signal);
156
+ }
157
+
158
+ return tickResult({
159
+ nextAction: withInFlight(plan.nextAction, inFlight),
160
+ blockedStories: plan.blockedStories,
161
+ gateFailures: plan.gateFailures,
162
+ readyCount: plan.readyCount,
163
+ inFlight,
164
+ });
165
+ }
166
+
167
+ /**
168
+ * Resolve the Epic id and the five injectable collaborators (with their
169
+ * production-default fallbacks) from the `tick` args. The single home for the
170
+ * collaborator/fallback wiring so the coordinator stays declarative.
171
+ *
172
+ * Throws `WaveRunnerError('invalid-input')` when the epic id is not a
173
+ * positive integer (or `{ id: positiveInt }`) or when no provider is supplied
174
+ * via either `collaborators.provider` or `ctx.provider`.
175
+ *
176
+ * @param {WaveTickArgs} args
177
+ * @returns {{
178
+ * epicId: number,
179
+ * provider: object,
180
+ * epicRunStateStore: { read: () => Promise<object|null> },
181
+ * emit: (signal: object) => Promise<unknown>,
182
+ * inFlightReader: () => Promise<number[]>,
183
+ * ctx: object,
184
+ * }}
185
+ */
186
+ function resolveTickCollaborators(args) {
56
187
  const epicId = resolveEpicId(args.epic);
57
188
  const {
58
189
  provider: collabProvider,
59
190
  epicRunStateStore: collabStore,
60
191
  signalEmit,
61
192
  inFlightReader: collabInFlightReader,
62
- recurringFailureReporter: collabRecurringFailureReporter,
63
193
  } = args.collaborators ?? {};
64
194
  const ctx = args.ctx ?? {};
65
195
  const provider = collabProvider ?? ctx.provider;
66
196
  if (!provider) {
67
197
  throw new WaveRunnerError('invalid-input', 'provider is required');
68
198
  }
69
- // Story #2409 — the wave-runner tick is stateless. When the caller
70
- // does not supply a collaborator shim, we read the `epic-run-state`
71
- // structured comment directly via the function-based store, mirroring
72
- // the pre-migration `.read()` shape exactly.
199
+ // The ready-set tick is stateless. When the caller does not supply a
200
+ // collaborator shim, read the `epic-run-state` structured comment directly
201
+ // via the function-based store.
73
202
  const epicRunStateStore = collabStore ?? {
74
203
  read: () => epicRunStateStoreModule.read({ provider, epicId }),
75
204
  };
76
205
  const emit = signalEmit ?? defaultSignalEmit(epicId, ctx);
77
206
  const inFlightReader =
78
207
  collabInFlightReader ?? (() => defaultInFlightReader(epicId, ctx?.config));
208
+ return { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx };
209
+ }
79
210
 
211
+ /**
212
+ * Read the `epic-run-state` checkpoint via the store, validate its shape, and
213
+ * fail closed on a pre-ready-set (wave-batch) checkpoint.
214
+ *
215
+ * Throws:
216
+ * - `WaveRunnerError('checkpoint-read')` when the store read rejects,
217
+ * - `WaveRunnerError('checkpoint-missing')` when the read resolves to a
218
+ * non-object (no comment),
219
+ * - `WaveRunnerError('old-shape-checkpoint')` when the checkpoint still
220
+ * carries a `plan` / `currentWave` / `totalWaves` field (via
221
+ * `assertNotOldShape`). A `plan` / `currentWave` / `totalWaves` comment
222
+ * predates the ready-set cutover (Story #4155); the ready-set runtime
223
+ * would otherwise ignore those fields and re-derive readiness from live
224
+ * labels — silently discarding an in-progress wave-batch run's resume
225
+ * pointer. Refuse with an explicit operator remediation instead.
226
+ *
227
+ * @param {{ read: () => Promise<object|null> }} store
228
+ * @param {number} epicId
229
+ * @returns {Promise<object>} the validated checkpoint state.
230
+ */
231
+ async function readAndValidateCheckpoint(store, epicId) {
80
232
  let state;
81
233
  try {
82
- state = await epicRunStateStore.read();
234
+ state = await store.read();
83
235
  } catch (err) {
84
236
  throw new WaveRunnerError('checkpoint-read', err);
85
237
  }
@@ -89,189 +241,292 @@ export async function tick(args = {}) {
89
241
  `no epic-run-state comment on Epic #${epicId}`,
90
242
  );
91
243
  }
244
+ assertNotOldShape(state, epicId);
245
+ return state;
246
+ }
92
247
 
93
- const currentWave = positiveIntOrZero(state.currentWave);
94
- const plan = Array.isArray(state.plan) ? state.plan : [];
95
- const totalWaves = positiveIntOrZero(state.totalWaves);
96
- const history = Array.isArray(state.waves) ? state.waves : [];
97
-
98
- if (totalWaves === 0 || currentWave >= totalWaves) {
99
- return tickResult({
100
- nextAction: { kind: 'epic-complete' },
101
- currentWave,
102
- totalWaves,
103
- });
104
- }
105
-
106
- const wavePlan = Array.isArray(plan[currentWave]) ? plan[currentWave] : [];
107
- if (wavePlan.length === 0) {
108
- await emit({
109
- kind: 'wave-complete',
110
- index: currentWave,
111
- totalWaves,
112
- empty: true,
113
- });
114
- return tickResult({
115
- nextAction: { kind: 'wave-complete', index: currentWave },
116
- currentWave,
117
- totalWaves,
118
- });
119
- }
120
-
121
- // Story #3026 — match the iterate-waves resume-check cache strategy:
122
- // only Stories that the checkpoint marks as halted on a prior wave
123
- // are force-refreshed. Every other Story serves the tick fetch from
124
- // the provider's in-process cache, eliminating the per-wave
125
- // `fresh: true` round-trip we historically issued for every Story.
126
- const haltedStoryIds = collectHaltedStoryIds(state);
127
- let waveStates;
248
+ /**
249
+ * Re-fetch the live Story records (body + labels + issue state) for every
250
+ * Story in scope. The body feeds `buildStoryAdjacency` (inside
251
+ * `selectReadySet`) so the dependency edges are always read from the current
252
+ * ticket text, never a stale checkpoint snapshot. Stories in `inFlightSet`
253
+ * are force-fresh-fetched (`{ fresh: true }`) so a label that flipped since
254
+ * the last tick is observed; every other Story serves from the provider's
255
+ * in-process cache.
256
+ *
257
+ * Throws `WaveRunnerError('story-fetch')` when any `provider.getTicket`
258
+ * rejects.
259
+ *
260
+ * @param {{ getTicket: (id: number, opts?: object) => Promise<object> }} provider
261
+ * @param {number[]} storyIds Ascending, deduped in-scope Story ids.
262
+ * @param {Set<number>} inFlightSet Ledger-derived in-flight Story ids.
263
+ * @returns {Promise<Array<object>>} normalized Story records.
264
+ */
265
+ async function refetchStoryRecords(provider, storyIds, inFlightSet) {
128
266
  try {
129
- waveStates = await Promise.all(
130
- wavePlan.map(async (s) => {
131
- const id = storyIdOf(s);
132
- const opts = haltedStoryIds.has(id) ? { fresh: true } : {};
267
+ return await Promise.all(
268
+ storyIds.map(async (id) => {
269
+ const opts = inFlightSet.has(id) ? { fresh: true } : {};
133
270
  const ticket = await provider.getTicket(id, opts);
134
271
  return {
135
272
  id,
136
- title: s.title ?? ticket?.title,
137
- worktree: s.worktree,
273
+ title: ticket?.title,
274
+ body: ticket?.body ?? '',
138
275
  labels: Array.isArray(ticket?.labels) ? ticket.labels : [],
139
276
  state: ticket?.state,
277
+ // Forward every declared file-footprint shape so the selector's
278
+ // overlap co-dispatch guard (`storiesOverlap`) can withhold two
279
+ // Stories that would race the same path on parallel branches.
280
+ files: Array.isArray(ticket?.files) ? ticket.files : undefined,
281
+ changes: Array.isArray(ticket?.changes) ? ticket.changes : undefined,
282
+ changeset: Array.isArray(ticket?.changeset)
283
+ ? ticket.changeset
284
+ : undefined,
140
285
  };
141
286
  }),
142
287
  );
143
288
  } catch (err) {
144
289
  throw new WaveRunnerError('story-fetch', err);
145
290
  }
291
+ }
146
292
 
147
- // Story #2891 — compute in-flight Stories from the lifecycle ledger.
148
- // A Story is "in-flight" when the ledger carries a
149
- // `story.dispatch.start` record for it without a matching
150
- // `story.dispatch.end`. The reconciliation is purely additive on the
151
- // result envelope so callers can surface dispatched-but-uncompleted
152
- // Stories that the per-Wave label state alone cannot reveal.
153
- //
154
- // Story #3907 the in-flight set is read **before** the dispatch
155
- // classification so it can be subtracted from the dispatchable set below.
156
- const inFlight = await safeReadInFlight(inFlightReader);
293
+ /**
294
+ * Pure dispatch planner the scheduler tick's decision core with **no I/O**.
295
+ * Given the parsed checkpoint, the live Story records, and the ledger-derived
296
+ * in-flight id list, it classifies every Story, detects a sibling dependency
297
+ * cycle, selects the ready set under the global in-flight cap, and decides the
298
+ * `nextAction`. It performs no fetching, no signal emission, and no ledger
299
+ * read: the two wave-window forensics signals are returned in the `signals`
300
+ * array for the coordinator to drain, so this function stays independently
301
+ * unit-testable against fixture records without a provider stub or an emitter.
302
+ *
303
+ * @param {object} state Parsed `epic-run-state` checkpoint (for the global
304
+ * cap and the per-Story `failed` rows surfaced as gate failures).
305
+ * @param {Array<object>} records Live Story records (id, title, body, labels,
306
+ * state, file-footprint shapes).
307
+ * @param {number[]} inFlight Ledger-derived dispatched-not-yet-ended ids.
308
+ * @returns {{
309
+ * nextAction: object,
310
+ * blockedStories: Array<{ storyId: number, reason: string, detail?: string }>,
311
+ * gateFailures: Array<{ storyId: number, gate: string, detail?: string }>,
312
+ * readyCount: number,
313
+ * signals: Array<object>,
314
+ * }}
315
+ */
316
+ export function planTick(state, records, inFlight) {
317
+ const globalCap = positiveIntOrZero(state.concurrencyCap);
157
318
  const inFlightSet = new Set(inFlight);
158
319
 
159
- // Story #3907 a Story is "done" when it carries `agent::done` OR its
160
- // GitHub issue is `state === 'closed'`. Reading the closed state (not just
161
- // the label) means a Story closed manually through the GitHub UI — which
162
- // closes the issue but does not flip the `agent::*` label — is recognised
163
- // as done and is never re-dispatched.
164
- const done = waveStates.filter(isStoryDone);
165
- const blocked = waveStates.filter((s) =>
166
- s.labels.includes(AGENT_LABELS.BLOCKED),
167
- );
168
- const executing = waveStates.filter((s) =>
169
- s.labels.includes(AGENT_LABELS.EXECUTING),
170
- );
171
- // Undispatched = no terminal/in-progress label AND not closed. The closed
172
- // check rides on `isStoryDone` via the negation in `isUndispatched`.
173
- const undispatchedByLabel = waveStates.filter(isUndispatched);
174
-
175
- // Story #3907 — subtract ledger in-flight Stories from the dispatch set.
176
- // A Story whose `story.dispatch.start` has been recorded but whose label
177
- // has not yet flipped to `agent::executing` (the child is mid-`story-init`,
178
- // or the host crashed after the dispatch-ledger write but before the label
179
- // flip) still looks "undispatched" by label alone. Re-dispatching it would
180
- // put a second agent on the same `story-<id>` branch the worst failure
181
- // mode in the system. The ledger in-flight signal is the authoritative
182
- // "already dispatched" record, so it overrides the label view here.
183
- const dispatchable = undispatchedByLabel.filter(
184
- (s) => !inFlightSet.has(s.id),
320
+ // 1. Classify by live label. `done` / `blocked` / `executing` / `ready`.
321
+ const byClass = { done: [], blocked: [], executing: [], ready: [] };
322
+ for (const rec of records) {
323
+ byClass[classifyStory(rec)].push(rec);
324
+ }
325
+
326
+ // 1a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
327
+ // A cycle makes every Story on it permanently un-eligible (no member's
328
+ // deps can all be done), so `selectReadySet` would return an empty set
329
+ // and the terminal decision could otherwise mistake the stall for
330
+ // completion. Surface it as a `halt` so the workflow parks the Epic on
331
+ // a diagnosable condition instead of silently dropping the cycle. Build
332
+ // adjacency with `dropForeign: true` to match the Epic-scoped semantics
333
+ // (a cycle is only meaningful over the scheduled sibling set). Mirrors
334
+ // the cycle handling in `stories-wave-tick.js`.
335
+ const epicAdjacency = buildStoryAdjacency(records, { dropForeign: true });
336
+ const cycle = detectCycle(epicAdjacency);
337
+
338
+ // 2. Select the ready set under the GLOBAL in-flight cap. The selector
339
+ // re-derives adjacency from the live bodies (with `dropForeign: true` so
340
+ // a `blocked by #N` whose target is outside this Epic's Story set — a
341
+ // foreign id or a typo is pruned rather than treated as a permanent
342
+ // unsatisfiable gate that strands the dependent), and applies the
343
+ // file-overlap co-dispatch guard, returning the deterministic,
344
+ // overlap-free, dependency-satisfied subset capped at the remaining
345
+ // slots.
346
+ //
347
+ // A Story recorded in-flight on the ledger (`story.dispatch.start`
348
+ // without a matching `.end`) but whose label has not yet flipped to
349
+ // `agent::executing` (the child is mid-`story-init`, or the host crashed
350
+ // after the dispatch-ledger write but before the label flip) still reads
351
+ // as `ready` by label alone. Re-dispatching it would put a second agent
352
+ // on the same `story-<id>` branch — the worst failure mode in the
353
+ // system. So the candidate set passed to the selector marks those
354
+ // Stories `executing`: they keep occupying a slot (and gate any
355
+ // dependent, since they are not done) but are never re-selected.
356
+ //
357
+ // The slot denominator is the size of the UNION of (a) ledger-in-flight
358
+ // ids and (b) Stories carrying `agent::executing` by label. A Story that
359
+ // flipped to `agent::executing` but whose `story.dispatch.start` never
360
+ // landed in the ledger (e.g. the label flip raced ahead of the ledger
361
+ // write) occupies a real slot the ledger count alone misses; counting
362
+ // only the ledger would let the global cap be exceeded. The union is the
363
+ // authoritative occupied-slot count.
364
+ const candidates = records.map((rec) =>
365
+ inFlightSet.has(rec.id) && classifyStory(rec) === 'ready'
366
+ ? { ...rec, labels: [...rec.labels, AGENT_LABELS.EXECUTING] }
367
+ : rec,
185
368
  );
369
+ const doneIds = byClass.done.map((s) => s.id);
370
+ const occupiedSlotIds = new Set([
371
+ ...inFlight,
372
+ ...byClass.executing.map((s) => s.id),
373
+ ]);
374
+ const readySet = selectReadySet({
375
+ stories: candidates,
376
+ doneIds,
377
+ inFlight: occupiedSlotIds.size,
378
+ globalCap,
379
+ dropForeign: true,
380
+ });
186
381
 
187
- const blockedStories = blocked.map((s) => ({
382
+ const blockedStories = byClass.blocked.map((s) => ({
188
383
  storyId: s.id,
189
384
  reason: 'agent::blocked',
190
385
  detail: s.title,
191
386
  }));
192
- const gateFailures = readGateFailures(history, currentWave);
193
-
194
- // Story #3062 — scan the per-Epic lifecycle ledger for recurring
195
- // failure classes (≥2 distinct Stories sharing the same
196
- // `close-validate.end` failedGate) and upsert a
197
- // `recurring-failure-class` structured comment on the Epic when
198
- // findings are returned. Idempotent across re-ticks: the upsert path
199
- // diffs body bytes, so a tick that produces the same findings does not
200
- // duplicate the comment. Best-effort a reporter throw must not crash
201
- // the planner.
202
- const recurringFailureReporter =
203
- collabRecurringFailureReporter ??
204
- defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
205
- await safeReportRecurringFailures(recurringFailureReporter);
206
-
207
- // 6. Decide nextAction.
387
+ const gateFailures = readGateFailures(state);
388
+
389
+ // 3. Decide nextAction.
390
+ // - A blocked Story halts the Epic → observe (the workflow flips the
391
+ // Epic to agent::blocked and parks).
392
+ // - A dependency cycle among the in-scope Stories halts the Epic → halt
393
+ // (the cycle is an unsatisfiable gate; never collapse it to complete).
394
+ // - A non-empty ready set dispatch it. Fire `wave-start` on the very
395
+ // first dispatch of the run (nothing executing / in-flight / done
396
+ // yet) so the perf-aggregator can bracket the run's wall-clock.
397
+ // - Otherwise, if any Story is still executing or in-flight → observe.
398
+ // - Otherwise, if EVERY in-scope Story is done → epic-complete.
399
+ // - Otherwise the ready set is empty, nothing is in flight, yet not all
400
+ // Stories are done: at least one Story is permanently gated (an
401
+ // unsatisfiable dependency that survived adjacency closure). Halt and
402
+ // name the stuck Story ids — never silently report the Epic complete.
403
+ const allDone = byClass.done.length === records.length;
404
+ const signals = [];
208
405
  let nextAction;
209
-
210
- // Stories that are label-undispatched but recorded in-flight on the ledger
211
- // (subtracted out of `dispatchable`) must be observed, not re-dispatched —
212
- // see the in-flight subtraction above.
213
- const inFlightUndispatched = undispatchedByLabel.filter((s) =>
214
- inFlightSet.has(s.id),
215
- );
216
-
217
406
  if (blockedStories.length) {
218
- nextAction = { kind: 'observe', waitingOn: blocked.map((s) => s.id) };
219
- } else if (dispatchable.length) {
220
- // First dispatch of this wave fires `wave-start` exactly once — the
221
- // perf-aggregator (`waveParallelism` report) brackets each wave's
222
- // wall-clock from `wave-start` → `wave-complete`. The ledger in-flight
223
- // set is consulted alongside the label view so a dispatched-but-not-yet-
224
- // executing Story does not re-fire `wave-start`.
407
+ nextAction = {
408
+ kind: 'observe',
409
+ waitingOn: byClass.blocked.map((s) => s.id).sort((a, b) => a - b),
410
+ };
411
+ } else if (cycle) {
412
+ const cycleIds = cycle
413
+ .filter((id) => Number.isInteger(id))
414
+ .sort((a, b) => a - b);
415
+ nextAction = {
416
+ kind: 'halt',
417
+ reason: 'dependency-cycle',
418
+ stuckStories: cycleIds,
419
+ cycle,
420
+ };
421
+ } else if (readySet.length) {
225
422
  if (
226
- executing.length === 0 &&
227
- done.length === 0 &&
228
- inFlightUndispatched.length === 0
423
+ byClass.executing.length === 0 &&
424
+ byClass.done.length === 0 &&
425
+ inFlight.length === 0
229
426
  ) {
230
- await emit({
427
+ signals.push({
231
428
  kind: 'wave-start',
232
- index: currentWave,
233
- totalWaves,
234
- stories: wavePlan.map((s) => ({ id: storyIdOf(s), title: s.title })),
429
+ stories: records.map((s) => ({ id: s.id, title: s.title })),
235
430
  });
236
431
  }
237
432
  nextAction = {
238
433
  kind: 'dispatch',
239
- stories: dispatchable.map((s) => ({
240
- id: s.id,
434
+ stories: readySet.map((s) => ({
435
+ id: storyIdOf(s),
241
436
  title: s.title,
242
- worktree: s.worktree,
243
437
  })),
244
438
  };
245
- } else if (executing.length || inFlightUndispatched.length) {
246
- // Either a Story is `agent::executing`, or the ledger shows a
247
- // dispatched-but-unflipped Story we just declined to re-dispatch. Both
248
- // are in-flight — observe rather than collapse the wave.
439
+ } else if (byClass.executing.length || inFlight.length) {
249
440
  const waitingOn = [
250
- ...executing.map((s) => s.id),
251
- ...inFlightUndispatched.map((s) => s.id),
441
+ ...new Set([...byClass.executing.map((s) => s.id), ...inFlight]),
252
442
  ].sort((a, b) => a - b);
253
443
  nextAction = { kind: 'observe', waitingOn };
254
- } else if (currentWave + 1 >= totalWaves) {
444
+ } else if (allDone) {
445
+ // Every Story is done and nothing is in flight: the run is complete.
446
+ signals.push({ kind: 'wave-complete' });
255
447
  nextAction = { kind: 'epic-complete' };
256
448
  } else {
257
- // Closes the wave window for the perf-aggregator's wall-clock bracket.
258
- await emit({ kind: 'wave-complete', index: currentWave, totalWaves });
259
- nextAction = { kind: 'wave-complete', index: currentWave };
449
+ // Ready set empty, nothing in flight, but not all Stories are done — a
450
+ // Story is gated on an unsatisfiable dependency. Halt with the stuck ids
451
+ // (every not-done, not-in-flight Story) so the operator can see exactly
452
+ // which Story stranded the run instead of a false epic-complete.
453
+ const stuckStories = records
454
+ .filter((rec) => classifyStory(rec) !== 'done')
455
+ .map((rec) => rec.id)
456
+ .filter((id) => Number.isInteger(id))
457
+ .sort((a, b) => a - b);
458
+ nextAction = {
459
+ kind: 'halt',
460
+ reason: 'unsatisfiable-dependency',
461
+ stuckStories,
462
+ };
260
463
  }
261
464
 
262
- // Story #2891 — attach the in-flight ledger reconciliation to the
263
- // nextAction envelope. Always emit the field (empty array when the
264
- // ledger is silent) so downstream consumers can pattern-match on
265
- // presence without an existence check.
266
- nextAction['in-flight'] = inFlight;
267
-
268
- return tickResult({
465
+ return {
269
466
  nextAction,
270
467
  blockedStories,
271
468
  gateFailures,
272
- currentWave,
273
- totalWaves,
274
- });
469
+ readyCount: readySet.length,
470
+ signals,
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Throw `WaveRunnerError('old-shape-checkpoint')` when the checkpoint still
476
+ * carries any wave-batch field. The message names the offending field(s) and
477
+ * the operator remediation so a stuck delivery is diagnosable from the
478
+ * thrown error alone.
479
+ *
480
+ * @param {object} state Parsed checkpoint.
481
+ * @param {number} epicId
482
+ */
483
+ function assertNotOldShape(state, epicId) {
484
+ const present = OLD_SHAPE_FIELDS.filter((f) => Object.hasOwn(state, f));
485
+ if (present.length === 0) return;
486
+ throw new WaveRunnerError(
487
+ 'old-shape-checkpoint',
488
+ `Epic #${epicId} carries a pre-ready-set (wave-batch) epic-run-state ` +
489
+ `checkpoint (fields: ${present.join(', ')}). The ready-set /deliver ` +
490
+ `runtime cannot resume a wave-batch run. Re-run ` +
491
+ `\`node .agents/scripts/epic-deliver-prepare.js --epic ${epicId}\` to ` +
492
+ `re-seed the checkpoint in the per-Story-status shape, then re-run ` +
493
+ `/deliver.`,
494
+ );
495
+ }
496
+
497
+ /**
498
+ * Extract the in-scope Story ids from the shrunk checkpoint's per-Story
499
+ * `stories` status map (`{ [storyId]: { status, ... } }`). Returns an
500
+ * ascending-sorted, deduped array of positive integers; tolerates an absent
501
+ * / malformed map by returning `[]`.
502
+ *
503
+ * @param {object} state
504
+ * @returns {number[]}
505
+ */
506
+ function checkpointStoryIds(state) {
507
+ const stories = state?.stories;
508
+ if (!stories || typeof stories !== 'object') return [];
509
+ const ids = new Set();
510
+ for (const key of Object.keys(stories)) {
511
+ const id = Number(key);
512
+ if (Number.isInteger(id) && id > 0) ids.add(id);
513
+ }
514
+ return [...ids].sort((a, b) => a - b);
515
+ }
516
+
517
+ /**
518
+ * Attach the ledger-derived in-flight Story-id list to a `nextAction`
519
+ * envelope under the `in-flight` key. Always present (empty array when the
520
+ * ledger is silent) so downstream consumers pattern-match on presence
521
+ * without an existence check.
522
+ *
523
+ * @param {object} nextAction
524
+ * @param {number[]} inFlight
525
+ * @returns {object} the same nextAction (mutated) for call-site convenience
526
+ */
527
+ function withInFlight(nextAction, inFlight) {
528
+ nextAction['in-flight'] = inFlight;
529
+ return nextAction;
275
530
  }
276
531
 
277
532
  /**
@@ -415,17 +670,17 @@ async function defaultInFlightReader(epicId, config) {
415
670
  for (const id of started) {
416
671
  if (!ended.has(id)) inFlight.push(id);
417
672
  }
418
- return inFlight;
673
+ return inFlight.sort((a, b) => a - b);
419
674
  }
420
675
 
421
676
  function tickResult({
422
677
  nextAction,
423
678
  blockedStories = [],
424
679
  gateFailures = [],
425
- currentWave,
426
- totalWaves,
680
+ readyCount = 0,
681
+ inFlight = [],
427
682
  }) {
428
- return { nextAction, blockedStories, gateFailures, currentWave, totalWaves };
683
+ return { nextAction, blockedStories, gateFailures, readyCount, inFlight };
429
684
  }
430
685
 
431
686
  function resolveEpicId(epic) {
@@ -443,66 +698,41 @@ function positiveIntOrZero(v) {
443
698
  return Number.isInteger(v) && v >= 0 ? v : 0;
444
699
  }
445
700
 
446
- function storyIdOf(s) {
447
- if (typeof s === 'number') return s;
448
- return s.id ?? s.storyId ?? s.number;
449
- }
450
-
451
- /**
452
- * A Story is "done" when it carries `agent::done` OR its GitHub issue is
453
- * `state === 'closed'`. The closed-state arm (Story #3907) is what aligns the
454
- * wave planner with the other done-predicates in the codebase
455
- * (`reconciler.isDone`, `verifySingleResult`) so a Story closed manually
456
- * through the GitHub UI — which closes the issue without flipping the
457
- * `agent::*` label — is recognised as done and never re-dispatched.
458
- *
459
- * @param {{ labels: string[], state?: string }} s
460
- * @returns {boolean}
461
- */
462
- export function isStoryDone(s) {
463
- const labels = Array.isArray(s?.labels) ? s.labels : [];
464
- return labels.includes(AGENT_LABELS.DONE) || s?.state === 'closed';
465
- }
466
-
467
701
  /**
468
- * A wave member is "undispatched" when it carries none of the terminal /
469
- * in-progress labels AND is not a closed issue. The closed check (Story
470
- * #3907) prevents a manually-closed Story (issue closed, label not flipped)
471
- * from being re-dispatched.
702
+ * Derive gate-failure rows from the checkpoint's per-Story `stories` status
703
+ * map: every Story recorded as `failed` surfaces as a gate failure so the
704
+ * operator workflow can act on it. The shrunk checkpoint no longer carries a
705
+ * per-wave history with explicit gate names, so the gate is reported as
706
+ * `unspecified` and the recorded `title` (when present) is the detail.
472
707
  *
473
- * @param {{ labels: string[], state?: string }} s
474
- * @returns {boolean}
708
+ * @param {object} state Parsed checkpoint.
709
+ * @returns {Array<{ storyId: number, gate: string, detail?: string }>}
475
710
  */
476
- function isUndispatched(s) {
477
- const labels = Array.isArray(s?.labels) ? s.labels : [];
478
- return (
479
- !isStoryDone(s) &&
480
- !labels.includes(AGENT_LABELS.BLOCKED) &&
481
- !labels.includes(AGENT_LABELS.EXECUTING)
482
- );
483
- }
484
-
485
- function readGateFailures(history, currentWave) {
486
- const prior = history[currentWave - 1];
487
- if (!prior || !Array.isArray(prior.stories)) return [];
488
- return prior.stories
489
- .filter((s) => s.status === 'failed' && typeof s.detail === 'string')
490
- .map((s) => ({
491
- storyId: s.storyId,
492
- gate: s.gate ?? 'unspecified',
493
- detail: s.detail,
494
- }));
711
+ function readGateFailures(state) {
712
+ const stories = state?.stories;
713
+ if (!stories || typeof stories !== 'object') return [];
714
+ const out = [];
715
+ for (const [key, rec] of Object.entries(stories)) {
716
+ const id = Number(key);
717
+ if (!Number.isInteger(id) || id <= 0) continue;
718
+ if (rec?.status !== 'failed') continue;
719
+ const row = { storyId: id, gate: 'unspecified' };
720
+ if (typeof rec.title === 'string' && rec.title) row.detail = rec.title;
721
+ out.push(row);
722
+ }
723
+ return out.sort((a, b) => a.storyId - b.storyId);
495
724
  }
496
725
 
497
726
  /**
498
727
  * Default emitter — appends to per-Epic `signals.ndjson`. Best-effort;
499
728
  * never throws. Tests override via `collaborators.signalEmit`.
500
729
  *
501
- * Story #3909 — the planner now emits only the two wave events that have a
502
- * live consumer: `wave-start` and `wave-complete`, which the perf-aggregator
503
- * (`waveParallelism` report) brackets into per-wave wall-clock. The
504
- * write-only `wave-tick` (per-call telemetry) and `epic-complete` (no reader)
505
- * emits were dropped they duplicated the `epic-run-state` checkpoint and the
730
+ * Story #3909 / #4155 — the planner emits only the two wave-window
731
+ * forensics events with a live consumer: `wave-start` (fired on the run's
732
+ * first dispatch) and `wave-complete` (fired when the run finishes), which
733
+ * the perf-aggregator (`waveParallelism` report) brackets into the run's
734
+ * wall-clock. The write-only per-call telemetry and `epic-complete` emits
735
+ * were dropped — they duplicated the `epic-run-state` checkpoint and the
506
736
  * `epic-run-progress` rollup and nothing consumed them.
507
737
  */
508
738
  function defaultSignalEmit(epicId, ctx) {