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.
- package/.agents/docs/agentrc-reference.json +1 -2
- package/.agents/docs/configuration.md +2 -4
- package/.agents/schemas/agentrc.schema.json +1 -5
- package/.agents/schemas/lifecycle/epic.automerge.end.schema.json +2 -1
- package/.agents/scripts/epic-deliver-preflight.js +30 -13
- package/.agents/scripts/epic-deliver-prepare.js +40 -53
- package/.agents/scripts/epic-execute-record-wave.js +119 -133
- package/.agents/scripts/lib/baselines/refresh-service.js +13 -1
- package/.agents/scripts/lib/config/explain.js +0 -2
- package/.agents/scripts/lib/config/limits.js +19 -8
- package/.agents/scripts/lib/config-settings-schema.js +1 -2
- package/.agents/scripts/lib/maintainability-utils.js +32 -9
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
- package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
- package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
- package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
- package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
- package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
- package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
- package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
- package/.agents/scripts/lib/story-adjacency.js +14 -10
- package/.agents/scripts/lib/story-body/story-body.js +36 -4
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
- package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
- package/.agents/scripts/lib/wave-runner/tick.js +312 -206
- package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
- package/.agents/scripts/lint-label-vocabulary.js +1 -1
- package/.agents/scripts/stories-wave-tick.js +262 -161
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/workflows/deliver.md +12 -9
- package/.agents/workflows/helpers/deliver-epic.md +126 -90
- package/.agents/workflows/helpers/deliver-stories.md +131 -85
- package/.agents/workflows/helpers/plan-epic.md +13 -10
- package/.agents/workflows/plan.md +1 -1
- package/docs/CHANGELOG.md +14 -0
- package/package.json +1 -1
- 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`, `
|
|
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
|
-
// - `
|
|
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 —
|
|
4
|
+
* stories-wave-tick.js — continuous ready-set planner for the standalone
|
|
5
|
+
* `/deliver` story-list path.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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-
|
|
22
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
51
|
-
of Story IDs
|
|
52
|
-
|
|
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-
|
|
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-
|
|
70
|
-
"
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
134
|
-
*
|
|
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
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 {
|
|
143
|
-
* @returns {
|
|
217
|
+
* @param {unknown} raw
|
|
218
|
+
* @returns {{ value: number|null, error: string|null }}
|
|
144
219
|
*/
|
|
145
|
-
export function
|
|
146
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
284
|
+
* Build the per-beat ready-set envelope from a validated DAG.
|
|
177
285
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
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 {
|
|
185
|
-
* @param {
|
|
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
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
|
195
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
//
|
|
220
|
-
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
361
|
+
const ready = selectReadySet({
|
|
362
|
+
stories: records,
|
|
363
|
+
doneIds,
|
|
364
|
+
inFlight,
|
|
365
|
+
globalCap: concurrencyCap,
|
|
366
|
+
}).map((rec) => rec.id);
|
|
228
367
|
|
|
229
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
waves: [],
|
|
322
|
-
totalStories: 0,
|
|
440
|
+
return inputError(
|
|
441
|
+
`Could not read DAG file "${dagFile}": ${err.message}`,
|
|
323
442
|
concurrencyCap,
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
};
|
|
327
|
-
return { envelope, exitCode: 1 };
|
|
443
|
+
inFlightValue,
|
|
444
|
+
);
|
|
328
445
|
}
|
|
329
446
|
} else if (dagJson) {
|
|
330
447
|
rawJson = dagJson;
|
|
331
448
|
} else {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
waves: [],
|
|
335
|
-
totalStories: 0,
|
|
449
|
+
return inputError(
|
|
450
|
+
'Either --dag <json> or --dag-file <path> is required',
|
|
336
451
|
concurrencyCap,
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
waves: [],
|
|
350
|
-
totalStories: 0,
|
|
460
|
+
return inputError(
|
|
461
|
+
`Invalid JSON: ${err.message}`,
|
|
351
462
|
concurrencyCap,
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|