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
@@ -2,36 +2,39 @@
2
2
  /* node:coverage ignore file */
3
3
 
4
4
  /**
5
- * epic-execute-record-wave.js — record one wave's per-Story returns,
6
- * advance the `epic-run-state` checkpoint, and re-render the unified
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
- * The slash-command (`/deliver`) calls this CLI once per wave, after
10
- * its host-level Agent-tool fan-out drains. It is the only writer of the
11
- * `epic-run-progress` structured comment for the wave-completion path
12
- * there is no separate `/wave-execute` skill, no `wave-run-progress`
13
- * comment, and no separate rollup CLI. The host LLM owns wave dispatch;
14
- * this CLI owns the post-wave persistence and operator-facing summary.
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. Aggregate the wave's terminal status (complete | blocked | failed).
18
- * 3. Splice the wave outcome into `state.waves[]`, advance
19
- * `state.currentWave` on `complete`, and re-write the checkpoint.
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
- * `finalize` | `halt-blocked` | `halt-failed`).
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 projection
28
- * helpers (status aggregation, rollup-row shaping, next-record
29
- * splicing, next-action classification). Re-exported from this file
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 wave boundary.
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 { emitWaveBoundaryNotifications } from './lib/orchestration/wave-record-notifications.js';
58
+ import { emitRecordNotifications } from './lib/orchestration/wave-record-notifications.js';
56
59
  import {
57
- projectWaveRecord,
58
- resolveConcurrencyCap,
59
60
  selectInputFlag,
60
- validateEpicWave,
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
- classifyWaveOutcome,
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
- validateEpicWave,
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> --wave <waveIndex> [--concurrency-cap <N>] \\
88
+ --epic <epicId> \\
94
89
  (--returns @<file>|<inline-json> | --results @<file>|<inline-json>)
95
90
 
96
- Records the wave's per-Story outcomes, advances the epic-run-state
97
- checkpoint, and upserts the unified epic-run-progress rollup on the Epic.
98
- Prints the next action for the /deliver slash command.
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
- * End-to-end record-wave. DI-friendly: tests pass `injectedProvider` and a
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
- validateEpicWave(epicId, wave);
167
- validateResultsReturnsXor(results, returns);
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. `existing` is threaded so the
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. Project the post-wave-record state. Pure: aggregates the wave
213
- // status, splices this wave's record into the prior list, derives
214
- // nextCurrentWave, and classifies the slash-command next action.
215
- const projection = projectWaveRecord({
216
- wave,
217
- verified,
218
- existing,
219
- concurrencyCap,
220
- titleById,
221
- now,
222
- });
223
-
224
- // 5. Persist the projected checkpoint.
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
- // 5a. Emit one `story.dispatch.end` per recorded Story (Story #3900).
237
- // Closes the start/end pairing the wave-tick reconciler and the
238
- // `--check-idle` watchdog use to derive in-flight Stories. Before this
239
- // the only producer was `wave-session.js`, which the host-LLM driven
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
- // state. This is the only operator-facing summary — there is no
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
- waves: projection.rollupWaves,
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
- // 7. Fire the curated webhook events for this wave boundary. Mirrors the
259
- // wave-loop emits in `lib/orchestration/epic-runner/phases/iterate-waves.js`
260
- // for the host-LLM driven /deliver path (which does not pass
261
- // through `runEpic`). Each helper is fire-and-forget webhook
262
- // misconfig or a transient Slack outage must not block the wave loop.
263
- await emitWaveBoundaryNotifications({
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
- wave,
270
- status: projection.status,
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: projection.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: projection.status,
278
+ status,
292
279
  stories: verified.map((r) => ({ id: r.storyId, status: r.status })),
293
- blockedStoryIds: projection.blockedStoryIds,
294
- nextAction: projection.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 wave
314
- * loop. The Story status taxonomy (`done`/`blocked`/`failed`) maps directly
315
- * onto the `story.dispatch.end` outcome enum.
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. CLI surface unchanged (same flags, same exit codes, same
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
- if (underTarget) sourceList.push(abs);
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 `planning.*` and
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: 60,
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
- * pulls `maxTickets` and `planningContext` from `planning.*`, pulls
95
- * `maxTokenBudget` and `executionTimeoutMs` from `delivery.*`, pulls
96
- * signals from `delivery.signals.*`.
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
- maxTickets: planning.maxTickets ?? LIMITS_DEFAULTS.maxTickets,
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 8, hardFiles 30,
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.length > 0) {
75
- const absFilePath = path.isAbsolute(filePath)
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
- * @param {{ epicId: number, waves?: Array<{ stories?: Array<{ id: number }> }> } | null} state
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 wave of state.waves ?? []) {
118
- for (const story of wave?.stories ?? []) {
119
- if (story && Number.isInteger(story.id) && story.id > 0) {
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}`,