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.
@@ -120,6 +120,39 @@ rejected by `pre-push` hooks):
120
120
  (`--no-errors-on-unmatched` or equivalent) before escalating via
121
121
  `agent::blocked`.
122
122
 
123
+ ## Local checkout hygiene
124
+
125
+ **Invariant: the delivering flow owns tidying the local checkout — reaping its
126
+ own merged refs and fast-forwarding the base branch. `/git-cleanup` is a
127
+ recovery tool, not a routine chore.**
128
+
129
+ Every flow that lands work — `/deliver` (Epic and standalone-Story paths),
130
+ `/git-deliver` — is responsible for leaving the local checkout tidy without
131
+ operator intervention:
132
+
133
+ - **Fast-forwarding the base branch is owned by the flow.** The standalone
134
+ multi-Story path fast-forwards `main` itself in its summary phase (via
135
+ `git-cleanup.js --fast-forward-main --execute --yes`); the Epic path
136
+ fast-forwards `epic/<id>` / `main` on its merge-and-reap beat. No workflow
137
+ ends by telling the operator to "run `/git-cleanup` afterwards to catch up".
138
+ - **Reaping merged local refs is owned by the flow's next boot.** `/plan` and
139
+ `/git-deliver` open with a **protected boot sweep**
140
+ (`boot-sweep.js`) that fast-forwards `main`, prunes stale remote-tracking
141
+ refs, and reaps every local branch whose PR is already merged — skipping any
142
+ candidate with unpushed work, a dirty worktree, or a still-open parent
143
+ ticket. A branch a flow leaves behind (e.g. a `/git-deliver` feature branch
144
+ whose PR merges out of band) is therefore reaped automatically at the next
145
+ workflow boot, not left for the operator to sweep by hand.
146
+ - **`/git-cleanup` is recovery, not routine.** Run it by hand only to recover
147
+ an unusual state the automated hygiene does not cover — triaging stashes,
148
+ reaping across non-standard branch namespaces, or `--remote` pruning after a
149
+ force-push diverged a tip. It is **not** the expected way to keep `main`
150
+ current or to clear merged branches after a normal delivery; the delivering
151
+ flows already own that. If you find yourself reaching for `/git-cleanup`
152
+ after every routine `/deliver` or `/git-deliver` run, that is a signal the
153
+ owning flow's hygiene step regressed — fix the flow, do not codify the manual
154
+ sweep.
155
+
123
156
  ## Meta Labels (Retrospective Signal Routing)
124
157
 
125
158
  Two `meta::*` labels route retrospective signals into durable substrates so
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ /* node:coverage ignore file */
3
+
4
+ /**
5
+ * boot-sweep.js — protected boot-sweep CLI (Story #4373).
6
+ *
7
+ * A thin, non-interactive wrapper over the scope-agnostic
8
+ * [`sweepMergedBranches`](./lib/single-story-sweep.js) engine, exposed so
9
+ * workflow prose can invoke a *protected* boot sweep directly. Unlike the
10
+ * plain `git-cleanup.js --branches` phase (which reaps every merged
11
+ * candidate the planner surfaces), this surface always applies the
12
+ * `evaluateProtection` partition — a merged branch with unpushed work, a
13
+ * dirty worktree, or a still-open parent Story ticket is skipped, not
14
+ * reaped.
15
+ *
16
+ * The sweep is best-effort: any failure (lock contention, git/gh error)
17
+ * is swallowed and reported in the result envelope, never thrown, so a
18
+ * caller can wire it into a boot path without risking the host run.
19
+ *
20
+ * Usage:
21
+ * node .agents/scripts/boot-sweep.js [--include <glob>...] \
22
+ * [--exclude <glob>...] [--current <branch>] [--base <branch>] \
23
+ * [--no-fast-forward] [--json]
24
+ *
25
+ * Defaults: `--include story-*`, fast-forward the base branch on.
26
+ * Exit code is always 0 — a boot sweep never fails its host.
27
+ */
28
+
29
+ import path from 'node:path';
30
+ import { parseArgs } from 'node:util';
31
+
32
+ import { runAsCli } from './lib/cli-utils.js';
33
+ import { PROJECT_ROOT, resolveConfig } from './lib/config-resolver.js';
34
+ import { Logger } from './lib/Logger.js';
35
+ import { createProvider } from './lib/provider-factory.js';
36
+ import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
37
+ import { sweepMergedBranches } from './lib/single-story-sweep.js';
38
+
39
+ const HELP = `Usage: node .agents/scripts/boot-sweep.js [options]
40
+
41
+ Runs the protected merged-branch boot sweep non-interactively: reaps every
42
+ local branch whose PR is MERGED and whose HEAD matches the merged headRefOid,
43
+ skipping any candidate the protection partition flags (unpushed work, dirty
44
+ worktree, still-open parent Story), then fast-forwards the base branch.
45
+
46
+ Options:
47
+ --include <glob> Branch glob to sweep (repeatable). Default: story-*
48
+ --exclude <glob> Branch glob to exclude (repeatable).
49
+ --current <branch> A branch to always exclude (e.g. the active story).
50
+ --base <branch> Base branch to fast-forward. Default: project baseBranch.
51
+ --no-fast-forward Skip the base-branch fast-forward step.
52
+ --json Emit the result envelope as JSON.
53
+ `;
54
+
55
+ /**
56
+ * Run the protected boot sweep. Best-effort: swallows any error and
57
+ * returns the sweep envelope so no caller can be blocked by a failure.
58
+ *
59
+ * DI-friendly: `injectedConfig` / `injectedProvider` let a caller (e.g.
60
+ * `epic-deliver-prepare.js`) reuse an already-resolved config + provider,
61
+ * and `injectedSweep` swaps the engine for unit tests.
62
+ *
63
+ * @param {{
64
+ * cwd?: string,
65
+ * base?: string,
66
+ * include?: string[],
67
+ * exclude?: string[],
68
+ * current?: string,
69
+ * fastForward?: boolean,
70
+ * injectedConfig?: object,
71
+ * injectedProvider?: object,
72
+ * injectedSweep?: Function,
73
+ * logger?: { info?: Function, warn?: Function },
74
+ * }} [args]
75
+ * @returns {Promise<object>} the {@link sweepMergedBranches} envelope.
76
+ */
77
+ export async function runBootSweep({
78
+ cwd,
79
+ base,
80
+ include,
81
+ exclude,
82
+ current,
83
+ fastForward = true,
84
+ injectedConfig,
85
+ injectedProvider,
86
+ injectedSweep,
87
+ logger = Logger,
88
+ } = {}) {
89
+ const root = path.resolve(cwd ?? PROJECT_ROOT);
90
+ try {
91
+ // Config/provider resolution is inside the try so a malformed
92
+ // `.agentrc.json` (or a provider-construction throw) degrades to the
93
+ // swallowed `ok:false` envelope below rather than propagating and
94
+ // exiting non-zero — the "host continues, exit 0" boot-sweep contract
95
+ // must hold even when config resolution is the thing that fails.
96
+ const config = injectedConfig ?? resolveConfig({ cwd: root });
97
+ const provider = injectedProvider ?? createProvider(config);
98
+ const baseBranch = base ?? config.project?.baseBranch ?? 'main';
99
+
100
+ const includeGlobs =
101
+ Array.isArray(include) && include.length > 0 ? include : ['story-*'];
102
+ const excludeGlobs = Array.isArray(exclude) ? [...exclude] : [];
103
+ if (typeof current === 'string' && current.length > 0) {
104
+ excludeGlobs.push(current);
105
+ }
106
+
107
+ const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
108
+ const lockPath = path.resolve(root, tempRoot, 'boot-sweep.lock');
109
+ const lockTimeoutMs =
110
+ config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
111
+
112
+ const sweepFn = injectedSweep ?? sweepMergedBranches;
113
+ return await sweepFn({
114
+ cwd: root,
115
+ baseBranch,
116
+ include: includeGlobs,
117
+ exclude: excludeGlobs,
118
+ fastForward,
119
+ logTag: '[boot-sweep]',
120
+ logger: {
121
+ info: (m) => logger.info?.(m),
122
+ warn: (m) => logger.warn?.(m),
123
+ },
124
+ protectionCtx: buildProtectionCtx({ cwd: root, provider }),
125
+ lockPath,
126
+ lockTimeoutMs,
127
+ });
128
+ } catch (err) {
129
+ const msg = err?.message ?? String(err);
130
+ logger.warn?.(`[boot-sweep] sweep threw (host continues): ${msg}`);
131
+ return {
132
+ ok: false,
133
+ skipped: true,
134
+ error: msg,
135
+ candidates: 0,
136
+ localDeleted: 0,
137
+ remoteDeleted: 0,
138
+ protected: [],
139
+ failures: [],
140
+ };
141
+ }
142
+ }
143
+
144
+ async function main() {
145
+ const { values } = parseArgs({
146
+ options: {
147
+ base: { type: 'string' },
148
+ cwd: { type: 'string' },
149
+ include: { type: 'string', multiple: true, default: [] },
150
+ exclude: { type: 'string', multiple: true, default: [] },
151
+ current: { type: 'string' },
152
+ 'no-fast-forward': { type: 'boolean', default: false },
153
+ json: { type: 'boolean', default: false },
154
+ help: { type: 'boolean', short: 'h' },
155
+ },
156
+ strict: false,
157
+ });
158
+
159
+ if (values.help) {
160
+ Logger.info(HELP);
161
+ return;
162
+ }
163
+
164
+ const result = await runBootSweep({
165
+ cwd: typeof values.cwd === 'string' ? values.cwd : undefined,
166
+ base: typeof values.base === 'string' ? values.base : undefined,
167
+ include: Array.isArray(values.include) ? values.include : [],
168
+ exclude: Array.isArray(values.exclude) ? values.exclude : [],
169
+ current: typeof values.current === 'string' ? values.current : undefined,
170
+ fastForward: values['no-fast-forward'] !== true,
171
+ });
172
+
173
+ if (values.json) {
174
+ Logger.info(JSON.stringify(result, null, 2));
175
+ } else {
176
+ const protectedCount = result.protected?.length ?? 0;
177
+ Logger.info(
178
+ `[boot-sweep] reaped ${result.localDeleted} local + ${result.remoteDeleted} remote; protected ${protectedCount}.`,
179
+ );
180
+ }
181
+ }
182
+
183
+ runAsCli(import.meta.url, main, { source: 'boot-sweep' });
@@ -36,6 +36,7 @@ import fs from 'node:fs';
36
36
  import path from 'node:path';
37
37
  import { parseArgs } from 'node:util';
38
38
 
39
+ import { runBootSweep } from './boot-sweep.js';
39
40
  import { runAsCli } from './lib/cli-utils.js';
40
41
  import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
41
42
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
@@ -218,6 +219,49 @@ async function runPreflightGuardsForPrepare({
218
219
  });
219
220
  }
220
221
 
222
+ /**
223
+ * Route the Epic boot cleanup through the shared protected boot-sweep
224
+ * engine (Story #4373). Reaps merged, done `story-*` branches left over
225
+ * from prior runs — the protection partition skips any branch with
226
+ * unpushed work, a dirty worktree, or a still-open parent Story, so an
227
+ * in-flight Story is never touched. Fast-forward is off: the prepare may
228
+ * run on the Epic branch, and the fast-forward phase would otherwise
229
+ * check out the base branch.
230
+ *
231
+ * Best-effort — a sweep failure (lock contention, git/gh error) is
232
+ * swallowed and never blocks or fails the prepare. Skipped in the same
233
+ * injected-test shape the preflight guards use (a provider injected with
234
+ * no git seam) so unit tests never spawn real git/gh.
235
+ */
236
+ async function runBootSweepForPrepare({
237
+ cwd,
238
+ config,
239
+ provider,
240
+ injectedProvider,
241
+ injectedGit,
242
+ injectedSweep,
243
+ skipPreflightGuards,
244
+ }) {
245
+ const suppressed =
246
+ skipPreflightGuards || (Boolean(injectedProvider) && !injectedGit);
247
+ if (suppressed) return;
248
+ try {
249
+ await runBootSweep({
250
+ cwd,
251
+ include: ['story-*'],
252
+ fastForward: false,
253
+ injectedConfig: config,
254
+ injectedProvider: provider,
255
+ injectedSweep,
256
+ logger: Logger,
257
+ });
258
+ } catch (err) {
259
+ Logger.warn(
260
+ `[epic-deliver-prepare] ⚠️ boot sweep threw (prepare continues): ${err?.message ?? err}`,
261
+ );
262
+ }
263
+ }
264
+
221
265
  /**
222
266
  * Resolve the Epic state, preferring the preflight cache (Story #3027) and
223
267
  * falling back to a fresh snapshot + wave-DAG pass on miss or baseSha
@@ -333,6 +377,7 @@ export async function runEpicDeliverPrepare({
333
377
  steal = false,
334
378
  asOperator,
335
379
  injectedGit,
380
+ injectedSweep,
336
381
  leaseHeartbeatAt,
337
382
  leaseNow,
338
383
  skipPreflightGuards = false,
@@ -365,6 +410,16 @@ export async function runEpicDeliverPrepare({
365
410
  skipPreflightGuards,
366
411
  });
367
412
 
413
+ await runBootSweepForPrepare({
414
+ cwd,
415
+ config,
416
+ provider,
417
+ injectedProvider,
418
+ injectedGit,
419
+ injectedSweep,
420
+ skipPreflightGuards,
421
+ });
422
+
368
423
  const { state, cacheStatus } = await resolvePrepareState({
369
424
  epicId,
370
425
  cwd,
@@ -29,8 +29,12 @@
29
29
  */
30
30
 
31
31
  import { spawnSync } from 'node:child_process';
32
-
33
32
  import { parseWorktreePorcelain } from '../worktree/inspector.js';
33
+ import {
34
+ executeFastForward,
35
+ planFastForward,
36
+ } from './git-cleanup/phases/fast-forward.js';
37
+ import { makeFfProbes } from './git-cleanup/phases/git-probes-ff.js';
34
38
 
35
39
  const WT_SCRATCH_BRANCH = 'wt-branch';
36
40
 
@@ -328,6 +332,51 @@ export function deleteWtBranchIfPresent(opts) {
328
332
  return { deleted: false, present: true, stderr };
329
333
  }
330
334
 
335
+ /**
336
+ * Fast-forward the base branch to its remote after a confirmed merge, so a
337
+ * post-`/deliver` local checkout converges to `origin/<baseBranch>` without a
338
+ * manual `git pull`. Reuses `planFastForward` + `executeFastForward` from the
339
+ * git-cleanup phase library (the single source of the FF state machine),
340
+ * feeding them probes bound to the injected `gitSpawn`.
341
+ *
342
+ * @param {{
343
+ * cwd: string,
344
+ * baseBranch?: string,
345
+ * remoteName?: string,
346
+ * gitSpawn: (cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string },
347
+ * logger?: { info?: Function, warn?: Function },
348
+ * }} opts
349
+ * @returns {{ ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string }}
350
+ */
351
+ export function fastForwardBaseBranch(opts) {
352
+ const {
353
+ cwd,
354
+ baseBranch = 'main',
355
+ remoteName = 'origin',
356
+ gitSpawn,
357
+ logger,
358
+ } = opts;
359
+ const probe = makeFfProbes(gitSpawn);
360
+ const plan = planFastForward({
361
+ cwd,
362
+ baseBranch,
363
+ remoteName,
364
+ isCleanFn: probe.isClean,
365
+ currentBranchFn: probe.currentBranch,
366
+ fetchFn: probe.fetch,
367
+ canFastForwardFn: probe.canFastForward,
368
+ });
369
+ return executeFastForward({
370
+ cwd,
371
+ baseBranch,
372
+ remoteName,
373
+ plan,
374
+ checkoutFn: probe.checkout,
375
+ mergeFn: probe.merge,
376
+ ...(logger ? { logger } : {}),
377
+ });
378
+ }
379
+
331
380
  /**
332
381
  * Reap every branch owned by the Epic. Best-effort — failures aggregate into
333
382
  * the result rather than throwing.
@@ -350,6 +399,7 @@ export function deleteWtBranchIfPresent(opts) {
350
399
  * switched: { switched: boolean, from: string|null, to: string|null, stderr?: string } | null,
351
400
  * pruned: { pruned: string[], stderr?: string } | null,
352
401
  * wtBranch: { deleted: boolean, present: boolean, reason?: string, stderr?: string } | null,
402
+ * fastForward: { ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string } | null,
353
403
  * epicBranchKept: boolean,
354
404
  * ok: boolean,
355
405
  * }}
@@ -375,6 +425,7 @@ export function reapEpicBranches(opts) {
375
425
  switched: null,
376
426
  pruned: null,
377
427
  wtBranch: null,
428
+ fastForward: null,
378
429
  epicBranchKept: false,
379
430
  ok: true,
380
431
  };
@@ -463,6 +514,26 @@ export function reapEpicBranches(opts) {
463
514
  logger?.info?.(`[epic-cleanup] deleted stale ${WT_SCRATCH_BRANCH} ref`);
464
515
  }
465
516
 
517
+ // After a confirmed merge (epic branch NOT kept), fast-forward the base
518
+ // branch so the local checkout converges to `origin/<baseBranch>` with no
519
+ // manual `git pull`. When the epic branch is kept (open PR), the merge is
520
+ // not confirmed, so the FF is skipped with an explicit reason rather than
521
+ // moving `main` under an in-flight PR.
522
+ const fastForward = epicHasOpenPr
523
+ ? { ok: true, applied: false, skipped: true, reason: 'epic-branch-kept' }
524
+ : fastForwardBaseBranch({
525
+ cwd,
526
+ baseBranch,
527
+ remoteName: remote,
528
+ gitSpawn,
529
+ logger,
530
+ });
531
+ if (fastForward.applied) {
532
+ logger?.info?.(
533
+ `[epic-cleanup] fast-forwarded ${baseBranch} by ${fastForward.behind} commit(s) to ${remote}/${baseBranch}`,
534
+ );
535
+ }
536
+
466
537
  const failures = reaped.filter((r) => !r.branchDeleted);
467
538
  return {
468
539
  epicId: state?.epicId ?? null,
@@ -471,7 +542,224 @@ export function reapEpicBranches(opts) {
471
542
  switched,
472
543
  pruned,
473
544
  wtBranch,
545
+ fastForward,
474
546
  epicBranchKept: epicHasOpenPr,
475
547
  ok: failures.length === 0,
476
548
  };
477
549
  }
550
+
551
+ /**
552
+ * Does a local branch head ref exist? Thin `git rev-parse --verify` wrapper
553
+ * exported for the resume-detect path (and its tests).
554
+ *
555
+ * @param {{ cwd: string, gitSpawn: Function, branch: string }} opts
556
+ * @returns {boolean}
557
+ */
558
+ export function localRefExists({ cwd, gitSpawn, branch }) {
559
+ const res = gitSpawn(
560
+ cwd,
561
+ 'rev-parse',
562
+ '--verify',
563
+ '--quiet',
564
+ `refs/heads/${branch}`,
565
+ );
566
+ return res.status === 0;
567
+ }
568
+
569
+ /**
570
+ * Probe whether the Epic branch's PR is MERGED and, if so, its URL (needed
571
+ * for the `epic.merge.armed` payload). Fails CLOSED to
572
+ * `{ merged: false, prUrl: null }` on any probe error — an indeterminate
573
+ * probe must never auto-arm a destructive reap.
574
+ *
575
+ * @param {{
576
+ * epicBranch: string,
577
+ * cwd: string,
578
+ * spawnFn?: typeof spawnSync,
579
+ * logger?: { warn?: Function },
580
+ * }} opts
581
+ * @returns {{ merged: boolean, prUrl: string|null }}
582
+ */
583
+ export function epicPrMergeState(opts) {
584
+ const { epicBranch, cwd, spawnFn = spawnSync, logger } = opts;
585
+ if (typeof epicBranch !== 'string' || epicBranch.length === 0) {
586
+ return { merged: false, prUrl: null };
587
+ }
588
+ let result;
589
+ try {
590
+ result = spawnFn(
591
+ 'gh',
592
+ [
593
+ 'pr',
594
+ 'list',
595
+ '--head',
596
+ epicBranch,
597
+ '--state',
598
+ 'merged',
599
+ '--json',
600
+ 'number,url,mergedAt',
601
+ '--limit',
602
+ '1',
603
+ ],
604
+ { cwd, encoding: 'utf-8', shell: false },
605
+ );
606
+ } catch (err) {
607
+ logger?.warn?.(
608
+ `[epic-cleanup] merged-PR probe threw for ${epicBranch} (treating as unmerged): ${err?.message ?? err}`,
609
+ );
610
+ return { merged: false, prUrl: null };
611
+ }
612
+ if (!result || result.status !== 0) {
613
+ logger?.warn?.(
614
+ `[epic-cleanup] merged-PR probe failed for ${epicBranch} (status=${result?.status}): ${(result?.stderr ?? '').trim()}`,
615
+ );
616
+ return { merged: false, prUrl: null };
617
+ }
618
+ let parsed;
619
+ try {
620
+ parsed = JSON.parse(String(result.stdout ?? '').trim() || '[]');
621
+ } catch {
622
+ return { merged: false, prUrl: null };
623
+ }
624
+ if (!Array.isArray(parsed) || parsed.length === 0) {
625
+ return { merged: false, prUrl: null };
626
+ }
627
+ const row = parsed[0];
628
+ const prUrl =
629
+ typeof row?.url === 'string' && row.url.length > 0 ? row.url : null;
630
+ // A merged-state row with no usable URL cannot arm (the schema requires a
631
+ // `prUrl`), so treat it as not-armable.
632
+ return { merged: prUrl !== null, prUrl };
633
+ }
634
+
635
+ /**
636
+ * Detect a merged-but-uncleaned Epic: the PR merged but one or more local
637
+ * `epic/<id>` / `story-<id>` refs still linger. This is the signal `/deliver`
638
+ * idempotent resume uses to auto-fire `epic.merge.armed` so Phase 9 reaps
639
+ * without a manual command. Pure given its injected ports.
640
+ *
641
+ * @param {{
642
+ * state: object|null,
643
+ * cwd: string,
644
+ * gitSpawn: Function,
645
+ * spawnFn?: Function,
646
+ * prMergeStateFn?: typeof epicPrMergeState,
647
+ * logger?: { warn?: Function, info?: Function },
648
+ * }} opts
649
+ * @returns {{
650
+ * epicId: number|null,
651
+ * epicBranch: string|null,
652
+ * presentRefs: string[],
653
+ * localRefsPresent: boolean,
654
+ * merged: boolean,
655
+ * prUrl: string|null,
656
+ * shouldArm: boolean,
657
+ * reason: string,
658
+ * }}
659
+ */
660
+ export function detectMergedUncleanedEpic(opts) {
661
+ const {
662
+ state,
663
+ cwd,
664
+ gitSpawn,
665
+ spawnFn,
666
+ prMergeStateFn = epicPrMergeState,
667
+ logger,
668
+ } = opts;
669
+ const { epicBranch, storyBranches } = listEpicBranchesFromState(state);
670
+ if (!epicBranch) {
671
+ return {
672
+ epicId: null,
673
+ epicBranch: null,
674
+ presentRefs: [],
675
+ localRefsPresent: false,
676
+ merged: false,
677
+ prUrl: null,
678
+ shouldArm: false,
679
+ reason: 'no-state',
680
+ };
681
+ }
682
+ const presentRefs = [epicBranch, ...storyBranches].filter((branch) =>
683
+ localRefExists({ cwd, gitSpawn, branch }),
684
+ );
685
+ if (presentRefs.length === 0) {
686
+ // Already clean — nothing to arm. This is the idempotent no-op that
687
+ // makes re-running `/deliver` on an already-reaped Epic safe.
688
+ return {
689
+ epicId: state.epicId,
690
+ epicBranch,
691
+ presentRefs,
692
+ localRefsPresent: false,
693
+ merged: false,
694
+ prUrl: null,
695
+ shouldArm: false,
696
+ reason: 'no-local-refs',
697
+ };
698
+ }
699
+ const { merged, prUrl } = prMergeStateFn({
700
+ epicBranch,
701
+ cwd,
702
+ spawnFn,
703
+ logger,
704
+ });
705
+ const shouldArm = merged && prUrl !== null;
706
+ return {
707
+ epicId: state.epicId,
708
+ epicBranch,
709
+ presentRefs,
710
+ localRefsPresent: true,
711
+ merged,
712
+ prUrl,
713
+ shouldArm,
714
+ reason: shouldArm ? 'merged-uncleaned' : 'not-merged',
715
+ };
716
+ }
717
+
718
+ /**
719
+ * `/deliver` idempotent-resume auto-arm: detect a merged-but-uncleaned Epic
720
+ * and, when found, fire `epic.merge.armed` on the injected lifecycle `bus` so
721
+ * the Cleaner → BranchCleaner chain reaps Phase 9 without an operator command.
722
+ * A no-op (and never throws on a clean/unmerged Epic) so re-running resume is
723
+ * safe.
724
+ *
725
+ * @param {{
726
+ * state: object|null,
727
+ * cwd: string,
728
+ * gitSpawn: Function,
729
+ * spawnFn?: Function,
730
+ * bus: { emit: (event: string, payload: object) => Promise<unknown> },
731
+ * detectFn?: typeof detectMergedUncleanedEpic,
732
+ * logger?: { warn?: Function, info?: Function },
733
+ * }} opts
734
+ * @returns {Promise<{ armed: boolean, reason: string, prUrl: string|null, detection: object }>}
735
+ */
736
+ export async function armCleanupIfMerged(opts) {
737
+ const { state, cwd, gitSpawn, spawnFn, bus, detectFn, logger } = opts;
738
+ if (!bus || typeof bus.emit !== 'function') {
739
+ throw new TypeError('armCleanupIfMerged requires a bus exposing emit()');
740
+ }
741
+ const detect = detectFn ?? detectMergedUncleanedEpic;
742
+ const detection = detect({ state, cwd, gitSpawn, spawnFn, logger });
743
+ if (!detection.shouldArm) {
744
+ return {
745
+ armed: false,
746
+ reason: detection.reason,
747
+ prUrl: detection.prUrl,
748
+ detection,
749
+ };
750
+ }
751
+ const payload = { prUrl: detection.prUrl };
752
+ if (Number.isInteger(detection.epicId) && detection.epicId > 0) {
753
+ payload.epicId = detection.epicId;
754
+ }
755
+ await bus.emit('epic.merge.armed', payload);
756
+ logger?.info?.(
757
+ `[epic-cleanup] resume auto-arm: fired epic.merge.armed for ${detection.epicBranch} (${detection.prUrl})`,
758
+ );
759
+ return {
760
+ armed: true,
761
+ reason: 'merged-uncleaned',
762
+ prUrl: detection.prUrl,
763
+ detection,
764
+ };
765
+ }