mandrel 1.69.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 (35) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/docs/workflows.md +1 -1
  3. package/.agents/scripts/agents-update-preflight.js +235 -0
  4. package/.agents/scripts/apply-quality-bootstrap.js +79 -0
  5. package/.agents/scripts/audit-labels-bootstrap.js +52 -30
  6. package/.agents/scripts/audit-to-stories.js +54 -0
  7. package/.agents/scripts/bootstrap.js +13 -3
  8. package/.agents/scripts/generate-config-docs.js +189 -94
  9. package/.agents/scripts/lib/audit-suite/findings.js +0 -4
  10. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +99 -0
  11. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +13 -5
  12. package/.agents/scripts/lib/baseline-snapshot.js +163 -4
  13. package/.agents/scripts/lib/baselines/refresh-service.js +0 -4
  14. package/.agents/scripts/lib/config/baselines.js +0 -20
  15. package/.agents/scripts/lib/config/temp-paths.js +0 -31
  16. package/.agents/scripts/lib/crap-utils.js +281 -0
  17. package/.agents/scripts/lib/orchestration/dispatch-engine.js +0 -2
  18. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +0 -84
  19. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +3 -4
  20. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +0 -4
  21. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +101 -70
  22. package/.agents/scripts/lib/orchestration/spec-renderer.js +42 -14
  23. package/.agents/scripts/lib/orchestration/ticket-lease.js +3 -0
  24. package/.agents/scripts/lib/story-body/story-body.js +110 -65
  25. package/.agents/scripts/lib/test-tiers.js +13 -7
  26. package/.agents/scripts/lib/wave-runner/tick.js +177 -53
  27. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +226 -0
  28. package/.agents/scripts/providers/github/issues.js +48 -0
  29. package/.agents/scripts/providers/github.js +1 -0
  30. package/.agents/workflows/agents-update.md +205 -28
  31. package/README.md +20 -0
  32. package/docs/CHANGELOG.md +32 -0
  33. package/lib/cli/registry.js +49 -6
  34. package/lib/cli/update.js +335 -332
  35. package/package.json +16 -11
@@ -157,6 +157,45 @@ function sanitizeLabels(labels) {
157
157
  return out.length > 0 ? out : undefined;
158
158
  }
159
159
 
160
+ /**
161
+ * Descriptor table for the structured-body → markdown projection, in
162
+ * canonical emit order (`## Goal`, `## Changes`, `## Acceptance`,
163
+ * `## Verify`). Each descriptor reads one body field and returns the
164
+ * section's markdown block when the field is present and non-empty, or `null`
165
+ * to omit it. Adding a section is a one-line data edit rather than a new
166
+ * branch in {@link renderBody}.
167
+ *
168
+ * @type {Array<{ field: string, render: (value: unknown) => string | null }>}
169
+ */
170
+ const SPEC_BODY_SECTIONS = [
171
+ {
172
+ field: 'goal',
173
+ render: (goal) =>
174
+ typeof goal === 'string' && goal.length > 0 ? `## Goal\n${goal}` : null,
175
+ },
176
+ {
177
+ field: 'changes',
178
+ render: (changes) =>
179
+ Array.isArray(changes) && changes.length > 0
180
+ ? `## Changes\n${changes.map((c) => `- ${String(c)}`).join('\n')}`
181
+ : null,
182
+ },
183
+ {
184
+ field: 'acceptance',
185
+ render: (acceptance) =>
186
+ Array.isArray(acceptance) && acceptance.length > 0
187
+ ? `## Acceptance\n${acceptance.map((a) => `- [ ] ${String(a)}`).join('\n')}`
188
+ : null,
189
+ },
190
+ {
191
+ field: 'verify',
192
+ render: (verify) =>
193
+ Array.isArray(verify) && verify.length > 0
194
+ ? `## Verify\n${verify.map((v) => `- ${String(v)}`).join('\n')}`
195
+ : null,
196
+ },
197
+ ];
198
+
160
199
  /**
161
200
  * Convert a decomposer body value into a spec `body` string. The
162
201
  * decomposer schema admits two shapes for a Story body:
@@ -190,20 +229,9 @@ function renderBody(body) {
190
229
  if (typeof body !== 'object') return undefined;
191
230
 
192
231
  const sections = [];
193
- if (typeof body.goal === 'string' && body.goal.length > 0) {
194
- sections.push(`## Goal\n${body.goal}`);
195
- }
196
- if (Array.isArray(body.changes) && body.changes.length > 0) {
197
- const items = body.changes.map((c) => `- ${String(c)}`).join('\n');
198
- sections.push(`## Changes\n${items}`);
199
- }
200
- if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
201
- const items = body.acceptance.map((a) => `- [ ] ${String(a)}`).join('\n');
202
- sections.push(`## Acceptance\n${items}`);
203
- }
204
- if (Array.isArray(body.verify) && body.verify.length > 0) {
205
- const items = body.verify.map((v) => `- ${String(v)}`).join('\n');
206
- sections.push(`## Verify\n${items}`);
232
+ for (const descriptor of SPEC_BODY_SECTIONS) {
233
+ const block = descriptor.render(body[descriptor.field]);
234
+ if (block !== null) sections.push(block);
207
235
  }
208
236
  return sections.length > 0 ? sections.join('\n\n') : undefined;
209
237
  }
@@ -56,6 +56,9 @@ import { parseLedger } from './lifecycle/trace-logger.js';
56
56
  * coordinated under a shared identity) and no assignee PATCH ever writes a
57
57
  * literal `[USERNAME]` (HTTP 422).
58
58
  */
59
+ // kept (dead-export allowlist): public config sentinel — the distributed
60
+ // `.agentrc.json` / templates carry this literal; exported so consumers and
61
+ // future call sites resolve it by symbol rather than re-typing the string.
59
62
  export const OPERATOR_HANDLE_PLACEHOLDER = '@[USERNAME]';
60
63
  const OPERATOR_HANDLE_PLACEHOLDER_BARE = '[USERNAME]';
61
64
 
@@ -683,6 +683,108 @@ function serializePathEntry(entry) {
683
683
  return JSON.stringify({ path: entry.path, assumption: entry.assumption });
684
684
  }
685
685
 
686
+ /**
687
+ * Descriptor table for the human-readable Story-body sections, in canonical
688
+ * emit order (`## Goal`, `## Changes`, `## Acceptance`, `## Verify`,
689
+ * `## References`). Each descriptor reads one body field and returns the
690
+ * section's markdown block when the field is present and non-empty, or `null`
691
+ * to omit the section.
692
+ *
693
+ * Standardising the section ladder as a single data table makes adding a new
694
+ * optional section a one-line edit here rather than a new control-flow branch
695
+ * in {@link serialize}.
696
+ *
697
+ * @type {Array<{ field: string, render: (value: unknown) => string | null }>}
698
+ */
699
+ const SERIALIZE_SECTIONS = [
700
+ {
701
+ field: 'goal',
702
+ render: (goal) =>
703
+ typeof goal === 'string' && goal.trim().length > 0
704
+ ? `## Goal\n${goal.trim()}`
705
+ : null,
706
+ },
707
+ {
708
+ field: 'changes',
709
+ render: (changes) =>
710
+ Array.isArray(changes) && changes.length > 0
711
+ ? `## Changes\n${changes.map((c) => `- ${serializePathEntry(c)}`).join('\n')}`
712
+ : null,
713
+ },
714
+ {
715
+ field: 'acceptance',
716
+ render: (acceptance) =>
717
+ Array.isArray(acceptance) && acceptance.length > 0
718
+ ? `## Acceptance\n${acceptance.map((a) => `- [ ] ${a}`).join('\n')}`
719
+ : null,
720
+ },
721
+ {
722
+ field: 'verify',
723
+ render: (verify) =>
724
+ Array.isArray(verify) && verify.length > 0
725
+ ? `## Verify\n${verify.map((v) => `- ${v}`).join('\n')}`
726
+ : null,
727
+ },
728
+ {
729
+ field: 'references',
730
+ render: (references) =>
731
+ Array.isArray(references) && references.length > 0
732
+ ? `## References\n${references.map((r) => `- ${serializePathEntry(r)}`).join('\n')}`
733
+ : null,
734
+ },
735
+ ];
736
+
737
+ /**
738
+ * Build the trailing `<!-- meta: {...} -->` block carrying the fields that
739
+ * have no human-readable section (`wide`, `reason_to_exist`,
740
+ * `estimated_test_files`). Returns the empty string when no meta field is
741
+ * present so {@link serialize} appends nothing.
742
+ *
743
+ * Key insertion order (`wide` → `reason_to_exist` → `estimated_test_files`)
744
+ * is load-bearing: it fixes the serialized JSON byte sequence the parser's
745
+ * meta round-trip and the unit suite assert against.
746
+ *
747
+ * @param {StoryBody} body
748
+ * @returns {string}
749
+ */
750
+ function serializeMetaBlock(body) {
751
+ const metaFields = {};
752
+ const wide = normalizeWide(body.wide);
753
+ if (wide !== null) {
754
+ metaFields.wide = wide;
755
+ }
756
+ const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
757
+ if (reasonToExist !== null) {
758
+ metaFields.reason_to_exist = reasonToExist;
759
+ }
760
+ if (typeof body.estimated_test_files === 'number') {
761
+ metaFields.estimated_test_files = body.estimated_test_files;
762
+ }
763
+ if (Object.keys(metaFields).length === 0) return '';
764
+ return `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
765
+ }
766
+
767
+ /**
768
+ * Build the optional `---` footer block (`parent` / `Epic` / `blocked by`
769
+ * lines). Returns the empty string when `opts.includeFooter` is falsy.
770
+ *
771
+ * @param {StoryBody} body
772
+ * @param {SerializeOptions} opts
773
+ * @returns {string}
774
+ */
775
+ function serializeFooter(body, opts) {
776
+ if (!opts.includeFooter) return '';
777
+ const footerLines = ['---'];
778
+ if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
779
+ if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
780
+ if (Array.isArray(body.depends_on)) {
781
+ for (const dep of body.depends_on) {
782
+ footerLines.push(`blocked by ${dep}`);
783
+ }
784
+ }
785
+ return `\n\n${footerLines.join('\n')}`;
786
+ }
787
+
686
788
  /**
687
789
  * Serialize a structured {@link StoryBody} back to the canonical markdown
688
790
  * format written to GitHub issue bodies.
@@ -707,73 +809,16 @@ export function serialize(body, opts = {}) {
707
809
  }
708
810
 
709
811
  const sections = [];
710
-
711
- // ## Goal
712
- if (typeof body.goal === 'string' && body.goal.trim().length > 0) {
713
- sections.push(`## Goal\n${body.goal.trim()}`);
714
- }
715
-
716
- // ## Changes
717
- if (Array.isArray(body.changes) && body.changes.length > 0) {
718
- const items = body.changes
719
- .map((c) => `- ${serializePathEntry(c)}`)
720
- .join('\n');
721
- sections.push(`## Changes\n${items}`);
722
- }
723
-
724
- // ## Acceptance
725
- if (Array.isArray(body.acceptance) && body.acceptance.length > 0) {
726
- const items = body.acceptance.map((a) => `- [ ] ${a}`).join('\n');
727
- sections.push(`## Acceptance\n${items}`);
728
- }
729
-
730
- // ## Verify
731
- if (Array.isArray(body.verify) && body.verify.length > 0) {
732
- const items = body.verify.map((v) => `- ${v}`).join('\n');
733
- sections.push(`## Verify\n${items}`);
734
- }
735
-
736
- // ## References (only when non-empty)
737
- if (Array.isArray(body.references) && body.references.length > 0) {
738
- const items = body.references
739
- .map((r) => `- ${serializePathEntry(r)}`)
740
- .join('\n');
741
- sections.push(`## References\n${items}`);
812
+ for (const descriptor of SERIALIZE_SECTIONS) {
813
+ const block = descriptor.render(body[descriptor.field]);
814
+ if (block !== null) sections.push(block);
742
815
  }
743
816
 
744
- let out = sections.join('\n\n');
745
-
746
- // Meta block for fields not representable as human-readable sections.
747
- const metaFields = {};
748
- const wide = normalizeWide(body.wide);
749
- if (wide !== null) {
750
- metaFields.wide = wide;
751
- }
752
- const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
753
- if (reasonToExist !== null) {
754
- metaFields.reason_to_exist = reasonToExist;
755
- }
756
- if (typeof body.estimated_test_files === 'number') {
757
- metaFields.estimated_test_files = body.estimated_test_files;
758
- }
759
- if (Object.keys(metaFields).length > 0) {
760
- out += `\n\n<!-- meta: ${JSON.stringify(metaFields)} -->`;
761
- }
762
-
763
- // Footer
764
- if (opts.includeFooter) {
765
- const footerLines = ['---'];
766
- if (opts.footer?.parent) footerLines.push(`parent: #${opts.footer.parent}`);
767
- if (opts.footer?.epic) footerLines.push(`Epic: #${opts.footer.epic}`);
768
- if (Array.isArray(body.depends_on)) {
769
- for (const dep of body.depends_on) {
770
- footerLines.push(`blocked by ${dep}`);
771
- }
772
- }
773
- out += `\n\n${footerLines.join('\n')}`;
774
- }
775
-
776
- return out;
817
+ return (
818
+ sections.join('\n\n') +
819
+ serializeMetaBlock(body) +
820
+ serializeFooter(body, opts)
821
+ );
777
822
  }
778
823
 
779
824
  // ---------------------------------------------------------------------------
@@ -33,20 +33,26 @@ const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
33
33
  * `tests` holds the framework's suite tree; `lib` holds the published CLI
34
34
  * (under `lib/cli` and `lib/migrations`) whose tests are colocated in
35
35
  * `__tests__` directories per the unit-tier convention in
36
- * `rules/testing-standards.md`. Without `lib` here, both the quick /
37
- * integration walk and the full-tier glob set miss the colocated CLI tests,
36
+ * `rules/testing-standards.md`. `.agents/scripts` holds the orchestration
37
+ * engine; some of its modules colocate tests in `__tests__` directories the
38
+ * same way (Story #4195). Without each root here, both the quick /
39
+ * integration walk and the full-tier glob set miss the colocated tests,
38
40
  * leaving that coverage dark in `npm test`. The matching full-tier globs
39
41
  * live in `FULL_TIER_GLOBS`.
40
42
  */
41
- const TEST_WALK_ROOTS = ['tests', 'lib'];
43
+ const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
42
44
 
43
45
  /**
44
46
  * Glob targets for the `full` tier — one per walk root in `TEST_WALK_ROOTS`.
45
- * The `tests` glob is a flat recursive sweep; the `lib` glob is scoped to
46
- * `__tests__` subtrees so it only matches colocated tests, never the shipped
47
- * source modules themselves.
47
+ * The `tests` glob is a flat recursive sweep; the `lib` and `.agents/scripts`
48
+ * globs are scoped to `__tests__` subtrees so they only match colocated
49
+ * tests, never the shipped source modules themselves.
48
50
  */
49
- const FULL_TIER_GLOBS = ['tests/**/*.test.js', 'lib/**/__tests__/**/*.test.js'];
51
+ const FULL_TIER_GLOBS = [
52
+ 'tests/**/*.test.js',
53
+ 'lib/**/__tests__/**/*.test.js',
54
+ '.agents/scripts/**/__tests__/**/*.test.js',
55
+ ];
50
56
 
51
57
  /**
52
58
  * @param {string} dir
@@ -12,7 +12,7 @@
12
12
  * state) for every Story in scope,
13
13
  * 3. classifies each by live label (`classifyStory`), re-derives
14
14
  * adjacency from the live bodies (`buildStoryAdjacency`, inside
15
- * `selectReadySet`), and selects the ready set under a global
15
+ * `selectReadySet`) and selects the ready set under a global
16
16
  * in-flight cap with the file-overlap co-dispatch guard
17
17
  * (`storiesOverlap`),
18
18
  * 4. returns a `WaveTickResult` describing the next action.
@@ -29,6 +29,20 @@
29
29
  * stories) flow back through result fields; unexpected failures (GH 5xx,
30
30
  * malformed / old-shape checkpoint) throw `WaveRunnerError`.
31
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.
45
+ *
32
46
  * @module lib/wave-runner/tick
33
47
  */
34
48
 
@@ -80,6 +94,13 @@ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
80
94
  * dependency cycle among the in-scope Stories is likewise surfaced as a
81
95
  * `halt` (with the offending `cycle`), never collapsed to `epic-complete`.
82
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.
103
+ *
83
104
  * @typedef {object} WaveTickArgs
84
105
  * @property {number | { id: number }} epic
85
106
  * @property {{
@@ -94,13 +115,81 @@ const OLD_SHAPE_FIELDS = Object.freeze(['plan', 'currentWave', 'totalWaves']);
94
115
  * @param {WaveTickArgs} args
95
116
  */
96
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) {
97
187
  const epicId = resolveEpicId(args.epic);
98
188
  const {
99
189
  provider: collabProvider,
100
190
  epicRunStateStore: collabStore,
101
191
  signalEmit,
102
192
  inFlightReader: collabInFlightReader,
103
- recurringFailureReporter: collabRecurringFailureReporter,
104
193
  } = args.collaborators ?? {};
105
194
  const ctx = args.ctx ?? {};
106
195
  const provider = collabProvider ?? ctx.provider;
@@ -108,18 +197,41 @@ export async function tick(args = {}) {
108
197
  throw new WaveRunnerError('invalid-input', 'provider is required');
109
198
  }
110
199
  // The ready-set tick is stateless. When the caller does not supply a
111
- // collaborator shim, read the `epic-run-state` structured comment
112
- // directly via the function-based store.
200
+ // collaborator shim, read the `epic-run-state` structured comment directly
201
+ // via the function-based store.
113
202
  const epicRunStateStore = collabStore ?? {
114
203
  read: () => epicRunStateStoreModule.read({ provider, epicId }),
115
204
  };
116
205
  const emit = signalEmit ?? defaultSignalEmit(epicId, ctx);
117
206
  const inFlightReader =
118
207
  collabInFlightReader ?? (() => defaultInFlightReader(epicId, ctx?.config));
208
+ return { epicId, provider, epicRunStateStore, emit, inFlightReader, ctx };
209
+ }
119
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) {
120
232
  let state;
121
233
  try {
122
- state = await epicRunStateStore.read();
234
+ state = await store.read();
123
235
  } catch (err) {
124
236
  throw new WaveRunnerError('checkpoint-read', err);
125
237
  }
@@ -129,39 +241,30 @@ export async function tick(args = {}) {
129
241
  `no epic-run-state comment on Epic #${epicId}`,
130
242
  );
131
243
  }
132
-
133
- // Fail closed on an old-shape (wave-batch) checkpoint. A `plan` /
134
- // `currentWave` / `totalWaves` comment predates the ready-set cutover
135
- // (Story #4155); the ready-set runtime would otherwise ignore those
136
- // fields and re-derive readiness from live labels — silently discarding
137
- // an in-progress wave-batch run's resume pointer. Refuse with an explicit
138
- // operator remediation instead.
139
244
  assertNotOldShape(state, epicId);
245
+ return state;
246
+ }
140
247
 
141
- const globalCap = positiveIntOrZero(state.concurrencyCap);
142
- const storyIds = checkpointStoryIds(state);
143
-
144
- if (storyIds.length === 0) {
145
- // No Stories in scope the Epic has nothing to dispatch.
146
- return tickResult({
147
- nextAction: withInFlight({ kind: 'epic-complete' }, []),
148
- readyCount: 0,
149
- inFlight: [],
150
- });
151
- }
152
-
153
- // 1. Re-fetch the live Story records (body + labels + issue state) for
154
- // every Story in scope. The body feeds `buildStoryAdjacency` (inside
155
- // `selectReadySet`) so the dependency edges are always read from the
156
- // current ticket text, never a stale checkpoint snapshot. In-flight
157
- // Stories are force-fresh-fetched so a label that flipped since the
158
- // last tick is observed; every other Story serves from the provider's
159
- // in-process cache.
160
- const inFlight = await safeReadInFlight(inFlightReader);
161
- const inFlightSet = new Set(inFlight);
162
- let records;
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) {
163
266
  try {
164
- records = await Promise.all(
267
+ return await Promise.all(
165
268
  storyIds.map(async (id) => {
166
269
  const opts = inFlightSet.has(id) ? { fresh: true } : {};
167
270
  const ticket = await provider.getTicket(id, opts);
@@ -185,14 +288,42 @@ export async function tick(args = {}) {
185
288
  } catch (err) {
186
289
  throw new WaveRunnerError('story-fetch', err);
187
290
  }
291
+ }
292
+
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);
318
+ const inFlightSet = new Set(inFlight);
188
319
 
189
- // 2. Classify by live label. `done` / `blocked` / `executing` / `ready`.
320
+ // 1. Classify by live label. `done` / `blocked` / `executing` / `ready`.
190
321
  const byClass = { done: [], blocked: [], executing: [], ready: [] };
191
322
  for (const rec of records) {
192
323
  byClass[classifyStory(rec)].push(rec);
193
324
  }
194
325
 
195
- // 2a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
326
+ // 1a. Detect a dependency cycle among the in-scope Stories BEFORE selecting.
196
327
  // A cycle makes every Story on it permanently un-eligible (no member's
197
328
  // deps can all be done), so `selectReadySet` would return an empty set
198
329
  // and the terminal decision could otherwise mistake the stall for
@@ -204,7 +335,7 @@ export async function tick(args = {}) {
204
335
  const epicAdjacency = buildStoryAdjacency(records, { dropForeign: true });
205
336
  const cycle = detectCycle(epicAdjacency);
206
337
 
207
- // 3. Select the ready set under the GLOBAL in-flight cap. The selector
338
+ // 2. Select the ready set under the GLOBAL in-flight cap. The selector
208
339
  // re-derives adjacency from the live bodies (with `dropForeign: true` so
209
340
  // a `blocked by #N` whose target is outside this Epic's Story set — a
210
341
  // foreign id or a typo — is pruned rather than treated as a permanent
@@ -248,14 +379,6 @@ export async function tick(args = {}) {
248
379
  dropForeign: true,
249
380
  });
250
381
 
251
- // 4. Best-effort recurring-failure scan (≥2 distinct Stories sharing the
252
- // same `close-validate.end` failedGate). Idempotent across re-ticks; a
253
- // reporter throw must not crash the planner.
254
- const recurringFailureReporter =
255
- collabRecurringFailureReporter ??
256
- defaultRecurringFailureReporter({ provider, epicId, config: ctx?.config });
257
- await safeReportRecurringFailures(recurringFailureReporter);
258
-
259
382
  const blockedStories = byClass.blocked.map((s) => ({
260
383
  storyId: s.id,
261
384
  reason: 'agent::blocked',
@@ -263,7 +386,7 @@ export async function tick(args = {}) {
263
386
  }));
264
387
  const gateFailures = readGateFailures(state);
265
388
 
266
- // 5. Decide nextAction.
389
+ // 3. Decide nextAction.
267
390
  // - A blocked Story halts the Epic → observe (the workflow flips the
268
391
  // Epic to agent::blocked and parks).
269
392
  // - A dependency cycle among the in-scope Stories halts the Epic → halt
@@ -278,6 +401,7 @@ export async function tick(args = {}) {
278
401
  // unsatisfiable dependency that survived adjacency closure). Halt and
279
402
  // name the stuck Story ids — never silently report the Epic complete.
280
403
  const allDone = byClass.done.length === records.length;
404
+ const signals = [];
281
405
  let nextAction;
282
406
  if (blockedStories.length) {
283
407
  nextAction = {
@@ -300,7 +424,7 @@ export async function tick(args = {}) {
300
424
  byClass.done.length === 0 &&
301
425
  inFlight.length === 0
302
426
  ) {
303
- await emit({
427
+ signals.push({
304
428
  kind: 'wave-start',
305
429
  stories: records.map((s) => ({ id: s.id, title: s.title })),
306
430
  });
@@ -319,7 +443,7 @@ export async function tick(args = {}) {
319
443
  nextAction = { kind: 'observe', waitingOn };
320
444
  } else if (allDone) {
321
445
  // Every Story is done and nothing is in flight: the run is complete.
322
- await emit({ kind: 'wave-complete' });
446
+ signals.push({ kind: 'wave-complete' });
323
447
  nextAction = { kind: 'epic-complete' };
324
448
  } else {
325
449
  // Ready set empty, nothing in flight, but not all Stories are done — a
@@ -338,13 +462,13 @@ export async function tick(args = {}) {
338
462
  };
339
463
  }
340
464
 
341
- return tickResult({
342
- nextAction: withInFlight(nextAction, inFlight),
465
+ return {
466
+ nextAction,
343
467
  blockedStories,
344
468
  gateFailures,
345
469
  readyCount: readySet.length,
346
- inFlight,
347
- });
470
+ signals,
471
+ };
348
472
  }
349
473
 
350
474
  /**