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
@@ -4,7 +4,8 @@
4
4
  * (blocked stories, gate failures) route through `WaveTickResult`.
5
5
  *
6
6
  * Callers classify via `err.phase`: `checkpoint-missing`,
7
- * `checkpoint-read`, `plan-missing`, `story-fetch`, `invalid-input`.
7
+ * `checkpoint-read`, `old-shape-checkpoint`, `story-fetch`,
8
+ * `invalid-input`.
8
9
  *
9
10
  * @module lib/wave-runner/wave-runner-error
10
11
  */
@@ -159,7 +159,7 @@ export function findVocabularyViolations(src, axes = KNOWN_AXES) {
159
159
  // - `context::acceptance-spec` — canonical label, value contains a
160
160
  // hyphen but is not at axis position.
161
161
  // - `<type>/<slug>` — template placeholders.
162
- // - `planning.maxTickets` — config-key paths with `.`.
162
+ // - `delivery.maxTokenBudget` — config-key paths with `.`.
163
163
  // - `context.{prdId,techSpecId}` — JS destructure shapes.
164
164
  // - `agent-protocol.md` — filenames / concept slugs.
165
165
  //
@@ -1,16 +1,30 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * stories-wave-tick.js — DAG/wave engine for the top-level /deliver workflow.
4
+ * stories-wave-tick.js — continuous ready-set planner for the standalone
5
+ * `/deliver` story-list path.
5
6
  *
6
- * Consumes an operator-supplied dependency DAG of standalone Story IDs and
7
- * emits ordered execution waves. Analogous to wave-tick.js but for standalone
8
- * Stories (not Epic-manifest tasks).
7
+ * Thin **adapter** over the path-agnostic ready-set scheduling core
8
+ * (`lib/wave-runner/ready-set.js#selectReadySet`). It consumes an
9
+ * operator-supplied dependency DAG of standalone Story IDs plus the live
10
+ * progress of the run (which Stories are done, how many are in flight) and
11
+ * emits the set of Stories safe to dispatch **on this beat** — a Story
12
+ * becomes dispatchable the instant its own dependencies are done, under the
13
+ * same global concurrency cap and the same file-overlap co-dispatch guard
14
+ * the Epic path uses. There is no wave barrier: this no longer batches
15
+ * Stories into fully-draining waves; it selects continuously.
16
+ *
17
+ * The previous static wave-batch plan (group N must fully drain before
18
+ * group N+1 opens, via `Graph.js#assignLayers`) is gone. The scheduling
19
+ * kernel — adjacency derivation, the done-predicate classifier, the
20
+ * eligibility rule, and the overlap guard — lives once in `selectReadySet`;
21
+ * this file only parses input, resolves the cap, and renders the envelope.
9
22
  *
10
23
  * Usage:
11
24
  * node .agents/scripts/stories-wave-tick.js --dag '<json>'
12
25
  * node .agents/scripts/stories-wave-tick.js --dag-file <path>
13
26
  * node .agents/scripts/stories-wave-tick.js --dag '<json>' --concurrency 5
27
+ * node .agents/scripts/stories-wave-tick.js --dag '<json>' --done 101,103 --in-flight 1
14
28
  *
15
29
  * DAG input format (JSON):
16
30
  * Array of { id: number, dependsOn: number[] } objects where id is a Story
@@ -18,20 +32,27 @@
18
32
  *
19
33
  * Output: one JSON object on stdout with shape:
20
34
  * {
21
- * kind: 'stories-wave-plan',
22
- * waves: Array<{ waveIndex: number, stories: number[] }>,
35
+ * kind: 'stories-ready-set',
36
+ * ready: number[], // Story IDs safe to dispatch on this beat
23
37
  * totalStories: number,
24
38
  * concurrencyCap: number,
39
+ * inFlight: number,
25
40
  * cycleError: string | null
26
41
  * }
27
42
  *
28
- * The per-wave concurrency cap is resolved from the same config seam
43
+ * The standalone loop calls this once per beat: after each Story closes it
44
+ * re-runs with the closed Story added to `--done` and the live in-flight
45
+ * count in `--in-flight`, dispatching the returned `ready` set (already
46
+ * capped at `concurrencyCap − inFlight` by the core). The run is complete
47
+ * when every Story is in `--done` and `ready` is empty.
48
+ *
49
+ * The per-beat concurrency cap is resolved from the same config seam
29
50
  * `/deliver` uses — `resolveConfig` + `getRunners` reading
30
51
  * `delivery.deliverRunner.concurrencyCap` (default 3) — so a
31
52
  * `.agentrc.local.json` override is honored. A `--concurrency <n>` CLI flag
32
53
  * overrides the config-resolved value for that run only. This puts both the
33
- * standalone (`/deliver`) and Epic (`/deliver`) delivery paths on
34
- * one deterministic config source.
54
+ * standalone (`/deliver`) and Epic (`/deliver`) delivery paths on one
55
+ * deterministic config source **and** one scheduling kernel.
35
56
  *
36
57
  * On cycle detection, exits with code 2 and sets cycleError in the envelope.
37
58
  */
@@ -41,15 +62,19 @@ import { parseArgs } from 'node:util';
41
62
 
42
63
  import { runAsCli } from './lib/cli-utils.js';
43
64
  import { getRunners, resolveConfig } from './lib/config-resolver.js';
44
- import { assignLayers, detectCycle } from './lib/Graph.js';
65
+ import { detectCycle } from './lib/Graph.js';
45
66
  import { Logger } from './lib/Logger.js';
67
+ import { AGENT_LABELS } from './lib/label-constants.js';
46
68
  import { buildStoryAdjacency } from './lib/story-adjacency.js';
69
+ import { selectReadySet } from './lib/wave-runner/ready-set.js';
47
70
 
48
- const HELP = `Usage: node .agents/scripts/stories-wave-tick.js --dag '<json>' | --dag-file <path> [--concurrency <n>]
71
+ const HELP = `Usage: node .agents/scripts/stories-wave-tick.js --dag '<json>' | --dag-file <path> [--concurrency <n>] [--done <csv>] [--in-flight <n>]
49
72
 
50
- DAG/wave engine for standalone Story delivery. Consumes a dependency graph
51
- of Story IDs and emits ordered execution waves plus a resolved per-wave
52
- concurrency cap.
73
+ Continuous ready-set planner for standalone Story delivery. Consumes a
74
+ dependency graph of Story IDs plus the live run progress and emits the set
75
+ of Stories safe to dispatch on this beat — a Story is dispatchable the
76
+ instant its own dependencies are done — plus the resolved per-beat
77
+ concurrency cap and the same file-overlap guard the Epic path uses.
53
78
 
54
79
  Input DAG format (JSON array):
55
80
  [{ "id": 101, "dependsOn": [] }, { "id": 102, "dependsOn": [101] }]
@@ -59,31 +84,45 @@ Each entry must include:
59
84
  dependsOn - Array of Story IDs that must complete before this Story runs
60
85
 
61
86
  Options:
62
- --concurrency <n> Override the per-wave concurrency cap for this run only.
87
+ --concurrency <n> Override the per-beat concurrency cap for this run only.
63
88
  Must be a positive integer. When omitted, the cap is
64
89
  resolved from delivery.deliverRunner.concurrencyCap in
65
90
  .agentrc.json / .agentrc.local.json (default 3).
91
+ --done <csv> Comma-separated Story IDs already completed this run.
92
+ Their dependents become eligible; they are never
93
+ re-dispatched. Defaults to empty.
94
+ --in-flight <n> Count of Stories already occupying a slot (dispatched
95
+ but not yet done). Subtracted from the cap to compute
96
+ remaining capacity. Non-negative integer; defaults to 0.
66
97
 
67
98
  Output envelope:
68
99
  {
69
- "kind": "stories-wave-plan",
70
- "waves": [{ "waveIndex": 0, "stories": [101] }, ...],
100
+ "kind": "stories-ready-set",
101
+ "ready": [101],
71
102
  "totalStories": 2,
72
103
  "concurrencyCap": 3,
104
+ "inFlight": 0,
73
105
  "cycleError": null
74
106
  }
75
107
 
76
108
  Exit codes:
77
- 0 - Success, waves emitted
78
- 1 - Invalid input (missing/malformed DAG, invalid --concurrency)
109
+ 0 - Success, ready set emitted
110
+ 1 - Invalid input (missing/malformed DAG, invalid --concurrency/--in-flight/--done)
79
111
  2 - Cycle detected in dependency graph
80
112
  `;
81
113
 
82
114
  /**
83
115
  * Parse and validate the raw DAG input array.
84
116
  *
117
+ * Each entry must carry `{ id, dependsOn }`. An optional `files` string
118
+ * array (the canonical footprint shape) is preserved and forwarded to the
119
+ * ready-set core so the file-overlap co-dispatch guard the Epic path uses is
120
+ * genuinely active on the standalone path too: two ready Stories that
121
+ * declare an intersecting footprint are never dispatched onto parallel
122
+ * `story-<id>` branches in the same beat.
123
+ *
85
124
  * @param {unknown} raw Parsed JSON value from --dag or --dag-file.
86
- * @returns {{ nodes: Array<{id: number, dependsOn: number[]}>, error: string|null }}
125
+ * @returns {{ nodes: Array<{id: number, dependsOn: number[], files?: string[]}>, error: string|null }}
87
126
  */
88
127
  export function parseDag(raw) {
89
128
  if (!Array.isArray(raw)) {
@@ -124,30 +163,99 @@ export function parseDag(raw) {
124
163
  };
125
164
  }
126
165
  }
127
- nodes.push({ id, dependsOn: [...dependsOn] });
166
+ const node = { id, dependsOn: [...dependsOn] };
167
+ if (entry.files !== undefined) {
168
+ if (
169
+ !Array.isArray(entry.files) ||
170
+ entry.files.some((f) => typeof f !== 'string')
171
+ ) {
172
+ return {
173
+ nodes: null,
174
+ error: `DAG entry at index ${i} (id=${id}): "files" must be an array of strings`,
175
+ };
176
+ }
177
+ node.files = [...entry.files];
178
+ }
179
+ nodes.push(node);
128
180
  }
129
181
  return { nodes, error: null };
130
182
  }
131
183
 
132
184
  /**
133
- * Build an adjacency map from parsed DAG nodes.
134
- * Returns Map<id, id[]> where each id maps to its dependencies.
185
+ * Parse a comma-separated `--done` list of Story IDs into a deduped set of
186
+ * positive integers. Empty / absent input yields an empty set. Rejects any
187
+ * token that is not a positive integer so a typo never silently drops a
188
+ * dependency gate.
135
189
  *
136
- * Delegates to the shared story-level builder
137
- * (`lib/story-adjacency.js#buildStoryAdjacency`) with `dropForeign: false`
138
- * to preserve the operator-DAG contract: a `dependsOn` id absent from the
139
- * input set still deepens the dependent's layer (assignLayers treats the
140
- * unknown id as a root).
190
+ * @param {string|undefined} raw
191
+ * @returns {{ ids: Set<number>|null, error: string|null }}
192
+ */
193
+ export function parseDoneIds(raw) {
194
+ if (raw == null || raw === '') {
195
+ return { ids: new Set(), error: null };
196
+ }
197
+ const ids = new Set();
198
+ for (const token of String(raw).split(',')) {
199
+ const trimmed = token.trim();
200
+ if (trimmed === '') continue;
201
+ const num = Number(trimmed);
202
+ if (!Number.isInteger(num) || num <= 0) {
203
+ return {
204
+ ids: null,
205
+ error: `--done must be a comma-separated list of positive integers, got "${trimmed}"`,
206
+ };
207
+ }
208
+ ids.add(num);
209
+ }
210
+ return { ids, error: null };
211
+ }
212
+
213
+ /**
214
+ * Parse the raw `--in-flight` value into a non-negative integer. Absent
215
+ * input defaults to 0. Rejects negatives and non-integers.
141
216
  *
142
- * @param {Array<{id: number, dependsOn: number[]}>} nodes
143
- * @returns {Map<number, number[]>}
217
+ * @param {unknown} raw
218
+ * @returns {{ value: number|null, error: string|null }}
144
219
  */
145
- export function buildAdjacency(nodes) {
146
- return buildStoryAdjacency(nodes, { dropForeign: false });
220
+ export function parseInFlight(raw) {
221
+ if (raw == null) {
222
+ return { value: 0, error: null };
223
+ }
224
+ const num = typeof raw === 'number' ? raw : Number(raw);
225
+ if (!Number.isInteger(num) || num < 0) {
226
+ return {
227
+ value: null,
228
+ error: `--in-flight must be a non-negative integer, got "${raw}"`,
229
+ };
230
+ }
231
+ return { value: num, error: null };
147
232
  }
148
233
 
149
234
  /**
150
- * Resolve the per-wave concurrency cap.
235
+ * Validate a raw `--concurrency` value into a positive integer.
236
+ *
237
+ * Accepts a number or a numeric string (from the CLI). Rejects anything that
238
+ * is not a positive integer (zero, negative, fractional, non-numeric).
239
+ *
240
+ * @param {unknown} raw
241
+ * @returns {{ value: number|null, error: string|null }}
242
+ */
243
+ export function parseConcurrencyOverride(raw) {
244
+ if (raw == null) {
245
+ return { value: null, error: null };
246
+ }
247
+ const num = typeof raw === 'number' ? raw : Number(raw);
248
+ if (!Number.isInteger(num) || num <= 0) {
249
+ return {
250
+ value: null,
251
+ error: `--concurrency must be a positive integer, got "${raw}"`,
252
+ };
253
+ }
254
+ return { value: num, error: null };
255
+ }
256
+
257
+ /**
258
+ * Resolve the per-beat concurrency cap.
151
259
  *
152
260
  * Mirrors the `/deliver` seam (`epic-deliver-prepare.js`): resolve the
153
261
  * project config (which deep-merges `.agentrc.local.json` over `.agentrc.json`)
@@ -173,103 +281,96 @@ export function resolveConcurrencyCap({ cwd, config, override } = {}) {
173
281
  }
174
282
 
175
283
  /**
176
- * Compute the wave plan from a validated adjacency map.
284
+ * Build the per-beat ready-set envelope from a validated DAG.
177
285
  *
178
- * Uses detectCycle from Graph.js to validate the DAG before computing
179
- * layers via assignLayers. Returns the wave envelope, carrying the resolved
180
- * per-wave `concurrencyCap` so the `/deliver` workflow dispatches
181
- * `min(wave.stories.length, concurrencyCap)` from a deterministic field rather
182
- * than from recalled prose.
286
+ * Maps each operator-DAG node onto a Story record the ready-set core
287
+ * understands (`{ id, dependsOn }`), tags any node already in the done set
288
+ * as `agent::done` so the core's classifier excludes it from the dispatch
289
+ * set **and** counts it as a satisfied dependency, then delegates the
290
+ * scheduling decision to `selectReadySet`. A cyclic operator DAG is a
291
+ * planning error (the core would silently never schedule the cycle), so we
292
+ * detect it up front via the shared `detectCycle` kernel and short-circuit
293
+ * with a `cycleError` and exit code 2.
183
294
  *
184
- * @param {Map<number, number[]>} adjacency
185
- * @param {number} concurrencyCap Resolved per-wave concurrency cap.
295
+ * @param {Array<{id: number, dependsOn: number[]}>} nodes
296
+ * @param {object} args
297
+ * @param {number} args.concurrencyCap Resolved per-beat concurrency cap.
298
+ * @param {Set<number>} [args.doneIds] Story IDs already completed this run.
299
+ * @param {number} [args.inFlight] Stories already occupying a slot.
186
300
  * @returns {{
187
- * kind: 'stories-wave-plan',
188
- * waves: Array<{waveIndex: number, stories: number[]}>,
189
- * totalStories: number,
190
- * concurrencyCap: number,
191
- * cycleError: string|null
301
+ * envelope: {
302
+ * kind: 'stories-ready-set',
303
+ * ready: number[],
304
+ * totalStories: number,
305
+ * concurrencyCap: number,
306
+ * inFlight: number,
307
+ * cycleError: string|null
308
+ * },
309
+ * exitCode: number
192
310
  * }}
193
311
  */
194
- export function computeStoriesWavePlan(adjacency, concurrencyCap) {
195
- const totalStories = adjacency.size;
312
+ export function buildReadySetEnvelope(
313
+ nodes,
314
+ { concurrencyCap, doneIds = new Set(), inFlight = 0 },
315
+ ) {
316
+ const totalStories = nodes.length;
317
+
318
+ const base = {
319
+ kind: 'stories-ready-set',
320
+ ready: [],
321
+ totalStories,
322
+ concurrencyCap,
323
+ inFlight,
324
+ cycleError: null,
325
+ };
196
326
 
197
327
  if (totalStories === 0) {
198
- return {
199
- kind: 'stories-wave-plan',
200
- waves: [],
201
- totalStories: 0,
202
- concurrencyCap,
203
- cycleError: null,
204
- };
328
+ return { envelope: base, exitCode: 0 };
205
329
  }
206
330
 
207
- // Detect cycles before computing layers — a cycle is a planning error.
331
+ // Cycle detection before scheduling — a cycle is a planning error the
332
+ // operator must fix. dropForeign:false preserves the operator-DAG contract
333
+ // (a dependency on an id outside the supplied set is honored, not pruned),
334
+ // matching the same builder seam selectReadySet uses internally.
335
+ const adjacency = buildStoryAdjacency(nodes, { dropForeign: false });
208
336
  const cycle = detectCycle(adjacency);
209
337
  if (cycle) {
210
338
  return {
211
- kind: 'stories-wave-plan',
212
- waves: [],
213
- totalStories,
214
- concurrencyCap,
215
- cycleError: `Dependency cycle detected: ${cycle.join(' → ')}. Fix the depends_on declarations before running /story-deliver.`,
339
+ envelope: {
340
+ ...base,
341
+ cycleError: `Dependency cycle detected: ${cycle.join(' → ')}. Fix the depends_on declarations before running /deliver.`,
342
+ },
343
+ exitCode: 2,
216
344
  };
217
345
  }
218
346
 
219
- // Assign layers (wave indices) via Graph.js wave 0 = roots (no deps).
220
- const layers = assignLayers(adjacency);
347
+ // Map DAG nodes Story records. Tag done nodes as agent::done so the
348
+ // core's classifier (a) excludes them from the dispatch set and (b) folds
349
+ // them into the satisfied-dependency set, making their dependents eligible.
350
+ // Forward any declared file footprint so the core's overlap guard fires.
351
+ const records = nodes.map((node) => {
352
+ const rec = {
353
+ id: node.id,
354
+ dependsOn: node.dependsOn,
355
+ labels: doneIds.has(node.id) ? [AGENT_LABELS.DONE] : [],
356
+ };
357
+ if (node.files !== undefined) rec.files = node.files;
358
+ return rec;
359
+ });
221
360
 
222
- // Group story IDs by wave index, sort deterministically within each wave.
223
- const waveMap = new Map();
224
- for (const [storyId, waveIndex] of layers.entries()) {
225
- if (!waveMap.has(waveIndex)) waveMap.set(waveIndex, []);
226
- waveMap.get(waveIndex).push(storyId);
227
- }
361
+ const ready = selectReadySet({
362
+ stories: records,
363
+ doneIds,
364
+ inFlight,
365
+ globalCap: concurrencyCap,
366
+ }).map((rec) => rec.id);
228
367
 
229
- const maxWave = Math.max(...waveMap.keys());
230
- const waves = [];
231
- for (let i = 0; i <= maxWave; i++) {
232
- const stories = (waveMap.get(i) ?? []).sort((a, b) => a - b);
233
- if (stories.length > 0) {
234
- waves.push({ waveIndex: i, stories });
235
- }
236
- }
237
-
238
- return {
239
- kind: 'stories-wave-plan',
240
- waves,
241
- totalStories,
242
- concurrencyCap,
243
- cycleError: null,
244
- };
245
- }
246
-
247
- /**
248
- * Validate a raw `--concurrency` value into a positive integer.
249
- *
250
- * Accepts a number or a numeric string (from the CLI). Rejects anything that
251
- * is not a positive integer (zero, negative, fractional, non-numeric).
252
- *
253
- * @param {unknown} raw
254
- * @returns {{ value: number|null, error: string|null }}
255
- */
256
- export function parseConcurrencyOverride(raw) {
257
- if (raw == null) {
258
- return { value: null, error: null };
259
- }
260
- const num = typeof raw === 'number' ? raw : Number(raw);
261
- if (!Number.isInteger(num) || num <= 0) {
262
- return {
263
- value: null,
264
- error: `--concurrency must be a positive integer, got "${raw}"`,
265
- };
266
- }
267
- return { value: num, error: null };
368
+ return { envelope: { ...base, ready }, exitCode: 0 };
268
369
  }
269
370
 
270
371
  /**
271
372
  * Core logic: parse DAG input, resolve the concurrency cap, validate, and
272
- * compute the wave plan.
373
+ * compute the per-beat ready set via the shared scheduling core.
273
374
  *
274
375
  * Exported for unit tests; the CLI `main` function is a thin wrapper. Tests
275
376
  * inject `config` so they never depend on a real `.agentrc`.
@@ -278,10 +379,12 @@ export function parseConcurrencyOverride(raw) {
278
379
  * @param {string} [args.dagJson] Raw JSON string from --dag.
279
380
  * @param {string} [args.dagFile] Path to a JSON file from --dag-file.
280
381
  * @param {string|number} [args.concurrency] Raw --concurrency override.
382
+ * @param {string} [args.done] Raw --done CSV of completed Story IDs.
383
+ * @param {string|number} [args.inFlight] Raw --in-flight count.
281
384
  * @param {string} [args.cwd] Repo root for config resolution.
282
385
  * @param {object} [args.config] Pre-resolved config (test injection).
283
386
  * @returns {{
284
- * envelope: {kind: string, waves: object[], totalStories: number, concurrencyCap: number, cycleError: string|null},
387
+ * envelope: {kind: string, ready: number[], totalStories: number, concurrencyCap: number, inFlight: number, cycleError: string|null},
285
388
  * exitCode: number
286
389
  * }}
287
390
  */
@@ -289,23 +392,41 @@ export function runStoriesWaveTick({
289
392
  dagJson,
290
393
  dagFile,
291
394
  concurrency,
395
+ done,
396
+ inFlight,
292
397
  cwd,
293
398
  config,
294
399
  } = {}) {
400
+ const inputError = (message, concurrencyCap = null, inFlightValue = 0) => ({
401
+ envelope: {
402
+ kind: 'stories-ready-set',
403
+ ready: [],
404
+ totalStories: 0,
405
+ concurrencyCap,
406
+ inFlight: inFlightValue,
407
+ cycleError: null,
408
+ inputError: message,
409
+ },
410
+ exitCode: 1,
411
+ });
412
+
295
413
  // Validate the --concurrency override before resolving config so an invalid
296
414
  // value fails fast with exit code 1 regardless of DAG validity.
297
415
  const { value: override, error: concurrencyError } =
298
416
  parseConcurrencyOverride(concurrency);
299
417
  if (concurrencyError) {
300
- const envelope = {
301
- kind: 'stories-wave-plan',
302
- waves: [],
303
- totalStories: 0,
304
- concurrencyCap: null,
305
- cycleError: null,
306
- inputError: concurrencyError,
307
- };
308
- return { envelope, exitCode: 1 };
418
+ return inputError(concurrencyError);
419
+ }
420
+
421
+ const { value: inFlightValue, error: inFlightError } =
422
+ parseInFlight(inFlight);
423
+ if (inFlightError) {
424
+ return inputError(inFlightError);
425
+ }
426
+
427
+ const { ids: doneIds, error: doneError } = parseDoneIds(done);
428
+ if (doneError) {
429
+ return inputError(doneError, null, inFlightValue);
309
430
  }
310
431
 
311
432
  const concurrencyCap = resolveConcurrencyCap({ cwd, config, override });
@@ -316,67 +437,43 @@ export function runStoriesWaveTick({
316
437
  try {
317
438
  rawJson = readFileSync(dagFile, 'utf8');
318
439
  } catch (err) {
319
- const envelope = {
320
- kind: 'stories-wave-plan',
321
- waves: [],
322
- totalStories: 0,
440
+ return inputError(
441
+ `Could not read DAG file "${dagFile}": ${err.message}`,
323
442
  concurrencyCap,
324
- cycleError: null,
325
- inputError: `Could not read DAG file "${dagFile}": ${err.message}`,
326
- };
327
- return { envelope, exitCode: 1 };
443
+ inFlightValue,
444
+ );
328
445
  }
329
446
  } else if (dagJson) {
330
447
  rawJson = dagJson;
331
448
  } else {
332
- const envelope = {
333
- kind: 'stories-wave-plan',
334
- waves: [],
335
- totalStories: 0,
449
+ return inputError(
450
+ 'Either --dag <json> or --dag-file <path> is required',
336
451
  concurrencyCap,
337
- cycleError: null,
338
- inputError: 'Either --dag <json> or --dag-file <path> is required',
339
- };
340
- return { envelope, exitCode: 1 };
452
+ inFlightValue,
453
+ );
341
454
  }
342
455
 
343
456
  let parsed;
344
457
  try {
345
458
  parsed = JSON.parse(rawJson);
346
459
  } catch (err) {
347
- const envelope = {
348
- kind: 'stories-wave-plan',
349
- waves: [],
350
- totalStories: 0,
460
+ return inputError(
461
+ `Invalid JSON: ${err.message}`,
351
462
  concurrencyCap,
352
- cycleError: null,
353
- inputError: `Invalid JSON: ${err.message}`,
354
- };
355
- return { envelope, exitCode: 1 };
463
+ inFlightValue,
464
+ );
356
465
  }
357
466
 
358
467
  const { nodes, error: parseError } = parseDag(parsed);
359
468
  if (parseError) {
360
- const envelope = {
361
- kind: 'stories-wave-plan',
362
- waves: [],
363
- totalStories: 0,
364
- concurrencyCap,
365
- cycleError: null,
366
- inputError: parseError,
367
- };
368
- return { envelope, exitCode: 1 };
369
- }
370
-
371
- const adjacency = buildAdjacency(nodes);
372
- const envelope = computeStoriesWavePlan(adjacency, concurrencyCap);
373
-
374
- // Cycle detection → exit code 2
375
- if (envelope.cycleError) {
376
- return { envelope, exitCode: 2 };
469
+ return inputError(parseError, concurrencyCap, inFlightValue);
377
470
  }
378
471
 
379
- return { envelope, exitCode: 0 };
472
+ return buildReadySetEnvelope(nodes, {
473
+ concurrencyCap,
474
+ doneIds,
475
+ inFlight: inFlightValue,
476
+ });
380
477
  }
381
478
 
382
479
  async function main(argv) {
@@ -386,6 +483,8 @@ async function main(argv) {
386
483
  dag: { type: 'string' },
387
484
  'dag-file': { type: 'string' },
388
485
  concurrency: { type: 'string' },
486
+ done: { type: 'string' },
487
+ 'in-flight': { type: 'string' },
389
488
  help: { type: 'boolean', short: 'h' },
390
489
  },
391
490
  strict: false,
@@ -401,6 +500,8 @@ async function main(argv) {
401
500
  dagJson: values.dag,
402
501
  dagFile: values['dag-file'],
403
502
  concurrency: values.concurrency,
503
+ done: values.done,
504
+ inFlight: values['in-flight'],
404
505
  });
405
506
 
406
507
  process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
@@ -23,6 +23,7 @@ allowed_tools:
23
23
  - Your operations are constrained to exactly two shapes: **(1) merge two or more Stories** into one (union their `changes`/`acceptance`/`verify`/`references`, keep one coherent `goal`); **(2) rewire `depends_on`** so the edges still reference surviving sibling-Story slugs. No other mutation is permitted.
24
24
  - Consume the Tech Spec **"Delivery Slicing"** section as the authoritative target grouping when one is present: cluster the draft's Stories toward the N shippable Stories the Architect proposed. When the section is **absent**, degrade gracefully — apply only the cohesion rules below and leave the rest of the draft shape intact.
25
25
  - Apply the same cohesion heuristic the author skill leads with: **one Story = one coherent change with one reason to exist**, and the **single-consumer merge rule** (a Story whose only consumer is one sibling Story is merged into that sibling). Lead every merge decision with the change's reason, not its file count.
26
+ - **Missing reason-to-exist check (cohesion gate).** Every draft Story body MUST carry a non-empty `reason_to_exist` meta field (the parseable form of "one Story = one coherent change with one reason to exist", encoded in the `<!-- meta: {"reason_to_exist": "..."} -->` comment and surfaced as `body.reason_to_exist` by `lib/story-body/story-body.js`). Flag any Story whose body carries **no** non-empty reason to exist: a Story that cannot state its single reason in one sentence is a cohesion smell — it is probably two Stories, or two Stories that should be merged into one. Name each flagged Story in the consolidation report so the operator sees the cohesion gap at the HITL diff gate.
26
27
  - After every merge, **rewire `depends_on`**: drop self-edges, collapse edges that now point at the absorbing Story onto itself, and re-point any edge that named a now-deleted slug at its surviving successor. Never leave a `depends_on` referencing a slug absent from the consolidated array — the validator HARD-rejects unknown deps.
27
28
  - The consolidation report MUST name each operation applied (merged slugs → surviving slug, rewired edges) with a one-line reason, plus a before/after Story-count line, so the operator can approve or reject at the HITL diff gate before the persist call.
28
29
 
@@ -95,6 +96,11 @@ Across the draft Story array, decide which Stories merge:
95
96
  single Story: union their `changes` / `acceptance` / `verify` / `references`,
96
97
  write one coherent `goal`, and keep the union of labels.
97
98
  - **Single-consumer Story** → merge into the one sibling that consumes it.
99
+ - **Missing reason to exist** → flag any Story whose body carries no non-empty
100
+ `reason_to_exist` meta field. A Story that cannot name its single coherent
101
+ reason is a cohesion smell; record it in the report so the operator can
102
+ re-scope it (merge, split, or have the author supply the reason) at the HITL
103
+ gate.
98
104
 
99
105
  Record each decision with its one-line reason for the report.
100
106