mandrel 1.68.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.agents/docs/agentrc-reference.json +1 -2
  2. package/.agents/docs/configuration.md +2 -4
  3. package/.agents/schemas/agentrc.schema.json +1 -5
  4. package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
  5. package/.agents/scripts/epic-deliver-preflight.js +30 -13
  6. package/.agents/scripts/epic-deliver-prepare.js +40 -53
  7. package/.agents/scripts/epic-execute-record-wave.js +119 -133
  8. package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
  9. package/.agents/scripts/lib/config/explain.js +0 -2
  10. package/.agents/scripts/lib/config/limits.js +19 -8
  11. package/.agents/scripts/lib/config-settings-schema.js +1 -2
  12. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  13. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  14. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  16. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  17. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  18. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  19. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  21. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  23. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  24. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  25. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  26. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  27. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  28. package/.agents/scripts/lib/story-adjacency.js +14 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  30. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  31. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  32. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  33. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  34. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  35. package/.agents/scripts/stories-wave-tick.js +262 -161
  36. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  37. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  38. package/.agents/skills/skills.index.json +2 -2
  39. package/.agents/workflows/deliver.md +12 -9
  40. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  41. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  42. package/.agents/workflows/helpers/plan-epic.md +13 -10
  43. package/.agents/workflows/plan.md +1 -1
  44. package/docs/CHANGELOG.md +14 -0
  45. package/package.json +1 -1
  46. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -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
- }
@@ -8,23 +8,40 @@
8
8
  *
9
9
  * No top-level side effects — safe to import from tests without
10
10
  * triggering GitHub I/O.
11
+ *
12
+ * Story #4157 — each projected story's `wave` is a **render-time
13
+ * dependency depth** derived from the manifest's `dependsOn` edges via the
14
+ * shared `deriveStoryDepths` lens (`manifest-render-waves.js`, built on
15
+ * `assignLayers`), not the persisted `earliestWave`. Scheduling no longer
16
+ * stamps a wave field onto the run checkpoint (Epic #4151 / Story #4155),
17
+ * so the rollup re-derives depth from the dependency graph at render time.
11
18
  */
12
19
 
20
+ import { deriveStoryDepths } from './manifest-render-waves.js';
21
+
13
22
  /**
14
23
  * Pure: project a full dispatch manifest into the `{ stories }` shape
15
24
  * `renderManifest` accepts. Returns the canonical, non-ungrouped story
16
25
  * rows used by the Epic-level dispatch-manifest comment.
17
26
  *
27
+ * The `wave` field is the render-time dependency depth (Story #4157):
28
+ * `deriveStoryDepths` runs `assignLayers` over the entries' `dependsOn`
29
+ * edges, so a Story with no in-set dependency is wave 0 and a dependent
30
+ * sits one layer deeper than its deepest dependency. Entries the lens
31
+ * cannot place (e.g. a non-integer storyId that survives the sentinel
32
+ * filter) fall back to `-1`.
33
+ *
18
34
  * @param {object} manifest
19
35
  * @returns {{ storyId: number|string, wave: number, title: string }[]}
20
36
  */
21
37
  export function projectStoriesFromManifest(manifest) {
22
38
  const storyManifest = manifest?.storyManifest ?? [];
39
+ const depths = deriveStoryDepths(storyManifest);
23
40
  return storyManifest
24
41
  .filter((s) => s && s.storyId !== '__ungrouped__')
25
42
  .map((s) => ({
26
43
  storyId: s.storyId,
27
- wave: s.earliestWave ?? -1,
44
+ wave: depths.get(s.storyId) ?? -1,
28
45
  title: s.storyTitle ?? s.storySlug ?? '',
29
46
  }));
30
47
  }
@@ -17,15 +17,81 @@
17
17
  * Imports the small pure helpers from `manifest-helpers.js` — the
18
18
  * formatter re-exports them so existing call-sites that read these
19
19
  * names off `manifest-formatter.js` keep working.
20
+ *
21
+ * Story #4157 — the per-wave grouping key is now a **render-time
22
+ * dependency depth** derived from each Story entry's `dependsOn` edges via
23
+ * `assignLayers` (`lib/Graph.js`), not the persisted `earliestWave` field
24
+ * the planner stamped on the manifest. Scheduling no longer persists waves
25
+ * onto the run checkpoint (Epic #4151 / Story #4155), so the rollup
26
+ * re-derives depth from the dependency graph at the moment it renders. The
27
+ * shared `deriveStoryDepths` lens below is the single home for that
28
+ * derivation; `dispatch-manifest-render.js` imports it so both
29
+ * operator-facing surfaces group by the same render-time depths.
20
30
  */
21
31
 
32
+ import { assignLayers } from '../Graph.js';
22
33
  import { AGENT_LABELS } from '../label-constants.js';
34
+ import { buildStoryAdjacency } from '../story-adjacency.js';
23
35
  import {
24
36
  deriveStorySymbol,
25
37
  deriveWaveStatus,
26
38
  waveHeadingText,
27
39
  } from './manifest-helpers.js';
28
40
 
41
+ // ---------------------------------------------------------------------------
42
+ // Render-time dependency-depth lens (Story #4157)
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Derive a render-time dependency depth for every Story entry in a
47
+ * `storyManifest`, keyed by storyId.
48
+ *
49
+ * The depth is computed by feeding the entries' `dependsOn` edges through
50
+ * the canonical `buildStoryAdjacency` builder (the same one the dispatch
51
+ * pipeline and `stories-wave-tick.js` use) and then `assignLayers`
52
+ * (`lib/Graph.js`): a Story with no dependency edges is depth 0, and a
53
+ * Story sits one layer deeper than its deepest in-set dependency. This
54
+ * replaces the planner's persisted `earliestWave` as the grouping key so
55
+ * the rollup renders correctly from the per-Story checkpoint shape, which
56
+ * no longer carries a wave field (Epic #4151 / Story #4155).
57
+ *
58
+ * `buildStoryAdjacency` reads each entry's id from `id ?? number`, so the
59
+ * entries are adapted to expose `id: storyId` and `dependsOn`. Foreign
60
+ * edges (pointing outside the supplied entry set) are dropped — the
61
+ * default `dropForeign: true` — so the DAG stays closed over the rendered
62
+ * Stories and depth never deepens on a reference the rollup cannot show.
63
+ * The ungrouped sentinel (`storyId === '__ungrouped__'`) and any non-object
64
+ * entry are skipped; they never participate in the graph.
65
+ *
66
+ * Pure: no IO, no clock. Returns a `Map<storyId, depth>` covering exactly
67
+ * the graph-eligible entries.
68
+ *
69
+ * @param {object[]} storyManifest
70
+ * @returns {Map<number|string, number>}
71
+ */
72
+ export function deriveStoryDepths(storyManifest) {
73
+ if (!Array.isArray(storyManifest)) return new Map();
74
+ const eligible = storyManifest.filter(
75
+ (s) =>
76
+ s !== null &&
77
+ typeof s === 'object' &&
78
+ s.storyId !== '__ungrouped__' &&
79
+ Number.isInteger(Number(s.storyId)),
80
+ );
81
+ const records = eligible.map((s) => ({
82
+ id: Number(s.storyId),
83
+ dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
84
+ }));
85
+ const adjacency = buildStoryAdjacency(records);
86
+ const layers = assignLayers(adjacency);
87
+
88
+ const depths = new Map();
89
+ for (const s of eligible) {
90
+ depths.set(s.storyId, layers.get(Number(s.storyId)) ?? 0);
91
+ }
92
+ return depths;
93
+ }
94
+
29
95
  // ---------------------------------------------------------------------------
30
96
  // ---------------------------------------------------------------------------
31
97
 
@@ -90,14 +156,20 @@ function pickWaveTail(status, waveIdx, sortedWaves, storyCount) {
90
156
  * (a Story is "done" when it carries `agent::done`) — the unit
91
157
  * `deriveWaveStatus` consumes.
92
158
  *
159
+ * Story #4157 — the bucket key is the render-time dependency depth from
160
+ * `depths` (keyed by storyId), not the persisted `earliestWave`. The
161
+ * ungrouped sentinel and any entry the lens could not place fall into the
162
+ * `-1` "Ungrouped" bucket so they still render.
163
+ *
93
164
  * @param {object[]} waveStories
165
+ * @param {Map<number|string, number>} depths
94
166
  * @returns {{ waveGroups: Map<number, object[]>, waveStats: Map<number, { total: number, done: number }> }}
95
167
  */
96
- function groupStoriesByWave(waveStories) {
168
+ function groupStoriesByWave(waveStories, depths) {
97
169
  const waveGroups = new Map();
98
170
  const waveStats = new Map();
99
171
  for (const story of waveStories) {
100
- const w = story.earliestWave ?? -1;
172
+ const w = depths.get(story.storyId) ?? -1;
101
173
  if (!waveGroups.has(w)) {
102
174
  waveGroups.set(w, []);
103
175
  waveStats.set(w, { total: 0, done: 0 });
@@ -126,7 +198,8 @@ export function renderNestedWaveSections(storyManifest) {
126
198
  validateWaveSection('story', s),
127
199
  );
128
200
 
129
- const { waveGroups, waveStats } = groupStoriesByWave(waveStories);
201
+ const depths = deriveStoryDepths(waveStories);
202
+ const { waveGroups, waveStats } = groupStoriesByWave(waveStories, depths);
130
203
  const sortedWaves = [...waveGroups.keys()].sort((a, b) => a - b);
131
204
  const lines = [];
132
205
 
@@ -230,4 +303,4 @@ function renderImplicitDepBullet(finding) {
230
303
 
231
304
  // Test-only: surface the private predicate so the sibling unit test can
232
305
  // exercise each branch without going through the full renderer.
233
- export const __testables = { validateWaveSection };
306
+ export const __testables = { validateWaveSection, groupStoriesByWave };
@@ -1,18 +1,21 @@
1
1
  /**
2
2
  * lib/story-adjacency.js — the single story-level adjacency builder.
3
3
  *
4
- * All three wave-computation wrappers bottom out in the shared
5
- * `lib/Graph.js` kernel (`detectCycle` / `assignLayers` /
6
- * `computeWaves`), but each historically re-implemented the step that
7
- * turns a list of Story records into the `Map<storyId, number[]>`
8
- * adjacency the kernel consumes. This module is now the one home for
9
- * that step; the consumers are:
4
+ * Both Epic-path wave-computation wrappers bottom out in the shared
5
+ * `lib/Graph.js` kernel (`detectCycle` / `assignLayers` / `computeWaves`),
6
+ * but each historically re-implemented the step that turns a list of Story
7
+ * records into the `Map<storyId, number[]>` adjacency the kernel consumes.
8
+ * This module is now the one home for that step; the consumers are:
10
9
  *
11
10
  * - `lib/orchestration/epic-runner/phases/build-wave-dag.js`
12
11
  * (`buildStoryDag` → `computeWaves`)
13
12
  * - `lib/orchestration/dispatch-pipeline.js`
14
13
  * (`buildStoryDispatchGraph` → `computeStoryWaves`)
15
- * - `stories-wave-tick.js` (`buildAdjacency` `assignLayers`)
14
+ * - `lib/wave-runner/ready-set.js` (`selectReadySet`, the path-agnostic
15
+ * continuous scheduler the standalone `stories-wave-tick.js` adapter
16
+ * and the Epic path both dispatch through)
17
+ * - `stories-wave-tick.js` (for cycle detection, before delegating
18
+ * selection to `selectReadySet`)
16
19
  *
17
20
  * Dependency source order (must stay aligned with manifest-builder.js so
18
21
  * the dispatch manifest and runtime wave scheduling never disagree):
@@ -45,9 +48,10 @@ import { parseBlockedBy } from './dependency-parser.js';
45
48
  * @param {boolean} [opts.dropForeign=true] When true (the default,
46
49
  * matching the Epic-scoped wrappers), edges pointing at ids outside
47
50
  * the supplied story set are dropped so the DAG stays closed over the
48
- * scheduled set. `stories-wave-tick.js` passes `false` to preserve its
49
- * historical operator-DAG contract, where a dependency on an id absent
50
- * from the input still deepens the dependent's layer.
51
+ * scheduled set. The standalone path (`stories-wave-tick.js` and the
52
+ * `selectReadySet` core) passes `false` to preserve the operator-DAG
53
+ * contract, where a dependency on an id absent from the input is treated
54
+ * as not-yet-done and withholds the dependent until it completes.
51
55
  * @returns {Map<number, number[]>}
52
56
  */
53
57
  export function buildStoryAdjacency(stories, { dropForeign = true } = {}) {
@@ -16,6 +16,7 @@
16
16
  * verify: string[], // exact commands / tier annotation
17
17
  * references: PathEntry[], // read-only paths (optional)
18
18
  * wide: { reason } | null,// declared-wide footprint (optional)
19
+ * reason_to_exist: string | null, // one-sentence cohesion reason (optional)
19
20
  * depends_on: string[], // blocker story slugs or #ids
20
21
  * estimated_test_files: number | null, // absent → null (informational)
21
22
  * }
@@ -69,6 +70,7 @@ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js
69
70
  * @property {string[]} verify - Exact commands with tier annotation.
70
71
  * @property {PathEntry[]} references - Read-only paths (may be empty).
71
72
  * @property {{ reason: string }|null} wide - Declared-wide footprint (reason), or null.
73
+ * @property {string|null} reason_to_exist - One-sentence cohesion reason ("why this Story exists"), or null.
72
74
  * @property {string[]} depends_on - Blocking story slugs / issue refs.
73
75
  * @property {number|null} estimated_test_files - Test surface count or null.
74
76
  */
@@ -246,10 +248,14 @@ const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
246
248
  * defaults instead of throwing.
247
249
  *
248
250
  * @param {string} markdown
249
- * @returns {{ wide: { reason: string }|null, estimated_test_files: number|null }}
251
+ * @returns {{ wide: { reason: string }|null, reason_to_exist: string|null, estimated_test_files: number|null }}
250
252
  */
251
253
  function extractMeta(markdown) {
252
- const result = { wide: null, estimated_test_files: null };
254
+ const result = {
255
+ wide: null,
256
+ reason_to_exist: null,
257
+ estimated_test_files: null,
258
+ };
253
259
  const match = markdown.match(META_BLOCK_RE);
254
260
  if (!match) return result;
255
261
 
@@ -263,12 +269,29 @@ function extractMeta(markdown) {
263
269
  if (parsed === null || typeof parsed !== 'object') return result;
264
270
 
265
271
  result.wide = normalizeWide(parsed.wide);
272
+ result.reason_to_exist = normalizeReasonToExist(parsed.reason_to_exist);
266
273
  if (typeof parsed.estimated_test_files === 'number') {
267
274
  result.estimated_test_files = parsed.estimated_test_files;
268
275
  }
269
276
  return result;
270
277
  }
271
278
 
279
+ /**
280
+ * Normalize a raw `reason_to_exist` value to a non-empty trimmed string or
281
+ * `null`. The field is the machine-checkable form of the cohesion rule
282
+ * ("one Story = one coherent change with one reason to exist"): the
283
+ * `epic-plan-consolidate` critic flags any Story whose body carries no
284
+ * non-empty reason. An empty or non-string value is treated as absent.
285
+ *
286
+ * @param {unknown} raw
287
+ * @returns {string|null}
288
+ */
289
+ function normalizeReasonToExist(raw) {
290
+ if (typeof raw !== 'string') return null;
291
+ const reason = raw.trim();
292
+ return reason.length === 0 ? null : reason;
293
+ }
294
+
272
295
  /**
273
296
  * Normalize a raw `wide` declaration to the canonical `{ reason }` shape or
274
297
  * `null`. A `wide` declaration is only honoured when it carries a non-empty
@@ -382,6 +405,7 @@ function parseLegacyStringBody(input, preamble, footer) {
382
405
  verify: [],
383
406
  references: [],
384
407
  wide: null,
408
+ reason_to_exist: null,
385
409
  depends_on: extractBlockedBy(footer),
386
410
  estimated_test_files: null,
387
411
  };
@@ -526,6 +550,7 @@ export function parse(input) {
526
550
  const meta = extractMeta(input);
527
551
  const estimated_test_files = meta.estimated_test_files;
528
552
  const wide = meta.wide;
553
+ const reason_to_exist = meta.reason_to_exist;
529
554
  if (estimated_test_files === null) {
530
555
  warnings.push(
531
556
  'test-surface-unestimated: estimated_test_files not present.',
@@ -539,6 +564,7 @@ export function parse(input) {
539
564
  verify,
540
565
  references,
541
566
  wide,
567
+ reason_to_exist,
542
568
  depends_on: dependsOn,
543
569
  estimated_test_files,
544
570
  };
@@ -597,6 +623,7 @@ function parseStructuredObject(obj) {
597
623
  }
598
624
 
599
625
  const wide = normalizeWide(obj.wide);
626
+ const reason_to_exist = normalizeReasonToExist(obj.reason_to_exist);
600
627
 
601
628
  // depends_on: may be at top level or in body
602
629
  const rawDeps = Array.isArray(obj.depends_on) ? obj.depends_on : [];
@@ -621,6 +648,7 @@ function parseStructuredObject(obj) {
621
648
  verify,
622
649
  references,
623
650
  wide,
651
+ reason_to_exist,
624
652
  depends_on,
625
653
  estimated_test_files,
626
654
  };
@@ -663,8 +691,8 @@ function serializePathEntry(entry) {
663
691
  * `## Goal`, `## Changes`, `## Acceptance`, `## Verify`, `## References`
664
692
  * (omitted when empty).
665
693
  *
666
- * `wide` and `estimated_test_files` are emitted as a fenced
667
- * `<!-- meta -->` comment block so round-trips preserve them without
694
+ * `wide`, `reason_to_exist`, and `estimated_test_files` are emitted as a
695
+ * fenced `<!-- meta -->` comment block so round-trips preserve them without
668
696
  * polluting the human-readable body.
669
697
  *
670
698
  * @param {StoryBody} body
@@ -721,6 +749,10 @@ export function serialize(body, opts = {}) {
721
749
  if (wide !== null) {
722
750
  metaFields.wide = wide;
723
751
  }
752
+ const reasonToExist = normalizeReasonToExist(body.reason_to_exist);
753
+ if (reasonToExist !== null) {
754
+ metaFields.reason_to_exist = reasonToExist;
755
+ }
724
756
  if (typeof body.estimated_test_files === 'number') {
725
757
  metaFields.estimated_test_files = body.estimated_test_files;
726
758
  }