mandrel 1.84.0 → 1.86.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 +8 -2
  2. package/.agents/docs/configuration.md +7 -2
  3. package/.agents/instructions.md +4 -0
  4. package/.agents/rules/ci-remediation.md +131 -0
  5. package/.agents/rules/git-conventions.md +33 -0
  6. package/.agents/schemas/agentrc.schema.json +29 -6
  7. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
  8. package/.agents/scripts/boot-sweep.js +183 -0
  9. package/.agents/scripts/epic-deliver-prepare.js +55 -0
  10. package/.agents/scripts/git-pr-quality-gate.js +7 -5
  11. package/.agents/scripts/lib/config/ci.js +24 -3
  12. package/.agents/scripts/lib/config/explain.js +11 -3
  13. package/.agents/scripts/lib/config/github.js +11 -7
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  16. package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
  17. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  18. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
  19. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  20. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
  21. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  22. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  23. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  24. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  25. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  26. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
  27. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
  28. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  29. package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
  30. package/.agents/scripts/lib/single-story-sweep.js +181 -54
  31. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  32. package/.agents/scripts/pr-watch-with-update.js +324 -37
  33. package/.agents/scripts/run-verify.js +18 -3
  34. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  35. package/.agents/scripts/single-story-init.js +7 -51
  36. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  37. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  38. package/.agents/workflows/git-deliver.md +36 -0
  39. package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
  40. package/.agents/workflows/helpers/deliver-epic.md +148 -28
  41. package/.agents/workflows/helpers/deliver-stories.md +18 -5
  42. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  43. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  44. package/.agents/workflows/plan.md +32 -4
  45. package/docs/CHANGELOG.md +21 -0
  46. package/package.json +1 -1
@@ -244,6 +244,24 @@ export const PROJECTORS = {
244
244
  'check-crap': projectCrapForGate,
245
245
  };
246
246
 
247
+ /**
248
+ * Composite gates fan out to per-kind projectors. The baseline pipeline was
249
+ * unified behind a single `check-baselines` gate (per-kind pipeline: schema →
250
+ * floor → tolerance), but the attribution layer still keys on the original
251
+ * per-kind gate names. Without this map a `check-baselines` failure projects
252
+ * zero regressions, the gate-failure handler sees an empty list, and the
253
+ * auto-refresh path silently no-ops — so a legitimate MI/CRAP regression
254
+ * hard-fails the close instead of self-healing (framework-gap #4377).
255
+ */
256
+ export const COMPOSITE_SUBGATES = {
257
+ 'check-baselines': ['check-maintainability', 'check-crap'],
258
+ };
259
+
260
+ /** `check-maintainability` → `maintainability`. */
261
+ function gateKind(gateName) {
262
+ return gateName.replace(/^check-/, '');
263
+ }
264
+
247
265
  export function projectRegressionsForGate({
248
266
  gateName,
249
267
  cwd,
@@ -253,14 +271,27 @@ export function projectRegressionsForGate({
253
271
  projectMaintainability = defaultProjectMaintainabilityRegressions,
254
272
  getBaselines = defaultGetBaselines,
255
273
  }) {
256
- const project = PROJECTORS[gateName];
257
- if (!project) return [];
258
- return project({
274
+ const ctx = {
259
275
  cwd,
260
276
  epicBranch,
261
277
  storyBranch,
262
278
  config,
263
279
  projectMaintainability,
264
280
  getBaselines,
265
- });
281
+ };
282
+ const subGates = COMPOSITE_SUBGATES[gateName];
283
+ if (subGates) {
284
+ // Union the per-kind regressions, tagging each row with its baseline
285
+ // kind so the gate-failure handler can refresh the right baseline (one
286
+ // kind per attribution cycle; the retry loop converges the rest).
287
+ return subGates.flatMap((sub) => {
288
+ const project = PROJECTORS[sub];
289
+ if (!project) return [];
290
+ const kind = gateKind(sub);
291
+ return project(ctx).map((row) => ({ _gateKind: kind, ...row }));
292
+ });
293
+ }
294
+ const project = PROJECTORS[gateName];
295
+ if (!project) return [];
296
+ return project(ctx);
266
297
  }
@@ -17,10 +17,12 @@
17
17
  * `.agents/scripts/lib/config-settings-schema.js`.
18
18
  *
19
19
  * Sizing model (Story #3760 — profile-matrix collapse; Story #3874 — one
20
- * uniform relaxed profile):
21
- * - Flat knobs: `softFiles` (~15), `hardFiles` (~30), `maxAcceptance` (~14),
20
+ * uniform relaxed profile; the hard acceptance ceiling was removed after the
21
+ * Epic #4355 decomposition experiment showed it forced fragmentation):
22
+ * - Flat knobs: `softFiles` (~15), `hardFiles` (~30),
22
23
  * `softAcceptanceCount` (~10). No per-profile ceiling map, no parallel
23
- * `testSurface` axis, no selector and no second profile.
24
+ * `testSurface` axis, no selector and no second profile. Acceptance
25
+ * mass is advisory-only.
24
26
  * - The four-profile `sizingProfile` enum is replaced by a single optional
25
27
  * `wide` declaration carrying a one-line human-readable reason. Declaring
26
28
  * `wide` with a reason lifts the `hardFiles` rejection; no Story is
@@ -119,9 +121,12 @@ export const DEFAULT_TASK_SIZING = Object.freeze({
119
121
  // The hard `hardFiles` rejection (30) is unchanged.
120
122
  softFiles: 15,
121
123
  softAcceptanceCount: 10,
122
- // Hard ceilings (rejection unless lifted).
124
+ // Hard ceiling (rejection unless lifted via `wide`). Acceptance mass has
125
+ // no hard ceiling: the former `maxAcceptance` rejection forced careful,
126
+ // fine-grained specs to fragment one coherent capability into dependent
127
+ // slices (observed on Epic #4355), so it was removed — the delivery-
128
+ // schedule simulation in the decomposer prompt owns that judgment now.
123
129
  hardFiles: 30,
124
- maxAcceptance: 14,
125
130
  // Under-size (merge-candidate) thresholds (Story #4312). A Story with a
126
131
  // footprint at or below BOTH ceilings that also carries at least one
127
132
  // `depends_on` edge to a sibling looks like a dependent fragment rather than
@@ -528,17 +533,13 @@ function computeStorySizingFindings(story, sizing) {
528
533
  ),
529
534
  );
530
535
 
531
- // Acceptance ceiling + soft warn.
532
- if (acceptance.length > sizing.maxAcceptance) {
533
- out.push(
534
- makeOversized(
535
- story.slug,
536
- 'acceptance',
537
- acceptance.length,
538
- sizing.maxAcceptance,
539
- ),
540
- );
541
- } else if (acceptance.length > sizing.softAcceptanceCount) {
536
+ // Acceptance mass is advisory-only (Story #4312's under-size heuristic is
537
+ // the merge signal; the former hard `maxAcceptance` rejection is removed).
538
+ // A long binding contract is a re-check-cohesion nudge, never by itself a
539
+ // decomposition error — cohesion and the delivery envelope govern Story
540
+ // size, and the delivery-schedule simulation in the decomposer prompt owns
541
+ // the fragmentation/consolidation judgment.
542
+ if (acceptance.length > sizing.softAcceptanceCount) {
542
543
  out.push(
543
544
  makeSoftWidth(
544
545
  story.slug,
@@ -0,0 +1,75 @@
1
+ /**
2
+ * single-story-sweep/protection-ctx.js
3
+ *
4
+ * Shared builder for the `evaluateProtection` context the boot-sweep
5
+ * engine ([`sweepMergedBranches`](../single-story-sweep.js)) threads into
6
+ * every candidate protection check. Single-homed here so the three boot
7
+ * callers — `single-story-init.js`, `epic-deliver-prepare.js`, and the
8
+ * `boot-sweep.js` CLI — build an identical ctx instead of each re-wiring
9
+ * the git/gh/ticket ports.
10
+ *
11
+ * Story #2990: the sweep protection-ctx `ghRunner` stays on raw
12
+ * `spawnSync('gh', …)` (not the `lib/gh-exec.js` async facade) because
13
+ * `executeCleanup` invokes the protection checks inside a synchronous
14
+ * candidate-filter loop. The runner contract is the legacy
15
+ * `(args, opts) => stdout string` shape.
16
+ */
17
+
18
+ import { spawnSync as defaultSpawnSync } from 'node:child_process';
19
+ import { gitSpawn } from '../git-utils.js';
20
+
21
+ /**
22
+ * Build the synchronous `gh` runner the sweep uses for its
23
+ * candidate-protection checks.
24
+ *
25
+ * Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so
26
+ * the runner's success/error handling can be unit-tested without a live
27
+ * `gh` binary. It defaults to `child_process.spawnSync`, so the
28
+ * production CLI path is unchanged.
29
+ *
30
+ * @param {string} cwd Repo root used as the default spawn cwd.
31
+ * @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
32
+ * defaults to `child_process.spawnSync`.
33
+ * @returns {(args: string[], opts?: { cwd?: string }) => string}
34
+ */
35
+ export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
36
+ return (args, opts) => {
37
+ const result = spawnImpl('gh', args, {
38
+ cwd: opts?.cwd ?? cwd,
39
+ encoding: 'utf-8',
40
+ shell: false,
41
+ });
42
+ if (result.status !== 0) {
43
+ throw new Error(
44
+ `gh ${args.join(' ')} exit ${result.status}: ${result.stderr ?? ''}`,
45
+ );
46
+ }
47
+ return result.stdout ?? '';
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Build the `evaluateProtection` ctx bag: the repo root, the `gitSpawn`
53
+ * port, the synchronous `gh` runner, and a `getTicket` port bound to the
54
+ * supplied provider.
55
+ *
56
+ * @param {{
57
+ * cwd: string,
58
+ * provider: { getTicket: (id: number) => Promise<object> },
59
+ * spawnImpl?: typeof defaultSpawnSync,
60
+ * }} args
61
+ * @returns {{
62
+ * repoRoot: string,
63
+ * gitSpawn: typeof gitSpawn,
64
+ * ghRunner: (args: string[], opts?: { cwd?: string }) => string,
65
+ * getTicket: (id: number) => Promise<object>,
66
+ * }}
67
+ */
68
+ export function buildProtectionCtx({ cwd, provider, spawnImpl }) {
69
+ return {
70
+ repoRoot: cwd,
71
+ gitSpawn,
72
+ ghRunner: makeGhRunner(cwd, spawnImpl),
73
+ getTicket: (id) => provider.getTicket(id),
74
+ };
75
+ }
@@ -1,27 +1,41 @@
1
1
  /**
2
- * single-story-sweep.js — Sweep merged `story-*` branches at init.
2
+ * single-story-sweep.js — the scope-agnostic merged-branch sweep engine
3
+ * plus the `story-*` boot-sweep preset.
3
4
  *
4
- * Wraps `git-cleanup-branches.js` with a fixed policy tuned for the
5
- * `/single-story-deliver` boot path:
5
+ * `sweepMergedBranches` is the single reap engine every boot cleanup
6
+ * path routes through. It sits directly over the `git-cleanup` phase
7
+ * library (`planCleanup` / `executeCleanup` / `executeFastForward` /
8
+ * `buildGlobFilter`) and the shared `evaluateProtection` guard set, so
9
+ * no reap path re-implements the `git branch -D` / `git merge --ff-only`
10
+ * primitives:
6
11
  *
7
- * - Scope: `story-*` only (never touches `epic/*`, `story/<id>/*`, etc.).
8
- * - Mode: --execute --remote (delete local + origin + prune trackers).
9
- * - Skip: the current run's `storyBranch` is always excluded, even if a
10
- * stale PR for the same id were already merged.
12
+ * - Scope: caller-supplied `include` / `exclude` globs (via
13
+ * `buildGlobFilter`). The story preset pins `story-*`.
14
+ * - Reap: merged local branches whose PR HEAD SHA equals the merged
15
+ * `headRefOid`, deleted local + origin with tracking-ref
16
+ * prune (`executeCleanup` in `--remote` mode).
11
17
  * - Protection (Story #2011): each candidate is filtered through
12
18
  * `evaluateProtection` before reaching `executeCleanup`. A
13
19
  * candidate is protected (not reaped) when its branch HEAD
14
20
  * differs from the PR's `headRefOid` (unpushed work), when
15
21
  * its worktree has uncommitted edits, or when the parent
16
22
  * Story ticket is not in a terminal state. Protected
17
- * candidates surface in the result envelope under
18
- * `protected` so the operator can see what was skipped.
23
+ * candidates surface under `protected`.
24
+ * - Fast-forward (opt-in via `fastForward: true`): fast-forward the
25
+ * base branch through `executeFastForward` after the reap.
26
+ * Best-effort — a failed fast-forward never fails the sweep.
19
27
  * - Concurrency (Story #2011): the sweep acquires a process-scoped
20
28
  * lockfile around plan + execute. On lock contention the
21
- * sweep is skipped (init continues — same contract as a
29
+ * sweep is skipped (the host continues — same contract as a
22
30
  * plan failure).
23
- * - Errors are caught and surfaced in the envelope. The caller MUST NOT
24
- * propagate sweep failures story init proceeds either way.
31
+ * - Never touches the stash stack.
32
+ * - Errors are caught and surfaced in the envelope. Callers MUST NOT
33
+ * propagate sweep failures — the host proceeds either way.
34
+ *
35
+ * `sweepMergedStoryBranches` is a thin preset over the engine tuned for
36
+ * the boot path (`include: story-*`, `exclude: <current story branch>`,
37
+ * `fastForward: false`). Its exported name, signature, and result
38
+ * envelope are unchanged from the pre-engine implementation.
25
39
  *
26
40
  * Re-exports the same `planCleanup` / `executeCleanup` injection seams so
27
41
  * tests can stub git/`gh` without touching the CLI.
@@ -30,7 +44,9 @@
30
44
  import {
31
45
  buildGlobFilter,
32
46
  executeCleanup as defaultExecuteCleanup,
47
+ executeFastForward as defaultExecuteFastForward,
33
48
  planCleanup as defaultPlanCleanup,
49
+ planFastForward as defaultPlanFastForward,
34
50
  } from '../git-cleanup.js';
35
51
  import { evaluateProtection as defaultEvaluateProtection } from './single-story-sweep/protection.js';
36
52
  import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-sweep/sweep-lock.js';
@@ -38,15 +54,20 @@ import { acquireSweepLock as defaultAcquireSweepLock } from './single-story-swee
38
54
  const STORY_BRANCH_INCLUDE = 'story-*';
39
55
 
40
56
  /**
41
- * Sweep merged `story-*` branches in `cwd`.
57
+ * Scope-agnostic merged-branch sweep engine.
42
58
  *
43
59
  * @param {{
44
60
  * cwd: string,
45
61
  * baseBranch: string,
46
- * currentStoryBranch: string,
62
+ * include?: string[],
63
+ * exclude?: string[],
64
+ * fastForward?: boolean,
47
65
  * logger?: { info?: (m: string) => void, warn?: (m: string) => void },
66
+ * logTag?: string,
48
67
  * planCleanupFn?: typeof defaultPlanCleanup,
49
68
  * executeCleanupFn?: typeof defaultExecuteCleanup,
69
+ * planFastForwardFn?: typeof defaultPlanFastForward,
70
+ * executeFastForwardFn?: typeof defaultExecuteFastForward,
50
71
  * protectionFn?: typeof defaultEvaluateProtection,
51
72
  * protectionCtx?: object,
52
73
  * acquireLockFn?: typeof defaultAcquireSweepLock,
@@ -61,17 +82,23 @@ const STORY_BRANCH_INCLUDE = 'story-*';
61
82
  * remoteDeleted: number,
62
83
  * protected: Array<{ branch: string, reason: string, worktreePath?: string|null }>,
63
84
  * failures: Array<{ branch: string|null, scope: string, stderr?: string }>,
85
+ * fastForward?: object,
64
86
  * error?: string,
65
87
  * reason?: string,
66
88
  * }>}
67
89
  */
68
- export async function sweepMergedStoryBranches({
90
+ export async function sweepMergedBranches({
69
91
  cwd,
70
92
  baseBranch,
71
- currentStoryBranch,
93
+ include = ['*'],
94
+ exclude = [],
95
+ fastForward = false,
72
96
  logger = {},
97
+ logTag = '[sweep]',
73
98
  planCleanupFn = defaultPlanCleanup,
74
99
  executeCleanupFn = defaultExecuteCleanup,
100
+ planFastForwardFn = defaultPlanFastForward,
101
+ executeFastForwardFn = defaultExecuteFastForward,
75
102
  protectionFn = defaultEvaluateProtection,
76
103
  protectionCtx = null,
77
104
  acquireLockFn = defaultAcquireSweepLock,
@@ -92,16 +119,13 @@ export async function sweepMergedStoryBranches({
92
119
 
93
120
  // Optional lock acquisition. Skip silently when no lockPath is
94
121
  // supplied (e.g. unit tests, callers that opt out). Contention is
95
- // non-fatal — return a skipped result and let init continue.
122
+ // non-fatal — return a skipped result and let the host continue.
96
123
  let releaseLock = () => {};
97
124
  if (lockPath) {
98
- const lockResult = acquireLockFn({
99
- lockPath,
100
- timeoutMs: lockTimeoutMs,
101
- });
125
+ const lockResult = acquireLockFn({ lockPath, timeoutMs: lockTimeoutMs });
102
126
  if (!lockResult.acquired) {
103
127
  log.warn(
104
- `[single-story-sweep] lock not acquired (${lockResult.reason}${
128
+ `${logTag} lock not acquired (${lockResult.reason}${
105
129
  lockResult.detail ? `: ${lockResult.detail}` : ''
106
130
  }); skipping sweep.`,
107
131
  );
@@ -120,16 +144,28 @@ export async function sweepMergedStoryBranches({
120
144
  }
121
145
 
122
146
  try {
123
- return await runSweepUnderLock({
147
+ const reap = await runSweepUnderLock({
124
148
  cwd,
125
149
  baseBranch,
126
- currentStoryBranch,
150
+ include,
151
+ exclude,
127
152
  log,
153
+ logTag,
128
154
  planCleanupFn,
129
155
  executeCleanupFn,
130
156
  protectionFn,
131
157
  protectionCtx,
132
158
  });
159
+ if (!fastForward) return reap;
160
+ const ff = runFastForwardStep({
161
+ cwd,
162
+ baseBranch,
163
+ log,
164
+ logTag,
165
+ planFastForwardFn,
166
+ executeFastForwardFn,
167
+ });
168
+ return { ...reap, fastForward: ff };
133
169
  } finally {
134
170
  try {
135
171
  releaseLock();
@@ -139,41 +175,73 @@ export async function sweepMergedStoryBranches({
139
175
  }
140
176
  }
141
177
 
178
+ /**
179
+ * Sweep merged `story-*` branches in `cwd`. Preset over
180
+ * {@link sweepMergedBranches} for the boot path: it pins the `story-*`
181
+ * include glob, excludes the current run's `currentStoryBranch`, and
182
+ * keeps `fastForward` off (the boot caller fast-forwards the base branch
183
+ * separately). Exported name, signature, and result-envelope shape are
184
+ * unchanged from the pre-engine implementation.
185
+ *
186
+ * @param {{
187
+ * cwd: string,
188
+ * baseBranch: string,
189
+ * currentStoryBranch: string,
190
+ * logger?: { info?: (m: string) => void, warn?: (m: string) => void },
191
+ * planCleanupFn?: typeof defaultPlanCleanup,
192
+ * executeCleanupFn?: typeof defaultExecuteCleanup,
193
+ * protectionFn?: typeof defaultEvaluateProtection,
194
+ * protectionCtx?: object,
195
+ * acquireLockFn?: typeof defaultAcquireSweepLock,
196
+ * lockPath?: string|null,
197
+ * lockTimeoutMs?: number,
198
+ * }} args
199
+ * @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
200
+ */
201
+ export function sweepMergedStoryBranches(args = {}) {
202
+ const { currentStoryBranch } = args;
203
+ const exclude =
204
+ typeof currentStoryBranch === 'string' && currentStoryBranch.length > 0
205
+ ? [currentStoryBranch]
206
+ : [];
207
+ return sweepMergedBranches({
208
+ ...args,
209
+ include: [STORY_BRANCH_INCLUDE],
210
+ exclude,
211
+ fastForward: false,
212
+ logTag: '[single-story-sweep]',
213
+ });
214
+ }
215
+
142
216
  /**
143
217
  * Inner: the plan + protect + execute pipeline. Kept separate so the
144
- * outer `sweepMergedStoryBranches` can stay focused on the lock
145
- * acquire/release wrapper.
218
+ * outer engine can stay focused on the lock and fast-forward wrappers.
146
219
  */
147
220
  async function runSweepUnderLock({
148
221
  cwd,
149
222
  baseBranch,
150
- currentStoryBranch,
223
+ include,
224
+ exclude,
151
225
  log,
226
+ logTag,
152
227
  planCleanupFn,
153
228
  executeCleanupFn,
154
229
  protectionFn,
155
230
  protectionCtx,
156
231
  }) {
157
- const exclude =
158
- typeof currentStoryBranch === 'string' && currentStoryBranch.length > 0
159
- ? [currentStoryBranch]
160
- : [];
161
- const filter = buildGlobFilter({
162
- include: [STORY_BRANCH_INCLUDE],
163
- exclude,
164
- });
232
+ const filter = buildGlobFilter({ include, exclude });
165
233
 
166
234
  let plan;
167
235
  try {
168
236
  plan = planCleanupFn({ cwd, baseBranch, filter });
169
237
  } catch (err) {
170
238
  const msg = err?.message ?? String(err);
171
- log.warn(`[single-story-sweep] plan failed: ${msg}`);
239
+ log.warn(`${logTag} plan failed: ${msg}`);
172
240
  return zeroResult({ error: `plan: ${msg}` });
173
241
  }
174
242
 
175
243
  if (plan.candidates.length === 0) {
176
- log.info('[single-story-sweep] no merged story branches to reap.');
244
+ log.info(`${logTag} no merged branches to reap.`);
177
245
  return {
178
246
  ok: true,
179
247
  skipped: false,
@@ -190,11 +258,12 @@ async function runSweepUnderLock({
190
258
  protectionFn,
191
259
  protectionCtx,
192
260
  log,
261
+ logTag,
193
262
  });
194
263
 
195
264
  if (reapable.length === 0) {
196
265
  log.info(
197
- `[single-story-sweep] all ${plan.candidates.length} candidate(s) protected; no reap.`,
266
+ `${logTag} all ${plan.candidates.length} candidate(s) protected; no reap.`,
198
267
  );
199
268
  return {
200
269
  ok: true,
@@ -207,20 +276,41 @@ async function runSweepUnderLock({
207
276
  };
208
277
  }
209
278
 
279
+ return executeReap({
280
+ reapable,
281
+ protectedList,
282
+ candidateCount: plan.candidates.length,
283
+ cwd,
284
+ executeCleanupFn,
285
+ log,
286
+ logTag,
287
+ });
288
+ }
289
+
290
+ /**
291
+ * Execute the reap plan for the reapable candidates and shape the result
292
+ * envelope. Split out of {@link runSweepUnderLock} so each function keeps
293
+ * a single responsibility.
294
+ */
295
+ function executeReap({
296
+ reapable,
297
+ protectedList,
298
+ candidateCount,
299
+ cwd,
300
+ executeCleanupFn,
301
+ log,
302
+ logTag,
303
+ }) {
210
304
  let result;
211
305
  try {
212
- result = executeCleanupFn({
213
- candidates: reapable,
214
- cwd,
215
- remote: true,
216
- });
306
+ result = executeCleanupFn({ candidates: reapable, cwd, remote: true });
217
307
  } catch (err) {
218
308
  const msg = err?.message ?? String(err);
219
- log.warn(`[single-story-sweep] execute failed: ${msg}`);
309
+ log.warn(`${logTag} execute failed: ${msg}`);
220
310
  return {
221
311
  ok: false,
222
312
  skipped: false,
223
- candidates: plan.candidates.length,
313
+ candidates: candidateCount,
224
314
  localDeleted: 0,
225
315
  remoteDeleted: 0,
226
316
  protected: protectedList,
@@ -241,18 +331,18 @@ async function runSweepUnderLock({
241
331
  const summary = `${localDeleted} local + ${remoteDeleted} remote${protectedSummary}`;
242
332
  if (result.ok) {
243
333
  log.info(
244
- `[single-story-sweep] reaped ${summary}${reapedBranches ? ` [${reapedBranches}]` : ''}.`,
334
+ `${logTag} reaped ${summary}${reapedBranches ? ` [${reapedBranches}]` : ''}.`,
245
335
  );
246
336
  } else {
247
337
  log.warn(
248
- `[single-story-sweep] reaped ${summary} with ${result.failures.length} failure(s) — init continues.`,
338
+ `${logTag} reaped ${summary} with ${result.failures.length} failure(s) — host continues.`,
249
339
  );
250
340
  }
251
341
 
252
342
  return {
253
343
  ok: result.ok,
254
344
  skipped: false,
255
- candidates: plan.candidates.length,
345
+ candidates: candidateCount,
256
346
  localDeleted,
257
347
  remoteDeleted,
258
348
  protected: protectedList,
@@ -260,6 +350,44 @@ async function runSweepUnderLock({
260
350
  };
261
351
  }
262
352
 
353
+ /**
354
+ * Best-effort fast-forward of the base branch through the git-cleanup
355
+ * fast-forward phase. Never throws — a failed fast-forward is logged and
356
+ * returned as `{ ok: false, error }` but must never fail the sweep.
357
+ */
358
+ function runFastForwardStep({
359
+ cwd,
360
+ baseBranch,
361
+ log,
362
+ logTag,
363
+ planFastForwardFn,
364
+ executeFastForwardFn,
365
+ }) {
366
+ try {
367
+ const plan = planFastForwardFn({ cwd, baseBranch });
368
+ const ff = executeFastForwardFn({
369
+ cwd,
370
+ baseBranch,
371
+ plan,
372
+ logger: {
373
+ info: (m) => log.info(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
374
+ warn: (m) => log.warn(m.replace(/^\[git-cleanup\]\s*/, `${logTag} `)),
375
+ },
376
+ });
377
+ return {
378
+ ok: ff.ok !== false,
379
+ applied: !!ff.applied,
380
+ skipped: !!ff.skipped,
381
+ behind: ff.behind ?? null,
382
+ reason: ff.reason ?? null,
383
+ };
384
+ } catch (err) {
385
+ const msg = err?.message ?? String(err);
386
+ log.warn(`${logTag} fast-forward failed: ${msg}`);
387
+ return { ok: false, applied: false, skipped: false, error: msg };
388
+ }
389
+ }
390
+
263
391
  /**
264
392
  * Iterate plan candidates and split them into `reapable` (safe to pass
265
393
  * to executeCleanup) and `protectedList` (skipped, with a reason).
@@ -270,14 +398,15 @@ async function runSweepUnderLock({
270
398
  *
271
399
  * When no `protectionCtx` is supplied (legacy callers, unit tests),
272
400
  * the protection check is bypassed entirely and every candidate is
273
- * reapable. The CLI surface in `single-story-init.js` always supplies
274
- * a ctx, so this fallback never fires in production.
401
+ * reapable. The boot-path CLI surfaces always supply a ctx, so this
402
+ * fallback never fires in production.
275
403
  */
276
404
  async function partitionCandidates({
277
405
  candidates,
278
406
  protectionFn,
279
407
  protectionCtx,
280
408
  log,
409
+ logTag,
281
410
  }) {
282
411
  const reapable = [];
283
412
  const protectedList = [];
@@ -291,7 +420,7 @@ async function partitionCandidates({
291
420
  verdict = await protectionFn({ candidate, ctx: protectionCtx });
292
421
  } catch (err) {
293
422
  const reason = `protection-eval-error: ${err?.message ?? err}`;
294
- log.warn(`[single-story-sweep] protected ${candidate.branch}: ${reason}`);
423
+ log.warn(`${logTag} protected ${candidate.branch}: ${reason}`);
295
424
  protectedList.push({
296
425
  branch: candidate.branch,
297
426
  reason,
@@ -300,9 +429,7 @@ async function partitionCandidates({
300
429
  continue;
301
430
  }
302
431
  if (verdict?.protected) {
303
- log.info(
304
- `[single-story-sweep] protected ${candidate.branch}: ${verdict.reason}`,
305
- );
432
+ log.info(`${logTag} protected ${candidate.branch}: ${verdict.reason}`);
306
433
  protectedList.push({
307
434
  branch: candidate.branch,
308
435
  reason: verdict.reason ?? 'unknown',
@@ -50,8 +50,7 @@ export function renderDecomposerSystemPrompt({
50
50
  function render2TierPrompt({ maxTickets, maxTokenBudget, epicId = null }) {
51
51
  // Sizing thresholds are sourced from the single DEFAULT_TASK_SIZING constant
52
52
  // (ticket-validator-sizing.js) so the prompt and the validator cannot drift.
53
- const { softFiles, hardFiles, maxAcceptance, softAcceptanceCount } =
54
- DEFAULT_TASK_SIZING;
53
+ const { softFiles, hardFiles, softAcceptanceCount } = DEFAULT_TASK_SIZING;
55
54
  // Deliverable-granularity definition + single-consumer merge rule + the
56
55
  // soft envelope-floor heuristic are sourced from the single
57
56
  // DELIVERABLE_GRANULARITY_GUIDANCE constant (ticket-validator-sizing.js) so
@@ -177,7 +176,22 @@ ${envelopeFloor}
177
176
 
178
177
  - A Story touching more than **${softFiles} files** (\`softFiles\`) emits an advisory width finding — a nudge to check cohesion or declare \`wide\`.
179
178
  - A Story touching more than **${hardFiles} files** (\`hardFiles\`) is **rejected** unless it declares \`wide\` with a reason.
180
- - A Story with more than **${maxAcceptance} acceptance items** (\`maxAcceptance\`) is **rejected**; more than ${softAcceptanceCount} (\`softAcceptanceCount\`) emits an advisory warning.
179
+ - Acceptance mass is **advisory only**: more than **${softAcceptanceCount} acceptance items** (\`softAcceptanceCount\`) emits an advisory warning. There is NO hard acceptance ceiling a long binding contract is a signal to re-check cohesion, never a reason to fragment one coherent capability into dependent slices.
180
+
181
+ #### DELIVERY-SCHEDULE SIMULATION — the story count must earn itself:
182
+
183
+ Before emitting, simulate the delivery schedule your plan implies, and judge the plan by its schedule — not by how tidy the taxonomy looks:
184
+
185
+ 1. **Build the wave schedule.** A Story runs only after every \`depends_on\` completes, and two Stories that name the same file in \`changes[]\` cannot run in the same wave (the scheduler serializes file-overlapping Stories even when no \`depends_on\` edge links them).
186
+ 2. **Compute the parallelism yield**: story count ÷ critical-path length in waves. A yield near 1.0 means the plan is a serial chain — N Stories that deliver no faster than one Story while paying N delivery sessions (hydration, branch, PR, review, CI).
187
+ 3. **Every Story must earn its slot** by at least one of:
188
+ - **(a) parallelism** — it actually runs concurrently with a sibling in the schedule you just built ("logically independent" does not count; *schedule*-independent does);
189
+ - **(b) risk isolation** — it isolates a consumer-facing behavior change or high-risk cutover into its own reviewable, revertable unit;
190
+ - **(c) envelope pressure** — merged into its neighbor it would exceed the one-pass delivery envelope (\`maxTokenBudget\`).
191
+ 4. **A dependent link with none of those justifications merges into its consumer.** This generalizes the single-consumer merge rule from pairs to chains.
192
+ 5. **Hot-file rule.** When one file appears in the \`changes[]\` of more than a third of your Stories, the slicing axis cuts across a shared seam — merge the Stories that co-edit it, or re-slice along the seam so each Story owns its files.
193
+
194
+ End each Story's \`reason_to_exist\` with its justification letter and one clause, e.g. "… (a: runs in wave 1 alongside <slug>)" or "(b: isolates the auto-merge default change)". A reason that names only a topic ("config work", "docs") with no justification is a merge signal.
181
195
 
182
196
  #### \`wide\` DECLARATION (optional — for legitimately broad changes):
183
197