mandrel 1.85.0 → 1.87.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 (41) hide show
  1. package/.agents/instructions.md +7 -0
  2. package/.agents/rules/git-conventions.md +45 -0
  3. package/.agents/scripts/boot-sweep.js +215 -0
  4. package/.agents/scripts/epic-deliver-prepare.js +55 -0
  5. package/.agents/scripts/git-cleanup.js +8 -0
  6. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
  7. package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
  8. package/.agents/scripts/lib/framework-version.js +210 -0
  9. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
  10. package/.agents/scripts/lib/orchestration/epic-cleanup.js +330 -6
  11. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
  13. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
  14. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
  15. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
  16. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
  17. package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
  18. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
  19. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
  20. package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
  21. package/.agents/scripts/lib/single-story-sweep.js +239 -57
  22. package/.agents/scripts/lib/story-body/story-body.js +81 -4
  23. package/.agents/scripts/providers/github/tickets.js +18 -1
  24. package/.agents/scripts/single-story-init.js +7 -51
  25. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
  26. package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
  27. package/.agents/skills/skills.index.json +3 -3
  28. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
  29. package/.agents/workflows/git-cleanup.md +72 -18
  30. package/.agents/workflows/git-deliver.md +36 -0
  31. package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
  32. package/.agents/workflows/helpers/deliver-epic-reference.md +19 -13
  33. package/.agents/workflows/helpers/deliver-epic.md +47 -3
  34. package/.agents/workflows/helpers/deliver-stories.md +16 -3
  35. package/.agents/workflows/helpers/epic-audit.md +60 -2
  36. package/.agents/workflows/helpers/parallel-tooling.md +9 -2
  37. package/.agents/workflows/helpers/plan-epic.md +32 -14
  38. package/.agents/workflows/loops/nightly-audit.md +9 -1
  39. package/.agents/workflows/plan.md +32 -4
  40. package/docs/CHANGELOG.md +27 -0
  41. package/package.json +1 -1
@@ -0,0 +1,210 @@
1
+ // .agents/scripts/lib/framework-version.js
2
+ /**
3
+ * framework-version.js — single source of truth for the running Mandrel
4
+ * framework version and the ticket-body authoring stamp.
5
+ *
6
+ * Two concerns live here:
7
+ *
8
+ * 1. **Version resolution.** Under npm distribution the root `package.json`
9
+ * is the canonical version marker. {@link resolveFrameworkVersion} reads it
10
+ * and degrades to `'unknown'` (never throws) so a missing/unreadable
11
+ * manifest can never crash an authoring or hydration path. The private
12
+ * `getVersion()` in `lib/orchestration/context-hydration-engine.js`
13
+ * delegates here (DRY — one manifest reader).
14
+ *
15
+ * 2. **Ticket-body stamp.** Epics and Stories are stamped **once at authoring
16
+ * time** with the running version and the authoring date, via a hybrid
17
+ * surface:
18
+ * - a hidden machine-readable field in the trailing
19
+ * `<!-- meta: {"mandrel_version":"…","authored_at":"…"} -->` block
20
+ * (the source of truth, queryable by tooling), and
21
+ * - a single visible footer line
22
+ * `> 🏷️ Authored with Mandrel v<version> · <YYYY-MM-DD>` so a human
23
+ * reading the raw GitHub issue sees the provenance without any tooling.
24
+ *
25
+ * The stamp is **immutable**: {@link stampFrameworkVersion} is a no-op when
26
+ * the body already carries a `mandrel_version`, so a later re-render or
27
+ * Epic-body edit preserves the originally-authored version verbatim rather
28
+ * than bumping it to whatever version happens to be running.
29
+ *
30
+ * This module imports only Node builtins so it can be pulled in from the
31
+ * story-body serializer, the ticket provider, and the Epic ideation renderer
32
+ * without risking an import cycle.
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+
39
+ /** Returned when the package manifest is absent or unreadable. */
40
+ export const FALLBACK_VERSION = 'unknown';
41
+
42
+ /**
43
+ * Trailing machine-metadata comment block: `<!-- meta: {...} -->`. Mirrors the
44
+ * regex the Story-body parser uses so both surfaces recognise the same block.
45
+ */
46
+ const META_BLOCK_RE = /<!--\s*meta:\s*(\{[\s\S]*?\})\s*-->/;
47
+
48
+ /**
49
+ * The visible authoring marker line. A blockquote so GitHub renders it as a
50
+ * callout. Used both to detect an already-emitted marker (for the strip step)
51
+ * and, in the Story-body parser, to skip the line during section parsing so it
52
+ * never pollutes the last structured section.
53
+ */
54
+ export const AUTHORED_MARKER_LINE_RE = /^\s*>\s*🏷️\s+Authored with Mandrel\b/;
55
+
56
+ /**
57
+ * Compute the default path to the root `package.json`. This module ships inside
58
+ * the `mandrel` package at `<pkgRoot>/.agents/scripts/lib/framework-version.js`,
59
+ * so the manifest sits three directories up — the same layout in the dev repo
60
+ * and the published tarball.
61
+ *
62
+ * @returns {string}
63
+ */
64
+ function defaultPkgPath() {
65
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
66
+ return path.resolve(moduleDir, '../../..', 'package.json');
67
+ }
68
+
69
+ /**
70
+ * Resolve the running framework version from the root `package.json`. Degrades
71
+ * to {@link FALLBACK_VERSION} (never throws) on any read or parse failure so a
72
+ * missing/unreadable manifest can never crash an authoring or hydration path.
73
+ *
74
+ * @param {{ pkgPath?: string }} [opts] - `pkgPath` override (test seam).
75
+ * @returns {string}
76
+ */
77
+ export function resolveFrameworkVersion({ pkgPath } = {}) {
78
+ try {
79
+ const resolved = typeof pkgPath === 'string' ? pkgPath : defaultPkgPath();
80
+ const parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
81
+ return typeof parsed.version === 'string' && parsed.version.trim()
82
+ ? parsed.version.trim()
83
+ : FALLBACK_VERSION;
84
+ } catch {
85
+ return FALLBACK_VERSION;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Format an authoring date as `YYYY-MM-DD` (UTC). Matches the date shape the
91
+ * rest of the authoring path uses (e.g. `qa-session`).
92
+ *
93
+ * @param {Date} [date=new Date()]
94
+ * @returns {string}
95
+ */
96
+ export function formatAuthoredDate(date = new Date()) {
97
+ const d =
98
+ date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date();
99
+ return d.toISOString().slice(0, 10);
100
+ }
101
+
102
+ /**
103
+ * Build the visible authoring marker line for a given stamp. Centralised so
104
+ * the string is byte-identical across the two producers (the Story-body
105
+ * serializer and {@link stampFrameworkVersion}).
106
+ *
107
+ * @param {{ version: string, authoredAt: string }} stamp
108
+ * @returns {string}
109
+ */
110
+ export function authoredMarkerLine({ version, authoredAt }) {
111
+ return `> 🏷️ Authored with Mandrel v${version} · ${authoredAt}`;
112
+ }
113
+
114
+ /**
115
+ * Read the framework stamp from a body's trailing meta block. Returns
116
+ * `{ version, authoredAt }` when a non-empty `mandrel_version` is present, or
117
+ * `null` when the body carries no stamp (or the meta block is malformed).
118
+ * `authoredAt` is `null` when the version is present but the date is absent.
119
+ *
120
+ * @param {string} markdown
121
+ * @returns {{ version: string, authoredAt: string|null }|null}
122
+ */
123
+ export function extractFrameworkStamp(markdown) {
124
+ if (typeof markdown !== 'string') return null;
125
+ const match = markdown.match(META_BLOCK_RE);
126
+ if (!match) return null;
127
+ let parsed;
128
+ try {
129
+ parsed = JSON.parse(match[1]);
130
+ } catch {
131
+ return null;
132
+ }
133
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
134
+ return null;
135
+ }
136
+ const version =
137
+ typeof parsed.mandrel_version === 'string' && parsed.mandrel_version.trim()
138
+ ? parsed.mandrel_version.trim()
139
+ : null;
140
+ if (version === null) return null;
141
+ const authoredAt =
142
+ typeof parsed.authored_at === 'string' && parsed.authored_at.trim()
143
+ ? parsed.authored_at.trim()
144
+ : null;
145
+ return { version, authoredAt };
146
+ }
147
+
148
+ /**
149
+ * Stamp a ticket body (Epic or Story markdown) with the framework version and
150
+ * authoring date — **once**. When the body already carries a `mandrel_version`
151
+ * the body is returned verbatim (immutability: never re-derive or bump an
152
+ * already-authored stamp). Otherwise the version keys are merged into (or
153
+ * create) the trailing `<!-- meta -->` block — appended **last** so the key
154
+ * order stays stable with the Story-body serializer — and the visible marker
155
+ * line is (re)emitted just above it.
156
+ *
157
+ * The `version` / `authoredAt` overrides let a caller (e.g. the Epic edit path)
158
+ * preserve a previously-authored stamp; both default to the running version and
159
+ * today's date when omitted.
160
+ *
161
+ * @param {string} markdown
162
+ * @param {{ version?: string, authoredAt?: string }} [stamp]
163
+ * @returns {string}
164
+ */
165
+ export function stampFrameworkVersion(markdown, stamp = {}) {
166
+ const body = typeof markdown === 'string' ? markdown : '';
167
+
168
+ // Immutability: a body that already carries a version is preserved verbatim.
169
+ if (extractFrameworkStamp(body) !== null) return body;
170
+
171
+ const version =
172
+ typeof stamp?.version === 'string' && stamp.version.trim()
173
+ ? stamp.version.trim()
174
+ : resolveFrameworkVersion();
175
+ const authoredAt =
176
+ typeof stamp?.authoredAt === 'string' && stamp.authoredAt.trim()
177
+ ? stamp.authoredAt.trim()
178
+ : formatAuthoredDate();
179
+
180
+ // Merge into any existing (version-less) meta block, appending the version
181
+ // keys last for stable key order.
182
+ const metaMatch = body.match(META_BLOCK_RE);
183
+ const meta = {};
184
+ if (metaMatch) {
185
+ try {
186
+ const parsed = JSON.parse(metaMatch[1]);
187
+ if (
188
+ parsed !== null &&
189
+ typeof parsed === 'object' &&
190
+ !Array.isArray(parsed)
191
+ ) {
192
+ Object.assign(meta, parsed);
193
+ }
194
+ } catch {
195
+ // Malformed meta comment — drop it and re-emit a clean block.
196
+ }
197
+ }
198
+ meta.mandrel_version = version;
199
+ meta.authored_at = authoredAt;
200
+
201
+ // Strip any existing meta block / marker so both re-append canonically.
202
+ const head = body
203
+ .replace(META_BLOCK_RE, '')
204
+ .replace(new RegExp(AUTHORED_MARKER_LINE_RE.source, 'm'), '')
205
+ .replace(/\n{3,}/g, '\n\n')
206
+ .trimEnd();
207
+
208
+ const marker = authoredMarkerLine({ version, authoredAt });
209
+ return `${head}\n\n${marker}\n\n<!-- meta: ${JSON.stringify(meta)} -->`;
210
+ }
@@ -19,7 +19,6 @@
19
19
  import crypto from 'node:crypto';
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
- import { fileURLToPath } from 'node:url';
23
22
  import { getCommands } from '../config/commands.js';
24
23
  import {
25
24
  getLimits,
@@ -28,6 +27,7 @@ import {
28
27
  resolveConfig,
29
28
  } from '../config-resolver.js';
30
29
  import { sliceEpicBodyForDelivery } from '../epic-body-sections.js';
30
+ import { resolveFrameworkVersion } from '../framework-version.js';
31
31
  import { Logger } from '../Logger.js';
32
32
  import {
33
33
  buildEnvelope,
@@ -133,31 +133,16 @@ export function formatSkillCapsulesSection(entries) {
133
133
  // ---------------------------------------------------------------------------
134
134
 
135
135
  /**
136
- * Resolve the framework version from the installed package's `package.json`.
137
- *
138
- * Under npm distribution `package.json` is the single source of truth for the
139
- * framework version (the legacy plaintext version marker is retired). This
140
- * module ships inside the `mandrel` package at
141
- * `<pkgRoot>/.agents/scripts/lib/orchestration/context-hydration-engine.js`,
142
- * so the package manifest sits four directories up — the same layout in the
143
- * dev repo and in the published tarball. Read that manifest's `version`.
144
- *
145
- * Falls back to `'unknown'` when the manifest is absent or unreadable so a
146
- * missing package.json never crashes hydration.
136
+ * Resolve the framework version. Delegates to the shared
137
+ * {@link resolveFrameworkVersion} helper (single owner of the root
138
+ * `package.json` read) so the hydrator and the ticket-body stamp read the same
139
+ * source. Retained as a thin named wrapper so the mismatch-warning call sites
140
+ * below read against a stable local name.
147
141
  *
148
142
  * @returns {string}
149
143
  */
150
144
  function getVersion() {
151
- try {
152
- const moduleDir = path.dirname(fileURLToPath(import.meta.url));
153
- const pkgPath = path.resolve(moduleDir, '../../../..', 'package.json');
154
- const parsed = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
155
- return typeof parsed.version === 'string' && parsed.version.trim()
156
- ? parsed.version.trim()
157
- : 'unknown';
158
- } catch {
159
- return 'unknown';
160
- }
145
+ return resolveFrameworkVersion();
161
146
  }
162
147
 
163
148
  /**
@@ -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
 
@@ -148,6 +152,40 @@ export function findWorktreePathForBranch(branch, worktrees) {
148
152
  return null;
149
153
  }
150
154
 
155
+ /**
156
+ * Classify a `git branch -D <branch>` result. Pure — no IO. Exported as the
157
+ * clean unit-test seam for the not-found rule (this module carries a
158
+ * `node:coverage ignore file` header, so the git-side glue is not directly
159
+ * covered).
160
+ *
161
+ * Story #4393 — on the `/deliver` post-merge reap path a branch is routinely
162
+ * *already gone* (a prior sweep, a re-run, or GitHub's `--delete-branch`
163
+ * already dropped it). `git branch -D` then exits non-zero with a "not found"
164
+ * stderr. That is an **already-reaped success**, not a reap failure: counting
165
+ * it as a failure forces `reapEpicBranches().ok` false, which makes
166
+ * BranchCleaner classify `failed`, which flips the merged Epic to
167
+ * `agent::blocked` and reopens it. Only a not-found stderr is absorbed as
168
+ * success; every other non-zero exit stays a genuine failure.
169
+ *
170
+ * @param {{ status: number, stderr?: string }} branchDel
171
+ * @returns {{ branchDeleted: boolean, alreadyAbsent: boolean, stderr?: string }}
172
+ */
173
+ export function classifyBranchDeletion(branchDel) {
174
+ if (branchDel?.status === 0) {
175
+ return { branchDeleted: true, alreadyAbsent: false };
176
+ }
177
+ const stderr = (branchDel?.stderr ?? '').trim();
178
+ // git's own message for a missing ref: "error: branch 'foo' not found."
179
+ if (/\bnot found\b/i.test(stderr)) {
180
+ return { branchDeleted: true, alreadyAbsent: true };
181
+ }
182
+ return {
183
+ branchDeleted: false,
184
+ alreadyAbsent: false,
185
+ ...(stderr ? { stderr } : {}),
186
+ };
187
+ }
188
+
151
189
  /**
152
190
  * Reap a single branch. Best-effort worktree remove → fallback to `--force`
153
191
  * → fallback to filesystem rm → `git worktree prune` → `git branch -D`.
@@ -160,7 +198,7 @@ export function findWorktreePathForBranch(branch, worktrees) {
160
198
  * rmSyncFn?: (path: string, opts: object) => void,
161
199
  * logger?: { info?: Function, warn?: Function },
162
200
  * }} opts
163
- * @returns {{ branch: string, worktreeReaped: boolean, branchDeleted: boolean, method: string, stderr?: string }}
201
+ * @returns {{ branch: string, worktreeReaped: boolean, branchDeleted: boolean, alreadyAbsent: boolean, method: string, stderr?: string }}
164
202
  */
165
203
  export function reapBranch(opts) {
166
204
  const { branch, cwd, worktreePath, gitSpawn, rmSyncFn, logger } = opts;
@@ -197,16 +235,18 @@ export function reapBranch(opts) {
197
235
  gitSpawn(cwd, 'worktree', 'prune');
198
236
  }
199
237
 
200
- // Drop the local branch.
238
+ // Drop the local branch. An already-absent branch (git → "not found") is
239
+ // already reaped — classifyBranchDeletion absorbs it as success so a benign
240
+ // missing ref never counts as a reap failure (Story #4393).
201
241
  const branchDel = gitSpawn(cwd, 'branch', '-D', branch);
202
- const branchDeleted = branchDel.status === 0;
203
- const stderr =
204
- !branchDeleted && branchDel.stderr ? branchDel.stderr.trim() : undefined;
242
+ const { branchDeleted, alreadyAbsent, stderr } =
243
+ classifyBranchDeletion(branchDel);
205
244
 
206
245
  return {
207
246
  branch,
208
247
  worktreeReaped,
209
248
  branchDeleted,
249
+ alreadyAbsent,
210
250
  method: method ?? 'unknown',
211
251
  ...(stderr ? { stderr } : {}),
212
252
  };
@@ -328,6 +368,51 @@ export function deleteWtBranchIfPresent(opts) {
328
368
  return { deleted: false, present: true, stderr };
329
369
  }
330
370
 
371
+ /**
372
+ * Fast-forward the base branch to its remote after a confirmed merge, so a
373
+ * post-`/deliver` local checkout converges to `origin/<baseBranch>` without a
374
+ * manual `git pull`. Reuses `planFastForward` + `executeFastForward` from the
375
+ * git-cleanup phase library (the single source of the FF state machine),
376
+ * feeding them probes bound to the injected `gitSpawn`.
377
+ *
378
+ * @param {{
379
+ * cwd: string,
380
+ * baseBranch?: string,
381
+ * remoteName?: string,
382
+ * gitSpawn: (cwd: string, ...args: string[]) => { status: number, stdout: string, stderr: string },
383
+ * logger?: { info?: Function, warn?: Function },
384
+ * }} opts
385
+ * @returns {{ ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string }}
386
+ */
387
+ export function fastForwardBaseBranch(opts) {
388
+ const {
389
+ cwd,
390
+ baseBranch = 'main',
391
+ remoteName = 'origin',
392
+ gitSpawn,
393
+ logger,
394
+ } = opts;
395
+ const probe = makeFfProbes(gitSpawn);
396
+ const plan = planFastForward({
397
+ cwd,
398
+ baseBranch,
399
+ remoteName,
400
+ isCleanFn: probe.isClean,
401
+ currentBranchFn: probe.currentBranch,
402
+ fetchFn: probe.fetch,
403
+ canFastForwardFn: probe.canFastForward,
404
+ });
405
+ return executeFastForward({
406
+ cwd,
407
+ baseBranch,
408
+ remoteName,
409
+ plan,
410
+ checkoutFn: probe.checkout,
411
+ mergeFn: probe.merge,
412
+ ...(logger ? { logger } : {}),
413
+ });
414
+ }
415
+
331
416
  /**
332
417
  * Reap every branch owned by the Epic. Best-effort — failures aggregate into
333
418
  * the result rather than throwing.
@@ -350,6 +435,7 @@ export function deleteWtBranchIfPresent(opts) {
350
435
  * switched: { switched: boolean, from: string|null, to: string|null, stderr?: string } | null,
351
436
  * pruned: { pruned: string[], stderr?: string } | null,
352
437
  * wtBranch: { deleted: boolean, present: boolean, reason?: string, stderr?: string } | null,
438
+ * fastForward: { ok: boolean, applied: boolean, skipped: boolean, reason?: string, behind?: number, stderr?: string } | null,
353
439
  * epicBranchKept: boolean,
354
440
  * ok: boolean,
355
441
  * }}
@@ -375,6 +461,7 @@ export function reapEpicBranches(opts) {
375
461
  switched: null,
376
462
  pruned: null,
377
463
  wtBranch: null,
464
+ fastForward: null,
378
465
  epicBranchKept: false,
379
466
  ok: true,
380
467
  };
@@ -463,6 +550,26 @@ export function reapEpicBranches(opts) {
463
550
  logger?.info?.(`[epic-cleanup] deleted stale ${WT_SCRATCH_BRANCH} ref`);
464
551
  }
465
552
 
553
+ // After a confirmed merge (epic branch NOT kept), fast-forward the base
554
+ // branch so the local checkout converges to `origin/<baseBranch>` with no
555
+ // manual `git pull`. When the epic branch is kept (open PR), the merge is
556
+ // not confirmed, so the FF is skipped with an explicit reason rather than
557
+ // moving `main` under an in-flight PR.
558
+ const fastForward = epicHasOpenPr
559
+ ? { ok: true, applied: false, skipped: true, reason: 'epic-branch-kept' }
560
+ : fastForwardBaseBranch({
561
+ cwd,
562
+ baseBranch,
563
+ remoteName: remote,
564
+ gitSpawn,
565
+ logger,
566
+ });
567
+ if (fastForward.applied) {
568
+ logger?.info?.(
569
+ `[epic-cleanup] fast-forwarded ${baseBranch} by ${fastForward.behind} commit(s) to ${remote}/${baseBranch}`,
570
+ );
571
+ }
572
+
466
573
  const failures = reaped.filter((r) => !r.branchDeleted);
467
574
  return {
468
575
  epicId: state?.epicId ?? null,
@@ -471,7 +578,224 @@ export function reapEpicBranches(opts) {
471
578
  switched,
472
579
  pruned,
473
580
  wtBranch,
581
+ fastForward,
474
582
  epicBranchKept: epicHasOpenPr,
475
583
  ok: failures.length === 0,
476
584
  };
477
585
  }
586
+
587
+ /**
588
+ * Does a local branch head ref exist? Thin `git rev-parse --verify` wrapper
589
+ * exported for the resume-detect path (and its tests).
590
+ *
591
+ * @param {{ cwd: string, gitSpawn: Function, branch: string }} opts
592
+ * @returns {boolean}
593
+ */
594
+ export function localRefExists({ cwd, gitSpawn, branch }) {
595
+ const res = gitSpawn(
596
+ cwd,
597
+ 'rev-parse',
598
+ '--verify',
599
+ '--quiet',
600
+ `refs/heads/${branch}`,
601
+ );
602
+ return res.status === 0;
603
+ }
604
+
605
+ /**
606
+ * Probe whether the Epic branch's PR is MERGED and, if so, its URL (needed
607
+ * for the `epic.merge.armed` payload). Fails CLOSED to
608
+ * `{ merged: false, prUrl: null }` on any probe error — an indeterminate
609
+ * probe must never auto-arm a destructive reap.
610
+ *
611
+ * @param {{
612
+ * epicBranch: string,
613
+ * cwd: string,
614
+ * spawnFn?: typeof spawnSync,
615
+ * logger?: { warn?: Function },
616
+ * }} opts
617
+ * @returns {{ merged: boolean, prUrl: string|null }}
618
+ */
619
+ export function epicPrMergeState(opts) {
620
+ const { epicBranch, cwd, spawnFn = spawnSync, logger } = opts;
621
+ if (typeof epicBranch !== 'string' || epicBranch.length === 0) {
622
+ return { merged: false, prUrl: null };
623
+ }
624
+ let result;
625
+ try {
626
+ result = spawnFn(
627
+ 'gh',
628
+ [
629
+ 'pr',
630
+ 'list',
631
+ '--head',
632
+ epicBranch,
633
+ '--state',
634
+ 'merged',
635
+ '--json',
636
+ 'number,url,mergedAt',
637
+ '--limit',
638
+ '1',
639
+ ],
640
+ { cwd, encoding: 'utf-8', shell: false },
641
+ );
642
+ } catch (err) {
643
+ logger?.warn?.(
644
+ `[epic-cleanup] merged-PR probe threw for ${epicBranch} (treating as unmerged): ${err?.message ?? err}`,
645
+ );
646
+ return { merged: false, prUrl: null };
647
+ }
648
+ if (!result || result.status !== 0) {
649
+ logger?.warn?.(
650
+ `[epic-cleanup] merged-PR probe failed for ${epicBranch} (status=${result?.status}): ${(result?.stderr ?? '').trim()}`,
651
+ );
652
+ return { merged: false, prUrl: null };
653
+ }
654
+ let parsed;
655
+ try {
656
+ parsed = JSON.parse(String(result.stdout ?? '').trim() || '[]');
657
+ } catch {
658
+ return { merged: false, prUrl: null };
659
+ }
660
+ if (!Array.isArray(parsed) || parsed.length === 0) {
661
+ return { merged: false, prUrl: null };
662
+ }
663
+ const row = parsed[0];
664
+ const prUrl =
665
+ typeof row?.url === 'string' && row.url.length > 0 ? row.url : null;
666
+ // A merged-state row with no usable URL cannot arm (the schema requires a
667
+ // `prUrl`), so treat it as not-armable.
668
+ return { merged: prUrl !== null, prUrl };
669
+ }
670
+
671
+ /**
672
+ * Detect a merged-but-uncleaned Epic: the PR merged but one or more local
673
+ * `epic/<id>` / `story-<id>` refs still linger. This is the signal `/deliver`
674
+ * idempotent resume uses to auto-fire `epic.merge.armed` so Phase 9 reaps
675
+ * without a manual command. Pure given its injected ports.
676
+ *
677
+ * @param {{
678
+ * state: object|null,
679
+ * cwd: string,
680
+ * gitSpawn: Function,
681
+ * spawnFn?: Function,
682
+ * prMergeStateFn?: typeof epicPrMergeState,
683
+ * logger?: { warn?: Function, info?: Function },
684
+ * }} opts
685
+ * @returns {{
686
+ * epicId: number|null,
687
+ * epicBranch: string|null,
688
+ * presentRefs: string[],
689
+ * localRefsPresent: boolean,
690
+ * merged: boolean,
691
+ * prUrl: string|null,
692
+ * shouldArm: boolean,
693
+ * reason: string,
694
+ * }}
695
+ */
696
+ export function detectMergedUncleanedEpic(opts) {
697
+ const {
698
+ state,
699
+ cwd,
700
+ gitSpawn,
701
+ spawnFn,
702
+ prMergeStateFn = epicPrMergeState,
703
+ logger,
704
+ } = opts;
705
+ const { epicBranch, storyBranches } = listEpicBranchesFromState(state);
706
+ if (!epicBranch) {
707
+ return {
708
+ epicId: null,
709
+ epicBranch: null,
710
+ presentRefs: [],
711
+ localRefsPresent: false,
712
+ merged: false,
713
+ prUrl: null,
714
+ shouldArm: false,
715
+ reason: 'no-state',
716
+ };
717
+ }
718
+ const presentRefs = [epicBranch, ...storyBranches].filter((branch) =>
719
+ localRefExists({ cwd, gitSpawn, branch }),
720
+ );
721
+ if (presentRefs.length === 0) {
722
+ // Already clean — nothing to arm. This is the idempotent no-op that
723
+ // makes re-running `/deliver` on an already-reaped Epic safe.
724
+ return {
725
+ epicId: state.epicId,
726
+ epicBranch,
727
+ presentRefs,
728
+ localRefsPresent: false,
729
+ merged: false,
730
+ prUrl: null,
731
+ shouldArm: false,
732
+ reason: 'no-local-refs',
733
+ };
734
+ }
735
+ const { merged, prUrl } = prMergeStateFn({
736
+ epicBranch,
737
+ cwd,
738
+ spawnFn,
739
+ logger,
740
+ });
741
+ const shouldArm = merged && prUrl !== null;
742
+ return {
743
+ epicId: state.epicId,
744
+ epicBranch,
745
+ presentRefs,
746
+ localRefsPresent: true,
747
+ merged,
748
+ prUrl,
749
+ shouldArm,
750
+ reason: shouldArm ? 'merged-uncleaned' : 'not-merged',
751
+ };
752
+ }
753
+
754
+ /**
755
+ * `/deliver` idempotent-resume auto-arm: detect a merged-but-uncleaned Epic
756
+ * and, when found, fire `epic.merge.armed` on the injected lifecycle `bus` so
757
+ * the Cleaner → BranchCleaner chain reaps Phase 9 without an operator command.
758
+ * A no-op (and never throws on a clean/unmerged Epic) so re-running resume is
759
+ * safe.
760
+ *
761
+ * @param {{
762
+ * state: object|null,
763
+ * cwd: string,
764
+ * gitSpawn: Function,
765
+ * spawnFn?: Function,
766
+ * bus: { emit: (event: string, payload: object) => Promise<unknown> },
767
+ * detectFn?: typeof detectMergedUncleanedEpic,
768
+ * logger?: { warn?: Function, info?: Function },
769
+ * }} opts
770
+ * @returns {Promise<{ armed: boolean, reason: string, prUrl: string|null, detection: object }>}
771
+ */
772
+ export async function armCleanupIfMerged(opts) {
773
+ const { state, cwd, gitSpawn, spawnFn, bus, detectFn, logger } = opts;
774
+ if (!bus || typeof bus.emit !== 'function') {
775
+ throw new TypeError('armCleanupIfMerged requires a bus exposing emit()');
776
+ }
777
+ const detect = detectFn ?? detectMergedUncleanedEpic;
778
+ const detection = detect({ state, cwd, gitSpawn, spawnFn, logger });
779
+ if (!detection.shouldArm) {
780
+ return {
781
+ armed: false,
782
+ reason: detection.reason,
783
+ prUrl: detection.prUrl,
784
+ detection,
785
+ };
786
+ }
787
+ const payload = { prUrl: detection.prUrl };
788
+ if (Number.isInteger(detection.epicId) && detection.epicId > 0) {
789
+ payload.epicId = detection.epicId;
790
+ }
791
+ await bus.emit('epic.merge.armed', payload);
792
+ logger?.info?.(
793
+ `[epic-cleanup] resume auto-arm: fired epic.merge.armed for ${detection.epicBranch} (${detection.prUrl})`,
794
+ );
795
+ return {
796
+ armed: true,
797
+ reason: 'merged-uncleaned',
798
+ prUrl: detection.prUrl,
799
+ detection,
800
+ };
801
+ }