mandrel 1.85.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.
@@ -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',
@@ -35,7 +35,6 @@
35
35
  * @see .agents/workflows/helpers/single-story-deliver.md
36
36
  */
37
37
 
38
- import { spawnSync as defaultSpawnSync } from 'node:child_process';
39
38
  import { existsSync } from 'node:fs';
40
39
  import path from 'node:path';
41
40
  import { parseSprintArgs } from './lib/cli-args.js';
@@ -67,12 +66,18 @@ import {
67
66
  upsertStructuredComment,
68
67
  } from './lib/orchestration/ticketing.js';
69
68
  import { createProvider } from './lib/provider-factory.js';
69
+ import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
70
70
  // `sweepMergedStoryBranches` is imported dynamically below — its transitive
71
71
  // graph reaches `picomatch` (via `git-cleanup.js`). Loading it statically
72
72
  // would crash module resolution before `assertDepsInstalled()` can emit a
73
73
  // friendly "run npm install" message.
74
74
  import { WorktreeManager } from './lib/worktree-manager.js';
75
75
 
76
+ // `makeGhRunner` moved to the shared `single-story-sweep/protection-ctx.js`
77
+ // module (Story #4373) so the three boot callers build an identical
78
+ // protection ctx. Re-exported here to preserve its existing import path.
79
+ export { makeGhRunner } from './lib/single-story-sweep/protection-ctx.js';
80
+
76
81
  /**
77
82
  * Fail fast with a clear, actionable message when project deps are missing.
78
83
  * Uses only Node builtins so it stays loadable when `node_modules/` is empty.
@@ -97,50 +102,6 @@ function assertDepsInstalled(projectRoot) {
97
102
 
98
103
  const progress = Logger.createProgress('single-story-init', { stderr: true });
99
104
 
100
- /**
101
- * Build the synchronous `gh` runner the single-story sweep uses for its
102
- * candidate-protection checks. Exported for testing.
103
- *
104
- * Story #2990: the sweep protection-ctx ghRunner stays on raw
105
- * `spawnSync('gh', …)` (not the `lib/gh-exec.js` async facade) because
106
- * `executeCleanup` invokes the protection checks inside a synchronous
107
- * candidate-filter loop. The runner contract is the legacy
108
- * `(args, opts) => stdout string` shape; converting it to async would
109
- * ripple into the single-story-sweep planner, which is intentionally out
110
- * of scope for the callers-only provider migration.
111
- *
112
- * Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so the
113
- * runner's success/error handling can be unit-tested without a live `gh`
114
- * binary. It defaults to `child_process.spawnSync` (mirroring the
115
- * `spawnImpl` seam in `lib/gh-exec.js` and the `runner` seam in
116
- * `lib/bootstrap/gh-preflight.js`), so the production CLI path is unchanged.
117
- * The synchronous `spawnSync` shape is preserved deliberately — the
118
- * candidate-filter loop in `executeCleanup` is synchronous, so converting
119
- * this to the async `lib/gh-exec.js` facade would ripple into the
120
- * single-story-sweep planner.
121
- *
122
- * @param {string} cwd Repo root used as the default spawn cwd.
123
- * @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
124
- * defaults to `child_process.spawnSync`. Tests pass a fake to assert the
125
- * error/exit-code handling without spawning a real child process.
126
- * @returns {(args: string[], opts?: { cwd?: string }) => string}
127
- */
128
- export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
129
- return (args, opts) => {
130
- const result = spawnImpl('gh', args, {
131
- cwd: opts?.cwd ?? cwd,
132
- encoding: 'utf-8',
133
- shell: false,
134
- });
135
- if (result.status !== 0) {
136
- throw new Error(
137
- `gh ${args.join(' ')} exit ${result.status}: ${result.stderr ?? ''}`,
138
- );
139
- }
140
- return result.stdout ?? '';
141
- };
142
- }
143
-
144
105
  /**
145
106
  * Validate that the fetched ticket is a standalone Story this script can
146
107
  * deliver. Throws with the canonical operator-facing message otherwise.
@@ -222,12 +183,7 @@ export async function reapMergedStoryBranches({
222
183
  info: (m) => progress('CLEANUP', m),
223
184
  warn: (m) => progress('CLEANUP', `⚠️ ${m}`),
224
185
  },
225
- protectionCtx: {
226
- repoRoot: cwd,
227
- gitSpawn,
228
- ghRunner: makeGhRunner(cwd),
229
- getTicket: (id) => provider.getTicket(id),
230
- },
186
+ protectionCtx: buildProtectionCtx({ cwd, provider }),
231
187
  lockPath,
232
188
  lockTimeoutMs,
233
189
  });
@@ -70,6 +70,33 @@ it is about to run** before it acts.
70
70
 
71
71
  ---
72
72
 
73
+ ## Boot sweep
74
+
75
+ Before detecting the git setup, run the **protected boot sweep** so
76
+ `/git-deliver` starts from a tidy local checkout — a feature branch this
77
+ command opened and pushed on a prior run, once its PR has merged, is reaped
78
+ here rather than left to accumulate:
79
+
80
+ ```bash
81
+ node .agents/scripts/boot-sweep.js \
82
+ --include 'feat/*' --include 'fix/*' --include 'chore/*' \
83
+ --include 'docs/*' --include 'refactor/*' \
84
+ --current "$(git rev-parse --abbrev-ref HEAD)"
85
+ ```
86
+
87
+ This is the **safe subset** of the `/git-cleanup` phases: it fast-forwards the
88
+ base branch (`main`), prunes stale remote-tracking refs, and reaps every local
89
+ branch whose PR is **merged** and whose HEAD matches the merged `headRefOid`.
90
+ It **never** touches the stash stack, and its `evaluateProtection` partition
91
+ skips (never reaps) any candidate with unpushed work, a dirty worktree, or a
92
+ still-open parent ticket; `--current` always excludes the branch you are on.
93
+ The sweep is **silent on a no-op** — nothing merged, `main` already current →
94
+ one summary line (`[boot-sweep] reaped 0 local + 0 remote; protected 0.`). Its
95
+ exit code is always `0`; a failed sweep is reported in that summary, never
96
+ allowed to fail the delivery run.
97
+
98
+ ---
99
+
73
100
  ## Step 0 — Detect Git Setup & Resolve Level
74
101
 
75
102
  1. Resolve `[BASE_BRANCH]` from `--base` or `.agentrc.json` →
@@ -248,6 +275,15 @@ Do **not** poll CI. That is the `/deliver` Phase 7 job and is overkill for
248
275
  ad-hoc changes. The operator (or GitHub's email notification) is the next
249
276
  watcher.
250
277
 
278
+ > **Local ref left behind — reaped at the next boot.** At the **pr** level
279
+ > this command leaves a local feature branch behind after the PR merges; it
280
+ > does **not** reap it inline (the merge happens later, out of band). You do
281
+ > not need to run `/git-cleanup` by hand: the next time `/git-deliver` (or
282
+ > `/plan`) runs, its **Boot sweep** step reaps that merged ad-hoc branch and
283
+ > fast-forwards `main` automatically. The delivering flow owns tidying its own
284
+ > refs on the next boot — see
285
+ > [`.agents/rules/git-conventions.md` § Local checkout hygiene](../rules/git-conventions.md).
286
+
251
287
  ---
252
288
 
253
289
  ## Troubleshooting
@@ -509,20 +509,26 @@ node .agents/scripts/lifecycle-emit.js --epic <epicId> \
509
509
 
510
510
  If Phase 8.5 fell back to the operator-merges-button path (`gh pr
511
511
  merge --auto` was declined), the `epic.merge.armed` event never fires
512
- inside this run and Phase 9 will not run automatically. After the
513
- operator merges the PR, `epic/<epicId>` and each `story-<id>` ref can
514
- be reaped manually:
512
+ inside this run and Phase 9 will not run automatically. **Do not** hand-reap
513
+ the refs with a raw `git branch -D` sequence — drive the same
514
+ `BranchCleaner`-backed reap the auto-merge path uses by firing
515
+ `epic.merge.armed` after the operator merges the PR:
515
516
 
516
517
  ```bash
517
- git checkout main
518
- git pull --ff-only origin main
519
- git branch -D epic/<epicId>
520
- git branch -D story-<id1> story-<id2> ...
521
- git remote prune origin
518
+ node .agents/scripts/lifecycle-emit.js --epic <epicId> \
519
+ --event epic.merge.armed --pr-url <prUrl>
522
520
  ```
523
521
 
524
- Note that `git-cleanup.js` alone will not catch `story-<id>` refs in
525
- this case because the epic PR squash-merges break the `git branch
526
- --merged main` signal and the stories never had their own PRs. Wiring
527
- a CLI surface that drives the BranchCleaner listener for this
528
- fallback is tracked as follow-up to Story #2398.
522
+ That single emit reaps `epic/<epicId>` and every `story-<id>` ref from the
523
+ checkpoint, prunes stale tracking refs, and fast-forwards local `main` to
524
+ `origin/main` the whole Phase 9 reap, not a partial hand-roll. A plain
525
+ `git-cleanup.js` sweep alone will **not** catch the `story-<id>` refs here,
526
+ because the epic PR squash-merge breaks the `git branch --merged main` signal
527
+ and the stories never had their own PRs; the lifecycle-emit surface above is
528
+ the correct driver.
529
+
530
+ Re-running `/deliver <epicId>` reaches the same outcome without the manual
531
+ emit: the idempotent-resume auto-arm
532
+ (`detectMergedUncleanedEpic` → `armCleanupIfMerged` in
533
+ [`epic-cleanup.js`](../../scripts/lib/orchestration/epic-cleanup.js)) detects
534
+ the merged-but-uncleaned Epic and fires `epic.merge.armed` for you.
@@ -734,6 +734,18 @@ On an arming decision the predicate emits `epic.merge.ready`; the downstream
734
734
  `epic.merge.blocked` with the disqualifying reasons and exits without merging
735
735
  — the operator merges manually.
736
736
 
737
+ **Blocked-path output (operator merges the button).** When arming is
738
+ declined, `epic.merge.armed` never fires inside this run, so Phase 9 does not
739
+ reap automatically. Surface the exact one-liner the operator runs **after**
740
+ they merge the PR by hand so local refs are reaped and `main` is
741
+ fast-forwarded (the idempotent-resume path below runs this automatically on
742
+ the next `/deliver <epicId>`):
743
+
744
+ ```bash
745
+ node .agents/scripts/lifecycle-emit.js --epic <epicId> \
746
+ --event epic.merge.armed --pr-url <prUrl>
747
+ ```
748
+
737
749
  Close the phase wrapper by emitting `epic.automerge.end` (records the arm
738
750
  outcome on the ledger; `merged: true` once GitHub completes the squash,
739
751
  `merged: false` with a reason otherwise):
@@ -784,6 +796,19 @@ via `helpers/epic-deliver-story`'s own checkpointing). The PR from Phase 7 is
784
796
  updated in place on subsequent runs. The authoritative live view is
785
797
  the `epic-run-progress` structured comment.
786
798
 
799
+ **Resume auto-arm for a merged-but-uncleaned Epic.** When `/deliver` resumes
800
+ against an Epic whose PR already merged (operator merged the button in a prior
801
+ session) but whose local `epic/<id>` / `story-<id>` refs still linger, the
802
+ resume path detects the merged-but-uncleaned state
803
+ (`detectMergedUncleanedEpic` in
804
+ [`epic-cleanup.js`](../../scripts/lib/orchestration/epic-cleanup.js)) and fires
805
+ `epic.merge.armed` automatically so Phase 9 reaps — no manual command. The
806
+ detection is idempotent: an already-reaped Epic (no local refs) is a clean
807
+ no-op, and an unmerged Epic never arms. It resolves the merged PR's URL for the
808
+ required `epic.merge.armed` payload and fails closed (does **not** arm) on any
809
+ indeterminate `gh` probe. The one-liner under Phase 8.5 / Phase 9 is the manual
810
+ equivalent for the case where the operator does not re-run `/deliver`.
811
+
787
812
  ---
788
813
 
789
814
  ## Constraints
@@ -318,11 +318,24 @@ Print a final run summary listing every delivered Story in completion order:
318
318
  All Stories delivered. PRs opened, auto-merge armed. CI will merge each
319
319
  PR when checks pass; each child then confirms the merge and flips its
320
320
  Story to `agent::done` (Story #3385 — until the merge confirms, a Story
321
- rests at `agent::closing` with its issue OPEN). Run
322
- `git-cleanup --fast-forward-main` after the last merge to bring local
323
- main up to date.
321
+ rests at `agent::closing` with its issue OPEN).
324
322
  ```
325
323
 
324
+ Then **fast-forward `main` yourself** — do not instruct the operator to run it
325
+ after the last merge. The delivering flow owns bringing the local base branch
326
+ up to whatever has merged so far:
327
+
328
+ ```bash
329
+ node .agents/scripts/git-cleanup.js --fast-forward-main --execute --yes
330
+ ```
331
+
332
+ This runs only the fast-forward-main phase (`git fetch origin main` →
333
+ `git merge --ff-only`); it is idempotent and a no-op when `main` is already
334
+ current, so it is safe to run even while some PRs are still queued in
335
+ auto-merge. Report its one-line result in the summary. Merged Story branches
336
+ themselves are reaped by the boot sweep at the next `/plan` / `/deliver` boot —
337
+ see [`.agents/rules/git-conventions.md` § Local checkout hygiene](../../rules/git-conventions.md).
338
+
326
339
  When some Stories are blocked or failed, list them explicitly with the
327
340
  `blockerCommentId` or failure detail so the operator knows where to look.
328
341