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.
- 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-delivery.js +17 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -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-plan-spec/phases/run-spec-phase.js +32 -1
- 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/lifecycle/listeners/watcher.js +13 -8
- package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
- package/.agents/scripts/lib/orchestration/planning-risk.js +45 -6
- 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 +26 -0
- package/package.json +1 -1
- 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`
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
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
|
|
252
|
-
* #902:
|
|
251
|
+
* The payload schema:
|
|
253
252
|
*
|
|
254
253
|
* {
|
|
255
254
|
* "kind": "epic-run-progress",
|
|
256
255
|
* "epicId": <number>,
|
|
257
|
-
* "
|
|
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
|
-
* `
|
|
266
|
-
*
|
|
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
|
-
*
|
|
272
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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 =
|
|
343
|
-
|
|
344
|
-
|
|
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 = ['|
|
|
353
|
-
if (
|
|
354
|
-
tableLines.push('| — |
|
|
322
|
+
const tableLines = ['| ID | State | Title |', '|---|---|---|'];
|
|
323
|
+
if (rows.length === 0) {
|
|
324
|
+
tableLines.push('| — | _(no stories yet)_ | — |');
|
|
355
325
|
} else {
|
|
356
|
-
for (const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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 ·
|
|
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 · ${
|
|
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
|
|
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,
|
|
257
|
+
export function renderMalformedReturnsFriction({ epicId, failures }) {
|
|
255
258
|
const lines = [
|
|
256
|
-
`### 🚧 epic-execute friction — Epic #${epicId}
|
|
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
|
|
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,
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
`${
|
|
188
|
+
`${nonDoneStatuses.length} story(ies) not done (statuses: ${nonDoneStatuses.join(', ')})`,
|
|
181
189
|
);
|
|
182
190
|
}
|
|
183
|
-
const storyBlockers = countStoryBlockers(
|
|
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,
|
|
197
|
+
return { interventionCount, storyStatuses, storyBlockers };
|
|
190
198
|
}
|
|
191
199
|
|
|
192
|
-
|
|
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
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* @param {Function} opts.
|
|
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
|
|
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
|
-
|
|
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` (~
|
|
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:
|
|
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,
|