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
|
@@ -1,37 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* wave-record-projection.js — pure
|
|
2
|
+
* wave-record-projection.js — pure helpers for the per-Story status
|
|
3
|
+
* recorder CLI (`epic-execute-record-wave.js`).
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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`, `
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
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
|
-
/**
|
|
201
|
-
export function
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
5
|
-
* `lib/Graph.js` kernel (`detectCycle` / `assignLayers` /
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
* - `
|
|
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`
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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 = {
|
|
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
|
|
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
|
}
|