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
|
@@ -2,36 +2,39 @@
|
|
|
2
2
|
/* node:coverage ignore file */
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* epic-execute-record-wave.js — record one
|
|
6
|
-
*
|
|
5
|
+
* epic-execute-record-wave.js — record one recorder beat's per-Story
|
|
6
|
+
* returns, splice each Story's terminal status into the `epic-run-state`
|
|
7
|
+
* checkpoint's flat per-Story `stories` map, and re-render the unified
|
|
7
8
|
* `epic-run-progress` rollup on the Epic.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Story #4155 (Epic #4151) — the Epic `/deliver` runtime cut over from the
|
|
11
|
+
* wave-batch scheduler to the continuous ready-set core. This recorder lost
|
|
12
|
+
* its wave semantics with it: there is no `--wave` flag, no wave-level
|
|
13
|
+
* status aggregation, no `currentWave` advance, and no `waves[]` history. The
|
|
14
|
+
* tick (`lib/wave-runner/tick.js`) re-derives readiness from live labels on
|
|
15
|
+
* every beat, so the checkpoint only records terminal Story outcomes (for the
|
|
16
|
+
* auto-merge predicate, branch cleanup, and the operator rollup). The
|
|
17
|
+
* recorder also emits one `story.dispatch.end` per recorded Story so the
|
|
18
|
+
* tick's ledger-derived in-flight set and the `--check-idle` watchdog can
|
|
19
|
+
* clear the dispatch start/end pairing.
|
|
15
20
|
*
|
|
16
21
|
* 1. Parse / reconcile / verify the per-Story returns.
|
|
17
|
-
* 2.
|
|
18
|
-
* 3.
|
|
19
|
-
*
|
|
20
|
-
* 4. Re-render `epic-run-progress` from `state.waves[]`.
|
|
22
|
+
* 2. Record each Story's terminal status onto the checkpoint `stories` map.
|
|
23
|
+
* 3. Emit `story.dispatch.end` per recorded Story.
|
|
24
|
+
* 4. Re-render `epic-run-progress` from the checkpoint `stories` map.
|
|
21
25
|
* 5. Print the next action for the slash-command (`dispatch-next` |
|
|
22
|
-
* `
|
|
26
|
+
* `halt-blocked` | `halt-failed`).
|
|
23
27
|
*
|
|
24
28
|
* The implementation is split across three modules so the parent stays a
|
|
25
29
|
* thin runner shell:
|
|
26
30
|
*
|
|
27
|
-
* - `lib/orchestration/wave-record-projection.js` — pure
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* so existing callers see an unchanged public surface.
|
|
31
|
+
* - `lib/orchestration/wave-record-projection.js` — pure helpers
|
|
32
|
+
* (per-Story validation / normalization, rollup-row shaping). Re-exported
|
|
33
|
+
* from this file so existing callers see an unchanged public surface.
|
|
31
34
|
* - `lib/orchestration/wave-record-io.js` — impure helpers (ticket
|
|
32
35
|
* verification, manifest title lookup, returns reconciliation).
|
|
33
36
|
* - `lib/orchestration/wave-record-notifications.js` — curated webhook
|
|
34
|
-
* emit chain for the
|
|
37
|
+
* emit chain for the recorder beat.
|
|
35
38
|
*/
|
|
36
39
|
|
|
37
40
|
import { readFileSync } from 'node:fs';
|
|
@@ -52,14 +55,11 @@ import {
|
|
|
52
55
|
resolveResolvedResults,
|
|
53
56
|
verifyWaveResults,
|
|
54
57
|
} from './lib/orchestration/wave-record-io.js';
|
|
55
|
-
import {
|
|
58
|
+
import { emitRecordNotifications } from './lib/orchestration/wave-record-notifications.js';
|
|
56
59
|
import {
|
|
57
|
-
projectWaveRecord,
|
|
58
|
-
resolveConcurrencyCap,
|
|
59
60
|
selectInputFlag,
|
|
60
|
-
|
|
61
|
+
validateEpic,
|
|
61
62
|
validateResults,
|
|
62
|
-
validateResultsReturnsXor,
|
|
63
63
|
} from './lib/orchestration/wave-record-projection.js';
|
|
64
64
|
import { createProvider } from './lib/provider-factory.js';
|
|
65
65
|
import { notify } from './notify.js';
|
|
@@ -73,29 +73,25 @@ export {
|
|
|
73
73
|
// Re-export the pure projection surface so tests and downstream consumers
|
|
74
74
|
// can keep importing from `epic-execute-record-wave.js` after the extract.
|
|
75
75
|
export {
|
|
76
|
-
aggregateWaveStatus,
|
|
77
76
|
classifyParsedReturn,
|
|
78
|
-
|
|
79
|
-
countDoneStories,
|
|
80
|
-
resolveConcurrencyCap,
|
|
77
|
+
normalizeReturnsPure,
|
|
81
78
|
STORY_STATUS_TO_ROW_STATE,
|
|
82
79
|
selectInputFlag,
|
|
83
80
|
toRollupRow,
|
|
84
|
-
VALID_RESULT_STATUSES,
|
|
85
81
|
VALID_STORY_STATUSES,
|
|
86
|
-
|
|
82
|
+
validateEpic,
|
|
87
83
|
validateResults,
|
|
88
|
-
validateResultsReturnsXor,
|
|
89
84
|
validateReturnsEntry,
|
|
90
85
|
} from './lib/orchestration/wave-record-projection.js';
|
|
91
86
|
|
|
92
87
|
const HELP = `Usage: node .agents/scripts/epic-execute-record-wave.js \\
|
|
93
|
-
--epic <epicId>
|
|
88
|
+
--epic <epicId> \\
|
|
94
89
|
(--returns @<file>|<inline-json> | --results @<file>|<inline-json>)
|
|
95
90
|
|
|
96
|
-
Records
|
|
97
|
-
checkpoint
|
|
98
|
-
Prints the next action for the
|
|
91
|
+
Records this recorder beat's per-Story outcomes onto the epic-run-state
|
|
92
|
+
checkpoint's flat per-Story status map and upserts the unified
|
|
93
|
+
epic-run-progress rollup on the Epic. Prints the next action for the
|
|
94
|
+
/deliver slash command.
|
|
99
95
|
`;
|
|
100
96
|
|
|
101
97
|
/**
|
|
@@ -135,15 +131,36 @@ export function parseInputArg(value, deps = {}) {
|
|
|
135
131
|
}
|
|
136
132
|
|
|
137
133
|
/**
|
|
138
|
-
*
|
|
134
|
+
* Classify the recorder beat's next action from the verified rows. Pure.
|
|
135
|
+
* Any failed Story → `halt-failed`; else any blocked Story → `halt-blocked`;
|
|
136
|
+
* else → `dispatch-next` (the host re-ticks to pick up the next ready set).
|
|
137
|
+
*
|
|
138
|
+
* @param {Array<{ status: string }>} verified
|
|
139
|
+
* @returns {{ status: 'complete'|'blocked'|'failed', nextAction: string, blockedStoryIds: number[] }}
|
|
140
|
+
*/
|
|
141
|
+
export function classifyRecordOutcome(verified) {
|
|
142
|
+
const rows = Array.isArray(verified) ? verified : [];
|
|
143
|
+
const failed = rows.some((r) => r.status === 'failed');
|
|
144
|
+
const blockedStoryIds = rows
|
|
145
|
+
.filter((r) => r.status === 'blocked')
|
|
146
|
+
.map((r) => r.storyId);
|
|
147
|
+
if (failed) {
|
|
148
|
+
return { status: 'failed', nextAction: 'halt-failed', blockedStoryIds };
|
|
149
|
+
}
|
|
150
|
+
if (blockedStoryIds.length > 0) {
|
|
151
|
+
return { status: 'blocked', nextAction: 'halt-blocked', blockedStoryIds };
|
|
152
|
+
}
|
|
153
|
+
return { status: 'complete', nextAction: 'dispatch-next', blockedStoryIds };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* End-to-end record beat. DI-friendly: tests pass `injectedProvider` and a
|
|
139
158
|
* fully-formed `results` (or `returns`) array to skip real network reads.
|
|
140
159
|
*
|
|
141
160
|
* @param {{
|
|
142
161
|
* epicId: number,
|
|
143
|
-
* wave: number,
|
|
144
162
|
* results?: unknown,
|
|
145
163
|
* returns?: unknown,
|
|
146
|
-
* concurrencyCap?: number,
|
|
147
164
|
* cwd?: string,
|
|
148
165
|
* injectedProvider?: object,
|
|
149
166
|
* injectedConfig?: object,
|
|
@@ -153,18 +170,25 @@ export function parseInputArg(value, deps = {}) {
|
|
|
153
170
|
*/
|
|
154
171
|
export async function runEpicExecuteRecordWave({
|
|
155
172
|
epicId,
|
|
156
|
-
wave,
|
|
157
173
|
results,
|
|
158
174
|
returns,
|
|
159
|
-
concurrencyCap: concurrencyCapOverride,
|
|
160
175
|
cwd,
|
|
161
176
|
injectedProvider,
|
|
162
177
|
injectedConfig,
|
|
163
178
|
injectedNotify,
|
|
164
179
|
now = () => new Date(),
|
|
165
180
|
} = {}) {
|
|
166
|
-
|
|
167
|
-
|
|
181
|
+
validateEpic(epicId);
|
|
182
|
+
if (results == null && returns == null) {
|
|
183
|
+
throw new TypeError(
|
|
184
|
+
'runEpicExecuteRecordWave: either `results` or `returns` is required',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (results != null && returns != null) {
|
|
188
|
+
throw new TypeError(
|
|
189
|
+
'runEpicExecuteRecordWave: pass `results` OR `returns`, not both',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
168
192
|
|
|
169
193
|
const config = injectedConfig ?? resolveConfig({ cwd });
|
|
170
194
|
const provider = injectedProvider ?? createProvider(config);
|
|
@@ -176,25 +200,16 @@ export async function runEpicExecuteRecordWave({
|
|
|
176
200
|
'run `node .agents/scripts/epic-deliver-prepare.js --epic <id>` first.',
|
|
177
201
|
);
|
|
178
202
|
}
|
|
203
|
+
const firstRecord = !hasRecordedStory(existing.stories);
|
|
179
204
|
|
|
180
205
|
const deliverRunner = getRunners(config).deliverRunner ?? {};
|
|
181
|
-
const concurrencyCap = resolveConcurrencyCap(
|
|
182
|
-
concurrencyCapOverride,
|
|
183
|
-
existing,
|
|
184
|
-
deliverRunner,
|
|
185
|
-
);
|
|
186
206
|
|
|
187
|
-
// 1. Parse / reconcile the per-Story returns.
|
|
188
|
-
// wave-complete-livelock recovery (Story #3907) can reconcile every
|
|
189
|
-
// Story in `plan[wave]` from GitHub when mode B records a wave with no
|
|
190
|
-
// child returns.
|
|
207
|
+
// 1. Parse / reconcile the per-Story returns.
|
|
191
208
|
const { resolvedResults, parseFailures } = await resolveResolvedResults({
|
|
192
209
|
provider,
|
|
193
210
|
epicId,
|
|
194
|
-
wave,
|
|
195
211
|
results,
|
|
196
212
|
returns,
|
|
197
|
-
existing,
|
|
198
213
|
});
|
|
199
214
|
|
|
200
215
|
const validated = validateResults(resolvedResults);
|
|
@@ -209,90 +224,61 @@ export async function runEpicExecuteRecordWave({
|
|
|
209
224
|
// 3. Cross-look manifest titles for the rollup rows.
|
|
210
225
|
const titleById = await loadManifestTitleMap({ provider, epicId });
|
|
211
226
|
|
|
212
|
-
// 4.
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
await epicRunStateStore.write({
|
|
226
|
-
provider,
|
|
227
|
-
epicId,
|
|
228
|
-
state: {
|
|
229
|
-
...existing,
|
|
230
|
-
currentWave: projection.nextCurrentWave,
|
|
231
|
-
totalWaves: projection.totalWaves,
|
|
232
|
-
waves: projection.nextWaves,
|
|
233
|
-
},
|
|
234
|
-
});
|
|
227
|
+
// 4. Record each Story's terminal status onto the checkpoint `stories`
|
|
228
|
+
// map. Recorded serially so the upserts do not race the same comment.
|
|
229
|
+
let state = existing;
|
|
230
|
+
for (const row of verified) {
|
|
231
|
+
state = await epicRunStateStore.recordStoryStatus({
|
|
232
|
+
provider,
|
|
233
|
+
epicId,
|
|
234
|
+
storyId: row.storyId,
|
|
235
|
+
status: row.status,
|
|
236
|
+
title: titleById.get(row.storyId),
|
|
237
|
+
blockerCommentId: row.blockerCommentId,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
235
240
|
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
// /deliver path never imports — so every dispatched Story stayed
|
|
241
|
-
// "in-flight" forever and completed Stories tripped the watchdog.
|
|
242
|
-
// Best-effort: a failed append must not block the wave loop.
|
|
241
|
+
// 5. Emit one `story.dispatch.end` per recorded Story. Closes the
|
|
242
|
+
// start/end pairing the tick reconciler and the `--check-idle`
|
|
243
|
+
// watchdog use to derive in-flight Stories. Best-effort: a failed
|
|
244
|
+
// append must not block the loop.
|
|
243
245
|
emitWaveDispatchEnds({ epicId, verified, config });
|
|
244
246
|
|
|
245
247
|
// 6. Re-render the unified `epic-run-progress` rollup from the checkpoint
|
|
246
|
-
//
|
|
247
|
-
// separate per-wave structured comment.
|
|
248
|
+
// `stories` map. The single operator-facing summary.
|
|
248
249
|
const { body: renderedBody } = await upsertEpicRunProgress({
|
|
249
250
|
provider,
|
|
250
251
|
epicId,
|
|
251
|
-
|
|
252
|
-
currentWave: projection.nextCurrentWave,
|
|
253
|
-
totalWaves: projection.totalWaves,
|
|
252
|
+
stories: state.stories,
|
|
254
253
|
startedAt: existing.startedAt,
|
|
255
254
|
now,
|
|
256
255
|
});
|
|
257
256
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
// misconfig or a transient Slack outage
|
|
263
|
-
|
|
257
|
+
const { status, nextAction, blockedStoryIds } =
|
|
258
|
+
classifyRecordOutcome(verified);
|
|
259
|
+
|
|
260
|
+
// 7. Fire the curated webhook events for this recorder beat. Each helper
|
|
261
|
+
// is fire-and-forget — webhook misconfig or a transient Slack outage
|
|
262
|
+
// must not block the loop.
|
|
263
|
+
await emitRecordNotifications({
|
|
264
264
|
injectedNotify,
|
|
265
265
|
defaultNotify: notify,
|
|
266
266
|
config,
|
|
267
267
|
provider,
|
|
268
268
|
epicId,
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
priorWaves: projection.priorWaves,
|
|
272
|
-
nextWaves: projection.nextWaves,
|
|
273
|
-
titleById,
|
|
274
|
-
totalWaves: projection.totalWaves,
|
|
275
|
-
nextCurrentWave: projection.nextCurrentWave,
|
|
269
|
+
firstRecord,
|
|
270
|
+
stories: state.stories,
|
|
276
271
|
verified,
|
|
277
|
-
blockedStoryIds
|
|
272
|
+
blockedStoryIds,
|
|
278
273
|
});
|
|
279
274
|
|
|
280
|
-
// Note (Story #3909): the per-wave dispatch-manifest refresh hop was
|
|
281
|
-
// deleted. It re-ran the full dispatch pipeline (re-fetch every ticket,
|
|
282
|
-
// recompute waves) on every tick only to re-render a comment nothing reads
|
|
283
|
-
// for control flow. The manifest is written once at prepare time; the
|
|
284
|
-
// surviving operator-facing surface is the `epic-run-progress` rollup
|
|
285
|
-
// re-rendered above.
|
|
286
|
-
|
|
287
275
|
const envelope = {
|
|
288
276
|
epicId,
|
|
289
|
-
wave,
|
|
290
277
|
recorded: true,
|
|
291
|
-
status
|
|
278
|
+
status,
|
|
292
279
|
stories: verified.map((r) => ({ id: r.storyId, status: r.status })),
|
|
293
|
-
blockedStoryIds
|
|
294
|
-
nextAction
|
|
295
|
-
remainingWaves: projection.remainingWaves,
|
|
280
|
+
blockedStoryIds,
|
|
281
|
+
nextAction,
|
|
296
282
|
renderedBody,
|
|
297
283
|
};
|
|
298
284
|
if (discrepancies.length > 0) {
|
|
@@ -307,12 +293,28 @@ export async function runEpicExecuteRecordWave({
|
|
|
307
293
|
return envelope;
|
|
308
294
|
}
|
|
309
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Whether the checkpoint's per-Story `stories` map already carries a
|
|
298
|
+
* non-`pending` (recorded) status — used to fire `epic-started` exactly
|
|
299
|
+
* once, on the first recorder beat. Pure helper.
|
|
300
|
+
*
|
|
301
|
+
* @param {Record<string, { status?: string }>|undefined} stories
|
|
302
|
+
* @returns {boolean}
|
|
303
|
+
*/
|
|
304
|
+
function hasRecordedStory(stories) {
|
|
305
|
+
const map = stories && typeof stories === 'object' ? stories : {};
|
|
306
|
+
for (const rec of Object.values(map)) {
|
|
307
|
+
if (rec?.status && rec.status !== 'pending') return true;
|
|
308
|
+
}
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
310
312
|
/**
|
|
311
313
|
* Append one `story.dispatch.end` lifecycle record per recorded Story
|
|
312
314
|
* (Story #3900). Each emit is independent and best-effort: a single failed
|
|
313
|
-
* append is logged and swallowed so one bad record never aborts the
|
|
314
|
-
*
|
|
315
|
-
*
|
|
315
|
+
* append is logged and swallowed so one bad record never aborts the loop.
|
|
316
|
+
* The Story status taxonomy (`done`/`blocked`/`failed`) maps directly onto
|
|
317
|
+
* the `story.dispatch.end` outcome enum.
|
|
316
318
|
*
|
|
317
319
|
* Exported for unit testing.
|
|
318
320
|
*
|
|
@@ -378,8 +380,6 @@ export function parseArgv(argv) {
|
|
|
378
380
|
const { values } = defineFlags(
|
|
379
381
|
{
|
|
380
382
|
epic: { type: 'integer', alias: 'epicId' },
|
|
381
|
-
wave: { type: 'integer' },
|
|
382
|
-
'concurrency-cap': { type: 'integer' },
|
|
383
383
|
results: { type: 'string', alias: 'resultsRaw' },
|
|
384
384
|
returns: { type: 'string', alias: 'returnsRaw' },
|
|
385
385
|
help: { type: 'boolean', short: 'h' },
|
|
@@ -393,8 +393,7 @@ export function parseArgv(argv) {
|
|
|
393
393
|
* Orchestration body of `main` extracted as a sibling exported function so
|
|
394
394
|
* the validate / dispatch / envelope-shape ladder is unit-testable without
|
|
395
395
|
* spawning a process. `main` becomes a thin shell: parse → call this →
|
|
396
|
-
* render → exit.
|
|
397
|
-
* stdout JSON schema).
|
|
396
|
+
* render → exit.
|
|
398
397
|
*
|
|
399
398
|
* @param {ReturnType<typeof parseArgv>} values
|
|
400
399
|
* @param {{
|
|
@@ -421,23 +420,10 @@ export async function runRecordWaveCli(values, deps = {}) {
|
|
|
421
420
|
},
|
|
422
421
|
};
|
|
423
422
|
}
|
|
424
|
-
if (!Number.isInteger(values.wave) || values.wave < 0) {
|
|
425
|
-
return {
|
|
426
|
-
exitCode: 2,
|
|
427
|
-
result: {
|
|
428
|
-
kind: 'validation-error',
|
|
429
|
-
message:
|
|
430
|
-
'[epic-execute-record-wave] ERROR: --wave <index> is required (>= 0).',
|
|
431
|
-
help: helpText,
|
|
432
|
-
},
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
423
|
const resolveInput = deps.resolveRecordInput ?? resolveRecordInput;
|
|
436
424
|
const runner = deps.runRecordWave ?? runEpicExecuteRecordWave;
|
|
437
425
|
const envelope = await runner({
|
|
438
426
|
epicId: values.epicId,
|
|
439
|
-
wave: values.wave,
|
|
440
|
-
concurrencyCap: values.concurrencyCap,
|
|
441
427
|
...resolveInput(values),
|
|
442
428
|
});
|
|
443
429
|
return { exitCode: 0, result: { kind: 'envelope', envelope } };
|
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
} from '../crap-utils.js';
|
|
91
91
|
import {
|
|
92
92
|
calculateAll as calculateAllMi,
|
|
93
|
+
isIgnoredByGlobs as isIgnoredByGlobsMi,
|
|
93
94
|
scanDirectory as scanDirectoryMi,
|
|
94
95
|
} from '../maintainability-utils.js';
|
|
95
96
|
import { filterExcludedRows } from './kinds/maintainability.js';
|
|
@@ -291,7 +292,18 @@ function buildDefaultMaintainabilityScorer({ cwd, config, quality } = {}) {
|
|
|
291
292
|
const underTarget = targetAbsDirs.some(
|
|
292
293
|
(root) => abs === root || abs.startsWith(`${root}${path.sep}`),
|
|
293
294
|
);
|
|
294
|
-
|
|
295
|
+
// Apply `ignoreGlobs` here too — the full-scope walk drops
|
|
296
|
+
// ignore-matched files via `scanDirectoryMi`, so the diff-scope path
|
|
297
|
+
// must do the same or an ignored-but-changed file (e.g. one matched by
|
|
298
|
+
// `config-settings-schema*.js`) enters `rows` and drags the
|
|
299
|
+
// `rollup["*"].min` below the maintainability floor. Reuse the same
|
|
300
|
+
// matcher `scanDirectoryMi` uses so behaviour is identical.
|
|
301
|
+
if (
|
|
302
|
+
underTarget &&
|
|
303
|
+
!isIgnoredByGlobsMi(abs, ignoreGlobs, effectiveCwd)
|
|
304
|
+
) {
|
|
305
|
+
sourceList.push(abs);
|
|
306
|
+
}
|
|
295
307
|
}
|
|
296
308
|
}
|
|
297
309
|
const scores = await calculateAllMi(sourceList);
|
|
@@ -112,8 +112,6 @@ const KEY_MEANINGS = Object.freeze({
|
|
|
112
112
|
'Allowlist of events that fire a webhook notification.',
|
|
113
113
|
|
|
114
114
|
// planning.*
|
|
115
|
-
'planning.maxTickets':
|
|
116
|
-
'Upper bound on tickets a single /plan run may create.',
|
|
117
115
|
'planning.context.maxBytes':
|
|
118
116
|
'Byte budget for the planning-context payload before summary mode kicks in.',
|
|
119
117
|
'planning.context.summaryMode':
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* Limits/budgets/signals accessors (Epic #1720 Story #1739 — top-level reshape).
|
|
3
3
|
*
|
|
4
4
|
* Pre-reshape, every runtime ceiling lived under the legacy `agentSettings.limits.*` bag.
|
|
5
|
-
* Post-reshape, the surviving keys are split across
|
|
6
|
-
* `delivery.*`:
|
|
5
|
+
* Post-reshape, the surviving operator-configurable keys are split across
|
|
6
|
+
* `planning.*` and `delivery.*`:
|
|
7
7
|
*
|
|
8
|
-
* - `planning.maxTickets` (decomposer ceiling)
|
|
9
8
|
* - `planning.context.{maxBytes, summaryMode}` (planning-context budget)
|
|
10
9
|
* - `delivery.maxTokenBudget` (task-prompt hydration cap)
|
|
11
10
|
* - `delivery.execution.timeoutMs` (per-process execution timeout)
|
|
@@ -13,6 +12,14 @@
|
|
|
13
12
|
* - `delivery.signals.{hotspot, rework, retry}` (performance-signal
|
|
14
13
|
* detector thresholds — `churn` and `idle` dropped)
|
|
15
14
|
*
|
|
15
|
+
* `maxTickets` (the decomposer reviewability budget) is a **framework
|
|
16
|
+
* constant** — Story #4163 collapsed the never-overridden
|
|
17
|
+
* `planning.maxTickets` operator knob to `LIMITS_DEFAULTS.maxTickets` and
|
|
18
|
+
* removed it from the AJV schema, the published mirror, and the explain
|
|
19
|
+
* map. The persist-time over-budget gate (ADR-20260610) still reads the
|
|
20
|
+
* constant via `getLimits(config).maxTickets`; `resolveLimits` no longer
|
|
21
|
+
* reads `planning.maxTickets`, so setting it in a config is inert.
|
|
22
|
+
*
|
|
16
23
|
* Dropped entirely: `maxInstructionSteps`, `friction.*` (the LLM
|
|
17
24
|
* self-pacing thresholds rewritten as qualitative prose in
|
|
18
25
|
* `.agents/instructions.md`), `executionMaxBuffer` (now a framework-internal
|
|
@@ -55,7 +62,7 @@ export const LEASE_TTL_MS_DEFAULT = 900000;
|
|
|
55
62
|
* per-complexity budget branch anywhere in the resolver.
|
|
56
63
|
*/
|
|
57
64
|
export const LIMITS_DEFAULTS = Object.freeze({
|
|
58
|
-
maxTickets:
|
|
65
|
+
maxTickets: 80,
|
|
59
66
|
maxTokenBudget: 300000,
|
|
60
67
|
executionTimeoutMs: 600000,
|
|
61
68
|
leaseTtlMs: LEASE_TTL_MS_DEFAULT,
|
|
@@ -91,9 +98,10 @@ function mergeSignals(userSignals) {
|
|
|
91
98
|
/**
|
|
92
99
|
* Resolve the surviving limits surface against a `.agentrc.json` shape
|
|
93
100
|
* (post-reshape). Accepts the resolved-config wrapper or a partial bag —
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
101
|
+
* `maxTickets` is the framework constant `LIMITS_DEFAULTS.maxTickets`
|
|
102
|
+
* (no longer operator-configurable; Story #4163), pulls `planningContext`
|
|
103
|
+
* from `planning.*`, pulls `maxTokenBudget` and `executionTimeoutMs` from
|
|
104
|
+
* `delivery.*`, pulls signals from `delivery.signals.*`.
|
|
97
105
|
*
|
|
98
106
|
* @param {object|undefined} config
|
|
99
107
|
* @returns {{
|
|
@@ -125,7 +133,10 @@ export function resolveLimits(config) {
|
|
|
125
133
|
const lease =
|
|
126
134
|
delivery.lease && typeof delivery.lease === 'object' ? delivery.lease : {};
|
|
127
135
|
return {
|
|
128
|
-
|
|
136
|
+
// `maxTickets` is a framework constant (Story #4163) — never read from
|
|
137
|
+
// `planning.maxTickets`. The persist-time over-budget gate still reads
|
|
138
|
+
// this value via getLimits().maxTickets.
|
|
139
|
+
maxTickets: LIMITS_DEFAULTS.maxTickets,
|
|
129
140
|
maxTokenBudget: delivery.maxTokenBudget ?? LIMITS_DEFAULTS.maxTokenBudget,
|
|
130
141
|
executionTimeoutMs:
|
|
131
142
|
execution.timeoutMs ?? LIMITS_DEFAULTS.executionTimeoutMs,
|
|
@@ -300,6 +300,22 @@ const ACCEPTANCE_EVAL_SCHEMA = {
|
|
|
300
300
|
additionalProperties: false,
|
|
301
301
|
};
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* `delivery.feedbackLoop` — opt-out toggles consumed by the Epic finalize
|
|
305
|
+
* listener's auto-file graduators (`lib/feedback-loop/*-graduator.js`, read
|
|
306
|
+
* via `graduator-core.js#makeIsAutoFileEnabled`). Both default to `true`
|
|
307
|
+
* (auto-file on); set either to `false` to suppress auto-filing the
|
|
308
|
+
* corresponding non-blocking findings as follow-up issues.
|
|
309
|
+
*/
|
|
310
|
+
const FEEDBACK_LOOP_SCHEMA = {
|
|
311
|
+
type: 'object',
|
|
312
|
+
properties: {
|
|
313
|
+
codeReviewAutoFile: { type: 'boolean' },
|
|
314
|
+
auditResultsAutoFile: { type: 'boolean' },
|
|
315
|
+
},
|
|
316
|
+
additionalProperties: false,
|
|
317
|
+
};
|
|
318
|
+
|
|
303
319
|
export const DELIVERY_SCHEMA = {
|
|
304
320
|
type: 'object',
|
|
305
321
|
properties: {
|
|
@@ -318,6 +334,7 @@ export const DELIVERY_SCHEMA = {
|
|
|
318
334
|
retro: RETRO_SCHEMA,
|
|
319
335
|
refactorStage: REFACTOR_STAGE_SCHEMA,
|
|
320
336
|
acceptanceEval: ACCEPTANCE_EVAL_SCHEMA,
|
|
337
|
+
feedbackLoop: FEEDBACK_LOOP_SCHEMA,
|
|
321
338
|
ci: CI_DELIVERY_SCHEMA,
|
|
322
339
|
preflight: PREFLIGHT_SCHEMA,
|
|
323
340
|
// Cross-Story concurrency-hazard gate (Story #2297). When true,
|
|
@@ -249,7 +249,7 @@ const CODEBASE_SNAPSHOT_SCHEMA = {
|
|
|
249
249
|
/**
|
|
250
250
|
* `planning.taskSizing` — Story-sizing thresholds consumed by
|
|
251
251
|
* `ticket-validator-sizing.js`. Operator overrides shallow-merge with
|
|
252
|
-
* `DEFAULT_TASK_SIZING` defaults (softFiles
|
|
252
|
+
* `DEFAULT_TASK_SIZING` defaults (softFiles 15, hardFiles 30,
|
|
253
253
|
* maxAcceptance 14, softAcceptanceCount 10 — the uniform relaxed profile
|
|
254
254
|
* from Story #3874). Story #3760 collapsed the per-profile matrix and the
|
|
255
255
|
* parallel `testSurface` axis into a flat set of knobs; the `sizingProfile`
|
|
@@ -271,7 +271,6 @@ const PLANNING_SCHEMA = {
|
|
|
271
271
|
type: 'object',
|
|
272
272
|
properties: {
|
|
273
273
|
riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
|
|
274
|
-
maxTickets: { type: 'integer', minimum: 1 },
|
|
275
274
|
context: PLANNING_CONTEXT_SCHEMA,
|
|
276
275
|
codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
|
|
277
276
|
taskSizing: TASK_SIZING_SCHEMA,
|
|
@@ -282,6 +281,17 @@ const PLANNING_SCHEMA = {
|
|
|
282
281
|
// decompose loop's re-prompt gate.
|
|
283
282
|
failOnSharedEditors: { type: 'boolean' },
|
|
284
283
|
requireExplicitCrossStoryDeps: { type: 'boolean' },
|
|
284
|
+
// Cross-cutting registry conflict knobs consumed by
|
|
285
|
+
// `ticket-validator-conflicts.js` (wired through
|
|
286
|
+
// `epic-plan-decompose/phases/planning-artifacts.js`).
|
|
287
|
+
// `crossCuttingRegistries` names the registry paths whose concurrent
|
|
288
|
+
// edits are flagged; `failOnRegistryConflicts` upgrades that finding to
|
|
289
|
+
// `'hard'`. `failOnLargeFanOut` / `largeFanOutThreshold` gate the
|
|
290
|
+
// single-Story fan-out finding.
|
|
291
|
+
crossCuttingRegistries: LIST_OR_EXTENDER_OF_STRINGS,
|
|
292
|
+
failOnRegistryConflicts: { type: 'boolean' },
|
|
293
|
+
failOnLargeFanOut: { type: 'boolean' },
|
|
294
|
+
largeFanOutThreshold: { type: 'integer', minimum: 0 },
|
|
285
295
|
// Navigability-reachability config consumed by the epic-plan-healthcheck
|
|
286
296
|
// --paranoid reachability check (Epic #4131, F7). Opt-in: absent or empty
|
|
287
297
|
// routeGlobs degrades to a silent no-op.
|
|
@@ -36,6 +36,36 @@ const IGNORED_DIRS = new Set([
|
|
|
36
36
|
'.next',
|
|
37
37
|
]);
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Test whether an absolute (or repo-relative) file path matches any of the
|
|
41
|
+
* configured `ignoreGlobs`. This is the single source of truth for how the
|
|
42
|
+
* maintainability scorer decides a file is ignored: both the full-scope
|
|
43
|
+
* directory walk (`scanDirectory` below) and the diff-scope file-list path
|
|
44
|
+
* in `refresh-service.js` MUST funnel through it so an `ignoreGlobs`-listed
|
|
45
|
+
* file is excluded identically in both scopes (a diff-scope refresh that
|
|
46
|
+
* skipped this check would let an ignored file poison the `rollup["*"].min`
|
|
47
|
+
* floor — see `buildDefaultMaintainabilityScorer`).
|
|
48
|
+
*
|
|
49
|
+
* Matching mirrors `scanDirectory`: the path is reduced to a canonicalised,
|
|
50
|
+
* POSIX, repo-relative form and tested against each glob with minimatch's
|
|
51
|
+
* `{ dot: true }` so dot-prefixed roots like `.agents/` match.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} filePath absolute or relative path to the source file
|
|
54
|
+
* @param {string[]} ignoreGlobs minimatch patterns; empty/absent is a no-op
|
|
55
|
+
* @param {string} [cwd] root for repo-relative resolution; defaults to cwd
|
|
56
|
+
* @returns {boolean} true when the file matches at least one ignore glob
|
|
57
|
+
*/
|
|
58
|
+
export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
|
|
59
|
+
if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return false;
|
|
60
|
+
const matchCwd = cwd ?? process.cwd();
|
|
61
|
+
const absFilePath = path.isAbsolute(filePath)
|
|
62
|
+
? filePath
|
|
63
|
+
: path.resolve(matchCwd, filePath);
|
|
64
|
+
const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
|
|
65
|
+
const relPath = canonicalisePath(rawRel);
|
|
66
|
+
return ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }));
|
|
67
|
+
}
|
|
68
|
+
|
|
39
69
|
/**
|
|
40
70
|
* Recursively scans a directory for JS/TS source files. Accepts `.js`,
|
|
41
71
|
* `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts`. Directories listed
|
|
@@ -71,15 +101,8 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
|
|
|
71
101
|
scanDirectory(filePath, fileList, opts);
|
|
72
102
|
}
|
|
73
103
|
} else if (entry.isFile() && isSupportedSourceFile(entry.name)) {
|
|
74
|
-
if (ignoreGlobs
|
|
75
|
-
|
|
76
|
-
? filePath
|
|
77
|
-
: path.resolve(filePath);
|
|
78
|
-
const rawRel = path.relative(matchCwd, absFilePath).replace(/\\/g, '/');
|
|
79
|
-
const relPath = canonicalisePath(rawRel);
|
|
80
|
-
if (ignoreGlobs.some((g) => minimatch(relPath, g, { dot: true }))) {
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
104
|
+
if (isIgnoredByGlobs(filePath, ignoreGlobs, matchCwd)) {
|
|
105
|
+
continue;
|
|
83
106
|
}
|
|
84
107
|
fileList.push(filePath);
|
|
85
108
|
}
|
|
@@ -105,7 +105,12 @@ export function epicBranchHasOpenPr(opts) {
|
|
|
105
105
|
/**
|
|
106
106
|
* Build the list of branches owned by the Epic from the checkpoint.
|
|
107
107
|
*
|
|
108
|
-
*
|
|
108
|
+
* Story #4155 — the ready-set runtime records a flat per-Story status map
|
|
109
|
+
* (`stories: { [storyId]: { status, ... } }`) on the checkpoint instead of a
|
|
110
|
+
* per-wave `waves[]` history. The owned Story branches are the keys of that
|
|
111
|
+
* map.
|
|
112
|
+
*
|
|
113
|
+
* @param {{ epicId: number, stories?: Record<string, object> } | null} state
|
|
109
114
|
* @returns {{ epicBranch: string, storyBranches: string[] }}
|
|
110
115
|
*/
|
|
111
116
|
export function listEpicBranchesFromState(state) {
|
|
@@ -113,13 +118,12 @@ export function listEpicBranchesFromState(state) {
|
|
|
113
118
|
if (!Number.isInteger(epicId) || epicId <= 0) {
|
|
114
119
|
return { epicBranch: null, storyBranches: [] };
|
|
115
120
|
}
|
|
121
|
+
const storyMap =
|
|
122
|
+
state?.stories && typeof state.stories === 'object' ? state.stories : {};
|
|
116
123
|
const storyIds = new Set();
|
|
117
|
-
for (const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
storyIds.add(story.id);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
124
|
+
for (const key of Object.keys(storyMap)) {
|
|
125
|
+
const id = Number(key);
|
|
126
|
+
if (Number.isInteger(id) && id > 0) storyIds.add(id);
|
|
123
127
|
}
|
|
124
128
|
return {
|
|
125
129
|
epicBranch: `epic/${epicId}`,
|