mandrel 1.67.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 (50) 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-delivery.js +17 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -2
  13. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  14. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  17. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +32 -1
  18. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  19. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  20. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  21. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +13 -8
  24. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  25. package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  27. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  28. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  29. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  30. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  31. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  32. package/.agents/scripts/lib/story-adjacency.js +14 -10
  33. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  34. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  35. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  36. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  37. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  38. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  39. package/.agents/scripts/stories-wave-tick.js +262 -161
  40. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  41. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  42. package/.agents/skills/skills.index.json +2 -2
  43. package/.agents/workflows/deliver.md +12 -9
  44. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  45. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  46. package/.agents/workflows/helpers/plan-epic.md +13 -10
  47. package/.agents/workflows/plan.md +1 -1
  48. package/docs/CHANGELOG.md +26 -0
  49. package/package.json +1 -1
  50. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -240,42 +240,33 @@ export async function renderProgressBody({
240
240
  /**
241
241
  * Render and upsert the rolled-up `epic-run-progress` comment on the Epic.
242
242
  *
243
- * Called by `/deliver` Step 2b (`epic-execute-record-wave.js`) after
244
- * each wave completes. The caller folds `state.waves[]` from the
245
- * `epic-run-state` checkpoint into the per-wave rows and persists the
246
- * unified rollup as a fenced-JSON payload on the Epic ticket via
247
- * `upsertStructuredComment`. There is no separate per-wave structured
248
- * comment `epic-run-progress` is the single operator-facing summary,
249
- * grouped by wave.
243
+ * Called by `/deliver`'s per-Story status recorder
244
+ * (`epic-execute-record-wave.js`) after each recorder beat. Story #4155
245
+ * (Epic #4151) the Epic `/deliver` runtime cut over from the wave-batch
246
+ * scheduler to the continuous ready-set core, so the rollup is a **flat
247
+ * per-Story table** keyed by the checkpoint's `stories` status map, not a
248
+ * wave-grouped table. There is no `currentWave` / `totalWaves` / `waves[]`
249
+ * in the payload any more.
250
250
  *
251
- * The payload schema is pinned by `epic-execute.md` Step 2b / tech spec
252
- * #902:
251
+ * The payload schema:
253
252
  *
254
253
  * {
255
254
  * "kind": "epic-run-progress",
256
255
  * "epicId": <number>,
257
- * "currentWave": <number>,
258
- * "totalWaves": <number>,
259
- * "waves": [ { wave, concurrencyCap?, stories[] } ],
256
+ * "stories": [ { id, title?, state, blockerCommentId? } ],
260
257
  * "startedAt"?: "<iso8601>",
261
258
  * "updatedAt": "<iso8601>"
262
259
  * }
263
260
  *
264
261
  * The function does not re-derive Story state from labels — it trusts the
265
- * `waves` argument supplied by the caller, which itself is the projection
266
- * of the validated, verified per-Story rows recorded on the checkpoint.
262
+ * `stories` map supplied by the caller (the checkpoint's recorded per-Story
263
+ * statuses).
267
264
  *
268
265
  * @param {{
269
266
  * provider: import('../../../ITicketingProvider.js').ITicketingProvider,
270
267
  * epicId: number,
271
- * waves: Array<{
272
- * wave: number,
273
- * concurrencyCap?: number,
274
- * stories?: Array<{ id: number, title?: string, state?: string,
275
- * blockerCommentId?: string }>,
276
- * }>,
277
- * currentWave: number,
278
- * totalWaves: number,
268
+ * stories: Record<string, { status?: string, title?: string,
269
+ * blockerCommentId?: string }>,
279
270
  * startedAt?: string,
280
271
  * now?: () => Date,
281
272
  * }} args
@@ -285,9 +276,7 @@ export async function renderProgressBody({
285
276
  export async function upsertEpicRunProgress({
286
277
  provider,
287
278
  epicId,
288
- waves,
289
- currentWave,
290
- totalWaves,
279
+ stories,
291
280
  startedAt,
292
281
  now = () => new Date(),
293
282
  } = {}) {
@@ -300,73 +289,44 @@ export async function upsertEpicRunProgress({
300
289
  if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) {
301
290
  throw new TypeError('upsertEpicRunProgress requires a numeric epicId');
302
291
  }
303
- const totalWavesNum = Number(totalWaves);
304
- if (!Number.isInteger(totalWavesNum) || totalWavesNum < 0) {
305
- throw new TypeError(
306
- 'upsertEpicRunProgress requires a non-negative integer totalWaves',
307
- );
308
- }
309
- const currentWaveNum = Number(currentWave);
310
- if (!Number.isInteger(currentWaveNum) || currentWaveNum < 0) {
311
- throw new TypeError(
312
- 'upsertEpicRunProgress requires a non-negative integer currentWave',
313
- );
314
- }
315
- const wavesArr = Array.isArray(waves) ? waves : [];
292
+ const statusMap = stories && typeof stories === 'object' ? stories : {};
316
293
 
317
294
  const updatedAt = now().toISOString();
318
- const normalizedWaves = wavesArr.map((w) => {
319
- const stories = Array.isArray(w?.stories) ? w.stories : [];
320
- const out = {
321
- wave: Number(w?.wave),
322
- stories,
323
- };
324
- if (Number.isInteger(w?.concurrencyCap)) {
325
- out.concurrencyCap = Number(w.concurrencyCap);
326
- }
327
- return out;
328
- });
295
+ const rows = Object.entries(statusMap)
296
+ .map(([key, rec]) => {
297
+ const id = Number(key);
298
+ const state = String(rec?.status ?? 'pending');
299
+ const row = { id, title: String(rec?.title ?? ''), state };
300
+ if (rec?.blockerCommentId != null) {
301
+ row.blockerCommentId = String(rec.blockerCommentId);
302
+ }
303
+ return row;
304
+ })
305
+ .filter((r) => Number.isInteger(r.id) && r.id > 0)
306
+ .sort((a, b) => a.id - b.id);
329
307
 
330
308
  const payload = {
331
309
  kind: EPIC_RUN_PROGRESS_TYPE,
332
310
  epicId: epicIdNum,
333
- currentWave: currentWaveNum,
334
- totalWaves: totalWavesNum,
335
- waves: normalizedWaves,
311
+ stories: rows,
336
312
  updatedAt,
337
313
  };
338
314
  if (typeof startedAt === 'string' && startedAt) {
339
315
  payload.startedAt = startedAt;
340
316
  }
341
317
 
342
- const totalStories = normalizedWaves.reduce(
343
- (acc, w) => acc + w.stories.length,
344
- 0,
345
- );
346
- const doneStories = normalizedWaves.reduce(
347
- (acc, w) => acc + w.stories.filter((s) => s?.state === 'done').length,
348
- 0,
349
- );
350
- const header = `### 📊 Epic Progress — Wave ${Math.min(currentWaveNum + 1, Math.max(totalWavesNum, 1))}/${totalWavesNum || '?'} · ${doneStories}/${totalStories} stories done`;
318
+ const totalStories = rows.length;
319
+ const doneStories = rows.filter((s) => s.state === 'done').length;
320
+ const header = `### 📊 Epic Progress — ${doneStories}/${totalStories} stories done`;
351
321
 
352
- const tableLines = ['| Wave | ID | State | Title |', '|---|---|---|---|'];
353
- if (normalizedWaves.length === 0) {
354
- tableLines.push('| — | — | _(no waves yet)_ | — |');
322
+ const tableLines = ['| ID | State | Title |', '|---|---|---|'];
323
+ if (rows.length === 0) {
324
+ tableLines.push('| — | _(no stories yet)_ | — |');
355
325
  } else {
356
- for (const w of normalizedWaves) {
357
- if (w.stories.length === 0) {
358
- tableLines.push(`| ${w.wave + 1} | — | _(empty wave)_ | — |`);
359
- continue;
360
- }
361
- for (const s of w.stories) {
362
- const state = String(s?.state ?? 'unknown');
363
- const emoji = STATE_EMOJI[state] ?? '';
364
- const id = Number(s?.id ?? 0);
365
- const title = escapePipes(truncate(String(s?.title ?? ''), 60));
366
- tableLines.push(
367
- `| ${w.wave + 1} | #${id} | ${emoji} ${state} | ${title} |`,
368
- );
369
- }
326
+ for (const s of rows) {
327
+ const emoji = STATE_EMOJI[s.state] ?? '';
328
+ const title = escapePipes(truncate(s.title, 60));
329
+ tableLines.push(`| #${s.id} | ${emoji} ${s.state} | ${title} |`);
370
330
  }
371
331
  }
372
332
 
@@ -38,10 +38,10 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
38
38
 
39
39
  /**
40
40
  * Fire a curated `epic-progress` webhook event. Event-driven only — called
41
- * at wave boundaries and after blocker raise/clear transitions. Carries
42
- * the rollup payload `{ pct, done, total, currentWave, totalWaves, phase,
43
- * openBlockers }`, which Slack consumers and downstream subscribers use to
44
- * track epic progress without subscribing to per-story chatter.
41
+ * per recorder beat and after blocker raise/clear transitions. Carries the
42
+ * rollup payload `{ pct, done, total, phase, openBlockers }`, which Slack
43
+ * consumers and downstream subscribers use to track epic progress without
44
+ * subscribing to per-story chatter.
45
45
  *
46
46
  * The dispatch passes `skipComment: true` — the operator-facing GitHub
47
47
  * comment is owned by `ProgressReporter.fire()` and `upsertEpicRunProgress`,
@@ -50,13 +50,17 @@ export const EPIC_PROGRESS_EVENT = 'epic-progress';
50
50
  * Failures are swallowed by design: the runner must keep moving even if
51
51
  * the webhook URL is misconfigured or the network is flaky.
52
52
  *
53
+ * Story #4155 — the Epic `/deliver` runtime cut over to the continuous
54
+ * ready-set scheduler, which has **no wave index**. The wave segment was
55
+ * dropped from the message and the rollup payload entirely (rather than
56
+ * rendered as `Wave undefined/undefined`); the sole live caller
57
+ * (`wave-record-notifications.js`) never supplied wave coordinates.
58
+ *
53
59
  * @param {{
54
60
  * notify: Function|null,
55
61
  * epicId: number,
56
62
  * done: number,
57
63
  * total: number,
58
- * currentWave: number,
59
- * totalWaves: number,
60
64
  * phase?: string,
61
65
  * openBlockers?: Array<{ reason: string, storyId?: number }>,
62
66
  * logger?: { warn?: Function },
@@ -68,8 +72,6 @@ export async function emitEpicProgress({
68
72
  epicId,
69
73
  done,
70
74
  total,
71
- currentWave,
72
- totalWaves,
73
75
  phase,
74
76
  openBlockers = [],
75
77
  logger,
@@ -85,7 +87,7 @@ export async function emitEpicProgress({
85
87
  blockerCount > 0
86
88
  ? ` · 🚧 ${blockerCount} blocker${blockerCount === 1 ? '' : 's'}`
87
89
  : '';
88
- const message = `Epic #${epicIdNum} progress · Wave ${currentWave}/${totalWaves} · ${doneN}/${totalN} stories done (${pct}%)${blockerSuffix}`;
90
+ const message = `Epic #${epicIdNum} progress · ${doneN}/${totalN} stories done (${pct}%)${blockerSuffix}`;
89
91
 
90
92
  const payload = {
91
93
  severity: blockerCount > 0 ? 'high' : 'medium',
@@ -109,8 +111,6 @@ export async function emitEpicProgress({
109
111
  pct,
110
112
  done: doneN,
111
113
  total: totalN,
112
- currentWave,
113
- totalWaves,
114
114
  phase,
115
115
  openBlockers: openBlockers ?? [],
116
116
  },
@@ -121,11 +121,14 @@ export async function emitEpicProgress({
121
121
  * Fire a curated `epic-started` webhook event at /deliver kickoff.
122
122
  * The Slack consumer anchors the rest of the epic narrative to this fire.
123
123
  * Failures are swallowed.
124
+ *
125
+ * Story #4155 — the ready-set runtime has no wave count, so the wave
126
+ * segment was dropped from the message (rather than rendered as
127
+ * `undefined wave(s)`); the sole live caller never supplied one.
124
128
  */
125
129
  export async function emitEpicStarted({
126
130
  notify,
127
131
  epicId,
128
- totalWaves,
129
132
  totalStories,
130
133
  title,
131
134
  logger,
@@ -133,7 +136,7 @@ export async function emitEpicStarted({
133
136
  if (typeof notify !== 'function') return null;
134
137
  const epicIdNum = Number(epicId);
135
138
  if (!Number.isInteger(epicIdNum) || epicIdNum <= 0) return null;
136
- const message = `Epic #${epicIdNum} started · ${totalWaves} wave${totalWaves === 1 ? '' : 's'} · ${totalStories} stor${totalStories === 1 ? 'y' : 'ies'}${title ? ` — ${title}` : ''}`;
139
+ const message = `Epic #${epicIdNum} started · ${totalStories} stor${totalStories === 1 ? 'y' : 'ies'}${title ? ` — ${title}` : ''}`;
137
140
  try {
138
141
  await notify(
139
142
  epicIdNum,
@@ -241,26 +241,29 @@ export async function reconcileStoryFromGitHub({ provider, storyId } = {}) {
241
241
 
242
242
  /**
243
243
  * Render a single friction-comment body listing every malformed sub-agent
244
- * return for a given wave. Pure helper — no provider call. Exposed so tests
245
- * can pin the body shape.
244
+ * return for a recorder beat. Pure helper — no provider call. Exposed so
245
+ * tests can pin the body shape.
246
+ *
247
+ * Story #4155 — under the ready-set runtime there is no wave index; the
248
+ * recorder records the Stories it was handed, so the body is keyed by Epic
249
+ * only.
246
250
  *
247
251
  * @param {{
248
252
  * epicId: number,
249
- * wave: number,
250
253
  * failures: Array<{ storyId: number, error: string, returnText: string }>,
251
254
  * }} args
252
255
  * @returns {string}
253
256
  */
254
- export function renderMalformedReturnsFriction({ epicId, wave, failures }) {
257
+ export function renderMalformedReturnsFriction({ epicId, failures }) {
255
258
  const lines = [
256
- `### 🚧 epic-execute friction — Epic #${epicId}, wave ${wave}`,
259
+ `### 🚧 epic-execute friction — Epic #${epicId}`,
257
260
  '',
258
261
  `**Reason:** \`malformed-subagent-return\``,
259
262
  '',
260
263
  `${failures.length} sub-agent return(s) did not match the /deliver return contract.`,
261
264
  'Each Story below was reconciled from GitHub (labels + `story-run-progress`)',
262
- 'and its wave-row downgraded to `failed` unless the live ticket already carried',
263
- '`agent::done`.',
265
+ 'and its recorded status downgraded to `failed` unless the live ticket',
266
+ 'already carried `agent::done`.',
264
267
  '',
265
268
  ];
266
269
  for (const f of failures) {
@@ -21,7 +21,7 @@
21
21
  * is a hard block evaluated BEFORE the structured-signal evaluator.
22
22
  *
23
23
  * Either trigger evaluates the same verdict: if `evaluateAutoMergePredicate`
24
- * reports `clean: true` (no manual interventions, no incomplete waves,
24
+ * reports `clean: true` (no manual interventions, every Story done,
25
25
  * no story blockers, no critical/high review findings, machine-readable
26
26
  * "clean sprint" retro trailer), emit `epic.merge.ready`. Otherwise emit
27
27
  * `epic.merge.blocked` with a non-empty reason.
@@ -172,38 +172,51 @@ function evaluateStateSignals(state, reasons) {
172
172
  .join('; ')}${interventionCount > 3 ? '; …' : ''}`,
173
173
  );
174
174
  }
175
- const waves = Array.isArray(state?.waves) ? state.waves : [];
176
- const waveStatuses = waves.map((w) => w.status ?? 'unknown');
177
- const nonCompleteWaves = waveStatuses.filter((s) => s !== 'complete');
178
- if (nonCompleteWaves.length > 0) {
175
+ // Story #4155 the ready-set runtime records a flat per-Story status map
176
+ // on the checkpoint (`stories: { [id]: { status, blockerCommentId? } }`)
177
+ // instead of a per-wave `waves[]` history. The clean-run certification
178
+ // reads it directly: a run is clean only when every Story reached `done`
179
+ // and none carries a recorded blocker comment.
180
+ const stories =
181
+ state?.stories && typeof state.stories === 'object' ? state.stories : {};
182
+ const storyStatuses = Object.values(stories).map(
183
+ (s) => s?.status ?? 'pending',
184
+ );
185
+ const nonDoneStatuses = storyStatuses.filter((s) => s !== 'done');
186
+ if (nonDoneStatuses.length > 0) {
179
187
  reasons.push(
180
- `${nonCompleteWaves.length} wave(s) not complete (statuses: ${nonCompleteWaves.join(', ')})`,
188
+ `${nonDoneStatuses.length} story(ies) not done (statuses: ${nonDoneStatuses.join(', ')})`,
181
189
  );
182
190
  }
183
- const storyBlockers = countStoryBlockers(waves);
191
+ const storyBlockers = countStoryBlockers(stories);
184
192
  if (storyBlockers > 0) {
185
193
  reasons.push(
186
194
  `${storyBlockers} story-level blocker(s) recorded in run-state`,
187
195
  );
188
196
  }
189
- return { interventionCount, waveStatuses, storyBlockers };
197
+ return { interventionCount, storyStatuses, storyBlockers };
190
198
  }
191
199
 
192
- function countStoryBlockers(waves) {
200
+ /**
201
+ * Count blockers in the flat per-Story `stories` status map: each Story with
202
+ * a recorded `blockerCommentId` and each Story whose status is not `done`
203
+ * contributes one blocker (matching the prior per-wave count semantics).
204
+ *
205
+ * @param {Record<string, { status?: string, blockerCommentId?: string }>} stories
206
+ * @returns {number}
207
+ */
208
+ function countStoryBlockers(stories) {
193
209
  let blockers = 0;
194
- for (const w of waves) {
195
- if (!Array.isArray(w.stories)) continue;
196
- for (const s of w.stories) {
197
- if (
198
- s &&
199
- typeof s.blockerCommentId === 'string' &&
200
- s.blockerCommentId.length > 0
201
- ) {
202
- blockers += 1;
203
- }
204
- if (s?.status && s.status !== 'done') {
205
- blockers += 1;
206
- }
210
+ for (const s of Object.values(stories ?? {})) {
211
+ if (
212
+ s &&
213
+ typeof s.blockerCommentId === 'string' &&
214
+ s.blockerCommentId.length > 0
215
+ ) {
216
+ blockers += 1;
217
+ }
218
+ if (s?.status && s.status !== 'done') {
219
+ blockers += 1;
207
220
  }
208
221
  }
209
222
  return blockers;
@@ -271,7 +284,7 @@ function evaluateRetroSignals(retro, reasons) {
271
284
  * reasons: string[],
272
285
  * signals: {
273
286
  * manualInterventions: number,
274
- * waveStatuses: string[],
287
+ * storyStatuses: string[],
275
288
  * storyBlockers: number,
276
289
  * severity: { critical: number|null, high: number|null, medium: number|null, suggestion: number|null },
277
290
  * retroCompact: boolean,
@@ -292,7 +305,7 @@ export function deriveAutoMergeVerdict({ state, codeReview, retro }) {
292
305
  reasons,
293
306
  signals: {
294
307
  manualInterventions: stateSig.interventionCount,
295
- waveStatuses: stateSig.waveStatuses,
308
+ storyStatuses: stateSig.storyStatuses,
296
309
  storyBlockers: stateSig.storyBlockers,
297
310
  severity: reviewSig.severity,
298
311
  retroCompact: retroSig.retroCompact,
@@ -358,10 +358,15 @@ export async function pollUntilTerminal({
358
358
  * @param {number} opts.maxPolls Hard cap on total poll iterations.
359
359
  * @param {number} opts.maxUpdates Cap on `gh pr update-branch` recovery calls.
360
360
  * @param {number} opts.pollIntervalMs Delay between poll ticks.
361
- * @param {Function} opts.ghPrChecksFn
362
- * @param {Function} opts.ghPrViewFn
363
- * @param {Function} opts.ghPrUpdateBranchFn
364
- * @param {Function} opts.sleepFn
361
+ * @param {Function} [opts.ghPrChecksFn] `gh pr checks` invoker. Defaults
362
+ * to the real `gh pr checks` spawn so the CLI path (which injects no
363
+ * port) works; tests override it with a stub. Story #4144.
364
+ * @param {Function} [opts.ghPrViewFn] `gh pr view` invoker. Defaults
365
+ * to the real spawn; tests override.
366
+ * @param {Function} [opts.ghPrUpdateBranchFn] `gh pr update-branch`
367
+ * invoker. Defaults to the real spawn; tests override.
368
+ * @param {Function} [opts.sleepFn] Poll-tick delay. Defaults to a
369
+ * real `setTimeout`-backed sleep; tests override with a no-op.
365
370
  * @param {{ info?: Function, warn?: Function, debug?: Function }} opts.logger
366
371
  * @param {{status:number,stdout:string,stderr:string}} [opts.firstProbe]
367
372
  * Optional already-issued `gh pr checks` result. When the caller (the
@@ -388,10 +393,10 @@ export async function watchPrToTerminal({
388
393
  maxPolls,
389
394
  maxUpdates,
390
395
  pollIntervalMs,
391
- ghPrChecksFn,
392
- ghPrViewFn,
393
- ghPrUpdateBranchFn,
394
- sleepFn,
396
+ ghPrChecksFn = ghPrChecks,
397
+ ghPrViewFn = ghPrView,
398
+ ghPrUpdateBranchFn = ghPrUpdateBranch,
399
+ sleepFn = defaultSleep,
395
400
  logger,
396
401
  firstProbe,
397
402
  }) {
@@ -139,6 +139,12 @@ function buildStoryOnlyManifest(stories, epicId) {
139
139
  type: 'story',
140
140
  branchName: getStoryBranch(epicId, story.id),
141
141
  earliestWave,
142
+ // Carry the resolved cross-Story dependency edges on the entry so the
143
+ // presentation layer can derive grouping depth at render time via
144
+ // `assignLayers` (Story #4157) instead of trusting the persisted
145
+ // `earliestWave`. This is the same `explicitStoryDeps` set the wave
146
+ // computation consumed, already closed over the scheduled Story set.
147
+ dependsOn: explicitStoryDeps.get(story.id) ?? [],
142
148
  tasks: [],
143
149
  };
144
150
  });
@@ -37,6 +37,18 @@
37
37
  * @property {boolean} requiresReview
38
38
  * @property {AcceptanceDisposition} acceptanceDisposition
39
39
  * @property {GateDecision} gateDecision
40
+ * @property {string} [acceptanceWaivedReason] Present only when the
41
+ * acceptance disposition was forced to `not-applicable` by a non-axis
42
+ * signal (currently: no BDD runner detected). An operator-visible
43
+ * rationale so the override is never silent (Story #4145).
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} BddRunnerProbe
48
+ * @property {string|null} runner
49
+ * @property {boolean} fallback `true` when no supported BDD runner was
50
+ * detected in the project (`verifyBddRunnerPendingTag`).
51
+ * @property {string} [reason]
40
52
  */
41
53
 
42
54
  const LEVEL_RANK = Object.freeze({ low: 0, medium: 1, high: 2 });
@@ -129,27 +141,54 @@ function resolveRequiresReview(overallLevel, axes) {
129
141
  * (`epic-plan-spec.js`), never here, so a malformed verdict fails closed
130
142
  * before this function runs.
131
143
  *
144
+ * **No-BDD-runner waiver (Story #4145).** The acceptance disposition the risk
145
+ * axes derive presumes a BDD runner exists to satisfy an authored AC table.
146
+ * When `opts.bddRunner.fallback === true` (no supported runner detected — e.g.
147
+ * a `node:test` repo with no `tests/features/**`), an authored AC table can
148
+ * never be reconciled by `@epic-<id>-ac-*` feature tags, so `/deliver`
149
+ * finalize would abort. In that case the disposition is **forced** to
150
+ * `not-applicable` regardless of the risk axes, and `acceptanceWaivedReason`
151
+ * records the override so it is operator-visible, not silent. The
152
+ * `requiresReview` / `gateDecision` outputs are unaffected — a high-risk
153
+ * Epic still routes to review; only the acceptance-spec requirement is
154
+ * waived. Repos that ship a BDD runner (`fallback !== true`) are unaffected.
155
+ *
132
156
  * @param {RiskVerdict} [verdict]
157
+ * @param {{ bddRunner?: BddRunnerProbe|null }} [opts]
133
158
  * @returns {PlanningRiskEnvelope}
134
159
  */
135
- export function deriveRiskEnvelope(verdict = {}) {
160
+ export function deriveRiskEnvelope(verdict = {}, { bddRunner = null } = {}) {
136
161
  const axes = (Array.isArray(verdict.axes) ? verdict.axes : []).map(
137
162
  ({ axis, level, rationale }) => ({ axis, level, rationale }),
138
163
  );
139
164
 
140
165
  const overallLevel = resolveOverallLevel(axes);
141
- const acceptanceDisposition = resolveAcceptanceDisposition(
142
- axes,
143
- overallLevel,
144
- );
166
+ const axisDisposition = resolveAcceptanceDisposition(axes, overallLevel);
145
167
  const requiresReview = resolveRequiresReview(overallLevel, axes);
146
168
  const gateDecision = requiresReview ? 'review-required' : 'auto-proceed';
147
169
 
148
- return {
170
+ const noBddRunner = bddRunner?.fallback === true;
171
+ // Force the waiver only when the axes would otherwise have required (or
172
+ // recommended) an AC table; if the disposition is already not-applicable
173
+ // there is nothing to override and no waiver rationale to surface.
174
+ const forceWaiver = noBddRunner && axisDisposition !== 'not-applicable';
175
+ const acceptanceDisposition = forceWaiver
176
+ ? 'not-applicable'
177
+ : axisDisposition;
178
+
179
+ /** @type {PlanningRiskEnvelope} */
180
+ const envelope = {
149
181
  axes,
150
182
  overallLevel,
151
183
  requiresReview,
152
184
  acceptanceDisposition,
153
185
  gateDecision,
154
186
  };
187
+ if (forceWaiver) {
188
+ envelope.acceptanceWaivedReason =
189
+ `no BDD runner detected (${bddRunner?.reason ?? 'no-bdd-runner-detected'}) — ` +
190
+ `an authored acceptance-spec AC table cannot be reconciled by feature tags, ` +
191
+ `so the acceptance disposition is waived to not-applicable (was ${axisDisposition}).`;
192
+ }
193
+ return envelope;
155
194
  }
@@ -18,7 +18,7 @@
18
18
  *
19
19
  * Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
20
20
  * uniform relaxed profile):
21
- * - Flat knobs: `softFiles` (~8), `hardFiles` (~30), `maxAcceptance` (~14),
21
+ * - Flat knobs: `softFiles` (~15), `hardFiles` (~30), `maxAcceptance` (~14),
22
22
  * `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
23
23
  * `testSurface` axis, no selector and no second profile.
24
24
  * - The four-profile `sizingProfile` enum is replaced by a single optional
@@ -32,7 +32,11 @@
32
32
 
33
33
  export const DEFAULT_TASK_SIZING = Object.freeze({
34
34
  // Typical-Story warning thresholds (soft — emit advisory findings).
35
- softFiles: 8,
35
+ // Story #4162 raised `softFiles` 8 → 15: a capability-sized Story routinely
36
+ // touches a dozen-plus files for one cohesive reason, so the advisory width
37
+ // nudge fired far too eagerly and biased the decomposer toward over-slicing.
38
+ // The hard `hardFiles` rejection (30) is unchanged.
39
+ softFiles: 15,
36
40
  softAcceptanceCount: 10,
37
41
  // Hard ceilings (rejection unless lifted).
38
42
  hardFiles: 30,