mandrel 1.68.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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.js +1 -2
  12. package/.agents/scripts/lib/maintainability-utils.js +32 -9
  13. package/.agents/scripts/lib/orchestration/epic-cleanup.js +11 -7
  14. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/cli.js +6 -6
  15. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +11 -5
  16. package/.agents/scripts/lib/orchestration/epic-run-state-store.js +203 -110
  17. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +38 -78
  18. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/transport.js +16 -13
  19. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +10 -7
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +37 -24
  21. package/.agents/scripts/lib/orchestration/manifest-builder.js +6 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +6 -2
  23. package/.agents/scripts/lib/orchestration/wave-record-io.js +18 -77
  24. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +78 -122
  25. package/.agents/scripts/lib/orchestration/wave-record-projection.js +21 -226
  26. package/.agents/scripts/lib/presentation/dispatch-manifest-render.js +18 -1
  27. package/.agents/scripts/lib/presentation/manifest-render-waves.js +77 -4
  28. package/.agents/scripts/lib/story-adjacency.js +14 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +36 -4
  30. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -3
  31. package/.agents/scripts/lib/wave-runner/ready-set.js +295 -0
  32. package/.agents/scripts/lib/wave-runner/tick.js +312 -206
  33. package/.agents/scripts/lib/wave-runner/wave-runner-error.js +2 -1
  34. package/.agents/scripts/lint-label-vocabulary.js +1 -1
  35. package/.agents/scripts/stories-wave-tick.js +262 -161
  36. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +6 -0
  37. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +108 -101
  38. package/.agents/skills/skills.index.json +2 -2
  39. package/.agents/workflows/deliver.md +12 -9
  40. package/.agents/workflows/helpers/deliver-epic.md +126 -90
  41. package/.agents/workflows/helpers/deliver-stories.md +131 -85
  42. package/.agents/workflows/helpers/plan-epic.md +13 -10
  43. package/.agents/workflows/plan.md +1 -1
  44. package/docs/CHANGELOG.md +14 -0
  45. package/package.json +1 -1
  46. package/.agents/scripts/lib/wave-runner/wave-checkpoint.js +0 -91
@@ -59,7 +59,6 @@
59
59
  }
60
60
  },
61
61
  "planning": {
62
- "maxTickets": 60,
63
62
  "context": {
64
63
  "maxBytes": 50000,
65
64
  "summaryMode": "auto"
@@ -91,7 +90,7 @@
91
90
  "Schema migrations that rewrite existing rows or drop columns without a backfill or rollback plan."
92
91
  ],
93
92
  "taskSizing": {
94
- "softFiles": 8,
93
+ "softFiles": 15,
95
94
  "hardFiles": 30,
96
95
  "maxAcceptance": 14,
97
96
  "softAcceptanceCount": 10
@@ -26,7 +26,7 @@ with the runtime validators.
26
26
  "$schema": "./.agents/schemas/agentrc.schema.json",
27
27
  "project": { /* paths, commands, baseBranch, docsContextFiles */ },
28
28
  "github": { /* owner, repo, branchProtection, mergeMethods, notifications */ },
29
- "planning": { /* riskHeuristics, maxTickets, codebaseSnapshot, context */ },
29
+ "planning": { /* riskHeuristics, codebaseSnapshot, context */ },
30
30
  "delivery": { /* execution, quality, worktreeIsolation, deliverRunner, ... */ }
31
31
  }
32
32
  ```
@@ -97,7 +97,6 @@ top-level keys are validation errors.
97
97
  | Key | Required | Type | Default | Description |
98
98
  | --- | --- | --- | --- | --- |
99
99
  | `riskHeuristics` | No | `string[]` or `{ append?, prepend? }` | — | — |
100
- | `maxTickets` | No | `integer` | — | — |
101
100
  | `context` | No | `object` | — | Nested configuration block. |
102
101
  | `context.maxBytes` | No | `integer` | — | — |
103
102
  | `context.summaryMode` | No | `"auto"` \| `"always"` \| `"never"` | — | — |
@@ -107,7 +106,7 @@ top-level keys are validation errors.
107
106
  | `codebaseSnapshot.exclude` | No | `array<string>` | — | — |
108
107
  | `codebaseSnapshot.recentCommitWindow` | No | `integer` | — | — |
109
108
  | `taskSizing` | No | `object` | — | Story-sizing thresholds consumed by ticket-validator-sizing.js. Operator overrides shallow-merge with DEFAULT_TASK_SIZING defaults. Story #3760 collapsed the per-profile matrix and the parallel testSurface axis into a flat set of knobs; the sizingProfile enum was replaced by an optional body-level `wide` declaration that lifts the hardFiles rejection. Story #3874 cut over to one uniform relaxed profile sized for capability slices a frontier model delivers and self-verifies in one pass. |
110
- | `taskSizing.softFiles` | No | `integer` | — | File-count soft-warn threshold above which a typical-Story width finding fires (default 8). |
109
+ | `taskSizing.softFiles` | No | `integer` | — | File-count soft-warn threshold above which a typical-Story width finding fires (default 15). |
111
110
  | `taskSizing.hardFiles` | No | `integer` | — | File-count hard ceiling: a Story exceeding it is rejected unless it declares `wide` with a reason (default 30). |
112
111
  | `taskSizing.maxAcceptance` | No | `integer` | — | Hard ceiling on acceptance[] item count (default 14). |
113
112
  | `taskSizing.softAcceptanceCount` | No | `integer` | — | Soft-warn threshold on acceptance[] item count (default 10). |
@@ -430,7 +429,6 @@ suppress a channel entirely, set its array to `[]`.
430
429
  | Field | Required | Default | Purpose |
431
430
  | ------------------------------ | -------- | ---------- | ------------------------------------------------------------------------------------------------ |
432
431
  | `riskHeuristics` | No | `[]` | Free-form rubric for `risk::high` decisions (informational only — `risk::high` does not gate runtime). Accepts a plain array or the `{ append/prepend }` extender form. |
433
- | `maxTickets` | No | (none) | Soft cap on tickets a single Epic may decompose. |
434
432
  | `failOnSharedEditors` | No | (none) | Hard-fail Phase 7 when two Stories declare the same editor. |
435
433
  | `requireExplicitCrossStoryDeps`| No | (none) | Require explicit cross-Story `blocked by` declarations rather than inferring from shared paths. |
436
434
 
@@ -332,7 +332,7 @@
332
332
  "softFiles": {
333
333
  "type": "integer",
334
334
  "minimum": 1,
335
- "description": "File-count soft-warn threshold above which a typical-Story width finding fires (default 8)."
335
+ "description": "File-count soft-warn threshold above which a typical-Story width finding fires (default 15)."
336
336
  },
337
337
  "hardFiles": {
338
338
  "type": "integer",
@@ -358,10 +358,6 @@
358
358
  "riskHeuristics": {
359
359
  "$ref": "#/$defs/listOrExtenderOfStrings"
360
360
  },
361
- "maxTickets": {
362
- "type": "integer",
363
- "minimum": 1
364
- },
365
361
  "context": {
366
362
  "$ref": "#/$defs/planningContext"
367
363
  },
@@ -8,7 +8,8 @@
8
8
  "properties": {
9
9
  "prUrl": { "type": "string", "format": "uri", "minLength": 1 },
10
10
  "merged": { "type": "boolean" },
11
- "reason": { "type": "string" }
11
+ "reason": { "type": "string" },
12
+ "epicId": { "type": "integer", "minimum": 1 }
12
13
  },
13
14
  "additionalProperties": false
14
15
  }
@@ -8,7 +8,7 @@
8
8
  * fan-out and surfaces the result to the operator on two channels:
9
9
  *
10
10
  * 1. A JSON envelope on stdout (always) with the keys
11
- * `storyCount`, `installCostSeconds`, `waveCount`,
11
+ * `storyCount`, `installCostSeconds`, `dependencyDepth`,
12
12
  * `githubApiRequests`, `claudeQuotaTokens`, plus `breaches`
13
13
  * (the non-empty subset of `delivery.preflight.max*` thresholds the
14
14
  * estimate exceeds).
@@ -30,8 +30,14 @@
30
30
  * the comment can reason about the numbers):
31
31
  *
32
32
  * - `storyCount` = number of child `type::story` tickets.
33
- * - `waveCount` = computed wave DAG length (matches the
34
- * dispatch manifest).
33
+ * - `dependencyDepth` = longest dependency chain through the Story
34
+ * DAG (Story #4155). The ready-set runtime has
35
+ * no wave barrier, so wall-clock is bounded
36
+ * below by this depth, not by a wave count: a
37
+ * depth-1 Epic (all Stories independent) can run
38
+ * fully parallel, while a depth-N chain forces N
39
+ * sequential beats regardless of cap. Computed
40
+ * as the dependency-DAG layer count.
35
41
  * - `installCostSeconds` = `storyCount * perStoryInstallSeconds`
36
42
  * (default 45s, override via
37
43
  * `--per-story-install-seconds`). Models a
@@ -115,7 +121,7 @@ const DEFAULTS = Object.freeze({
115
121
  *
116
122
  * @param {{
117
123
  * storyCount: number,
118
- * waveCount: number,
124
+ * dependencyDepth: number,
119
125
  * perStoryInstallSeconds?: number,
120
126
  * perStoryApiRequests?: number,
121
127
  * perStoryClaudeTokens?: number,
@@ -124,14 +130,14 @@ const DEFAULTS = Object.freeze({
124
130
  * @returns {{
125
131
  * storyCount: number,
126
132
  * installCostSeconds: number,
127
- * waveCount: number,
133
+ * dependencyDepth: number,
128
134
  * githubApiRequests: number,
129
135
  * claudeQuotaTokens: number,
130
136
  * }}
131
137
  */
132
138
  export function computeEstimate({
133
139
  storyCount,
134
- waveCount,
140
+ dependencyDepth,
135
141
  perStoryInstallSeconds = DEFAULTS.perStoryInstallSeconds,
136
142
  perStoryApiRequests = DEFAULTS.perStoryApiRequests,
137
143
  perStoryClaudeTokens = DEFAULTS.perStoryClaudeTokens,
@@ -142,15 +148,15 @@ export function computeEstimate({
142
148
  'computeEstimate: storyCount must be a non-negative integer',
143
149
  );
144
150
  }
145
- if (!Number.isInteger(waveCount) || waveCount < 0) {
151
+ if (!Number.isInteger(dependencyDepth) || dependencyDepth < 0) {
146
152
  throw new TypeError(
147
- 'computeEstimate: waveCount must be a non-negative integer',
153
+ 'computeEstimate: dependencyDepth must be a non-negative integer',
148
154
  );
149
155
  }
150
156
  return {
151
157
  storyCount,
152
158
  installCostSeconds: storyCount * perStoryInstallSeconds,
153
- waveCount,
159
+ dependencyDepth,
154
160
  githubApiRequests: baseApiRequests + storyCount * perStoryApiRequests,
155
161
  claudeQuotaTokens: storyCount * perStoryClaudeTokens,
156
162
  };
@@ -167,7 +173,14 @@ export function computeEstimate({
167
173
  export function detectBreaches(estimate, thresholds) {
168
174
  const mapping = [
169
175
  { key: 'storyCount', observedKey: 'storyCount', maxKey: 'maxStories' },
170
- { key: 'waveCount', observedKey: 'waveCount', maxKey: 'maxWaves' },
176
+ // Story #4155 `dependencyDepth` replaces the retired `waveCount`
177
+ // estimate; it is still gated by the `maxWaves` config threshold (the
178
+ // published config key is left unchanged for contract stability).
179
+ {
180
+ key: 'dependencyDepth',
181
+ observedKey: 'dependencyDepth',
182
+ maxKey: 'maxWaves',
183
+ },
171
184
  {
172
185
  key: 'installCostSeconds',
173
186
  observedKey: 'installCostSeconds',
@@ -213,7 +226,7 @@ export function renderPreflightBody({
213
226
  lines.push('| --- | ---: | ---: |');
214
227
  const rows = [
215
228
  ['storyCount', estimate.storyCount, thresholds.maxStories],
216
- ['waveCount', estimate.waveCount, thresholds.maxWaves],
229
+ ['dependencyDepth', estimate.dependencyDepth, thresholds.maxWaves],
217
230
  [
218
231
  'installCostSeconds',
219
232
  estimate.installCostSeconds,
@@ -292,7 +305,11 @@ export async function runPreflight({
292
305
  state = await runBuildWaveDagPhase(ctx, {}, state);
293
306
 
294
307
  const storyCount = Array.isArray(state.stories) ? state.stories.length : 0;
295
- const waveCount = Array.isArray(state.waves) ? state.waves.length : 0;
308
+ // Dependency depth = the dependency-DAG layer count (the longest chain of
309
+ // `blocked by` edges). `runBuildWaveDagPhase` already computes this layering
310
+ // as `state.waves`, so its length is the depth even though the ready-set
311
+ // runtime no longer dispatches by wave (Story #4155).
312
+ const dependencyDepth = Array.isArray(state.waves) ? state.waves.length : 0;
296
313
 
297
314
  // Persist the snapshot/DAG envelope so `epic-deliver-prepare.js` can
298
315
  // reuse it instead of re-walking the hierarchy. The cache key is a
@@ -317,7 +334,7 @@ export async function runPreflight({
317
334
 
318
335
  const estimate = computeEstimate({
319
336
  storyCount,
320
- waveCount,
337
+ dependencyDepth,
321
338
  perStoryInstallSeconds,
322
339
  perStoryApiRequests,
323
340
  perStoryClaudeTokens,
@@ -5,17 +5,25 @@
5
5
  * epic-deliver-prepare.js — Step 0/1 of the operator-driven `/deliver`.
6
6
  *
7
7
  * Composes the existing engine phases that the in-process epic-runner used to
8
- * call sequentially, but does NOT dispatch any waves. The CLI is the single
8
+ * call sequentially, but does NOT dispatch any Stories. The CLI is the single
9
9
  * point at which the slash-command captures:
10
10
  *
11
11
  * 1. The Epic ticket snapshot (`runSnapshotPhase`).
12
- * 2. The wave DAG (`runBuildWaveDagPhase`) computed from every child Story.
12
+ * 2. The story DAG (`runBuildWaveDagPhase`) computed from every child Story
13
+ * used here only to enumerate the open Story set and run the
14
+ * concurrency-hazard gate; the ready-set runtime re-derives readiness
15
+ * from live labels on every `tick`, so the prepare no longer persists a
16
+ * wave grouping.
13
17
  * 3. The seeded `epic-run-state` checkpoint (`epic-run-state-store.initialize`)
14
- * idempotent, so re-running prepare against a partially-driven Epic
15
- * preserves the original `startedAt`.
16
- * 4. The per-wave dispatch plan (`StoryLauncher.planWave`) a deterministic
17
- * list of `{ storyId, worktree }` entries that the slash command feeds
18
- * into N parallel `Agent` tool calls per wave.
18
+ * in the per-Story-status shape (Story #4155): a flat
19
+ * `stories: { [storyId]: { status: 'pending' } }` map plus the GLOBAL
20
+ * in-flight `concurrencyCap`. Idempotent re-running prepare against a
21
+ * partially-driven Epic preserves the original `startedAt` and every
22
+ * already-recorded Story status (it never resets recorded progress).
23
+ * 4. The dispatch hint (`StoryLauncher.planWave`) — a deterministic list of
24
+ * `{ storyId, worktree }` entries the slash command uses to resolve
25
+ * per-Story worktree paths. The ready-set `tick` selects which of these
26
+ * to dispatch on each beat; the prepare only enumerates the set.
19
27
  *
20
28
  * Stdout is a single JSON envelope so the slash command can parse without
21
29
  * re-reading any tickets.
@@ -37,7 +45,6 @@ import {
37
45
  } from './lib/orchestration/epic-deliver-lease-guard.js';
38
46
  import {
39
47
  initialize as initializeEpicRunState,
40
- reconcileResumePointer,
41
48
  write as writeEpicRunState,
42
49
  } from './lib/orchestration/epic-run-state-store.js';
43
50
  import {
@@ -133,9 +140,9 @@ function resolveGitUserEmail(cwd) {
133
140
  * }} args
134
141
  * @returns {Promise<{
135
142
  * epicId: number,
136
- * totalWaves: number,
143
+ * storyCount: number,
137
144
  * concurrencyCap: number,
138
- * plan: Array<{ wave: number, stories: Array<{ storyId: number, title: string, worktree?: string }> }>,
145
+ * stories: Array<{ storyId: number, title: string, worktree?: string }>,
139
146
  * checkpointInitializedAt: string,
140
147
  * }>}
141
148
  */
@@ -334,64 +341,44 @@ export async function runEpicDeliverPrepare({
334
341
  ignoreConcurrencyHazards,
335
342
  });
336
343
 
337
- const totalWaves = state.waves.length;
344
+ // Flatten the wave-DAG into the open Story set. The ready-set runtime
345
+ // re-derives readiness from live labels on every tick, so the checkpoint
346
+ // stores only the Story set in scope (seeded at `pending`) and the global
347
+ // in-flight cap — no wave grouping, no `currentWave`, no `totalWaves`.
348
+ const openStories = state.waves.flat();
338
349
  const checkpointState = await initializeEpicRunState({
339
350
  provider,
340
351
  epicId,
341
- totalWaves,
352
+ storyIds: openStories,
342
353
  concurrencyCap,
343
354
  });
344
355
 
356
+ // Resolve per-Story worktree paths via the launcher so the slash command
357
+ // has a deterministic `{ storyId, worktree, title }` list to seed Agent
358
+ // dispatch from. This is a dispatch *hint* — the ready-set tick decides
359
+ // which Stories to dispatch on each beat; the prepare only enumerates them.
345
360
  const launcher = new StoryLauncher({ concurrencyCap });
346
- const plan = state.waves.map((stories, index) => ({
347
- wave: index,
348
- stories: launcher.planWave(stories).map((entry, i) => ({
349
- ...entry,
350
- title: stories[i]?.title ?? '',
351
- })),
361
+ const stories = launcher.planWave(openStories).map((entry, i) => ({
362
+ ...entry,
363
+ title: openStories[i]?.title ?? '',
352
364
  }));
353
365
 
354
- // Persist the plan onto the checkpoint so `wave-tick.js` (which reads
355
- // state.plan as `Array<Array<{ id|storyId, title?, worktree? }>>`) can
356
- // resolve the next wave's stories. Without this write the tick reports
357
- // every wave as `wave-complete: empty` and the delivery stalls.
358
- const tickPlan = plan.map((wave) => wave.stories);
359
-
360
- // Story #3358 — reconcile the resume pointer against the recomputed
361
- // plan. On a resumed Epic, `build-wave-dag.js` drops the already-merged
362
- // (closed) Stories, so the recomputed plan is shorter and re-indexed
363
- // from 0. The preserved `currentWave`/`waves[]` reference the *old*
364
- // index space; left untouched, `wave-tick.js` would index
365
- // `plan[currentWave]` into the new plan and dispatch the wrong wave.
366
- // Prepare owns the `plan` field, so it owns the pointer that indexes
367
- // into it. When the plan changed, reset the pointer to 0 and drop the
368
- // stale history; when it is byte-identical (idempotent re-prepare),
369
- // preserve in-flight progress verbatim.
370
- const { currentWave, waves } = reconcileResumePointer(
371
- checkpointState,
372
- checkpointState.plan,
373
- tickPlan,
374
- );
375
-
376
- // Persist the `--ignore-concurrency-hazards` flag on the checkpoint
377
- // so retro tooling can flag a run that shipped despite an outstanding
378
- // hazard (the warning above is one-shot; the checkpoint is durable).
379
- const checkpointPayload = {
380
- ...checkpointState,
381
- plan: tickPlan,
382
- currentWave,
383
- waves,
384
- };
366
+ // Persist the `--ignore-concurrency-hazards` flag on the checkpoint so
367
+ // retro tooling can flag a run that shipped despite an outstanding hazard
368
+ // (the warning above is one-shot; the checkpoint is durable).
385
369
  if (gate.bypassed) {
386
- checkpointPayload.ignoreConcurrencyHazards = true;
370
+ await writeEpicRunState({
371
+ provider,
372
+ epicId,
373
+ state: { ...checkpointState, ignoreConcurrencyHazards: true },
374
+ });
387
375
  }
388
- await writeEpicRunState({ provider, epicId, state: checkpointPayload });
389
376
 
390
377
  return {
391
378
  epicId,
392
- totalWaves,
379
+ storyCount: openStories.length,
393
380
  concurrencyCap,
394
- plan,
381
+ stories,
395
382
  checkpointInitializedAt:
396
383
  checkpointState.startedAt ??
397
384
  checkpointState.lastUpdatedAt ??