mandrel 1.86.0 โ†’ 1.88.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 (40) hide show
  1. package/.agents/docs/SDLC.md +15 -3
  2. package/.agents/docs/configuration.md +2 -0
  3. package/.agents/instructions.md +7 -0
  4. package/.agents/rules/git-conventions.md +13 -1
  5. package/.agents/schemas/agentrc.schema.json +12 -0
  6. package/.agents/scripts/boot-sweep.js +36 -4
  7. package/.agents/scripts/git-cleanup.js +8 -0
  8. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
  9. package/.agents/scripts/lib/config/explain.js +4 -0
  10. package/.agents/scripts/lib/config/runners.js +13 -2
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  12. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  13. package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
  14. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +19 -5
  15. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +17 -0
  16. package/.agents/scripts/lib/framework-version.js +210 -0
  17. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
  18. package/.agents/scripts/lib/orchestration/epic-cleanup.js +41 -5
  19. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
  20. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
  21. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
  22. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
  23. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
  24. package/.agents/scripts/lib/single-story-sweep.js +60 -5
  25. package/.agents/scripts/lib/story-body/story-body.js +81 -4
  26. package/.agents/scripts/providers/github/tickets.js +18 -1
  27. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
  28. package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
  29. package/.agents/skills/skills.index.json +3 -3
  30. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
  31. package/.agents/workflows/git-cleanup.md +72 -18
  32. package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
  33. package/.agents/workflows/helpers/code-review.md +83 -7
  34. package/.agents/workflows/helpers/deliver-epic.md +46 -7
  35. package/.agents/workflows/helpers/epic-audit.md +153 -12
  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/docs/CHANGELOG.md +22 -0
  40. package/package.json +1 -1
@@ -104,6 +104,13 @@ export function buildIdempotencyMarker(epicId, index) {
104
104
  * filter ๐Ÿ”ด (Critical Blocker โ€” blocking) out; ๐ŸŸ /๐ŸŸก/๐ŸŸข are non-blocking
105
105
  * and graduate to follow-up issues.
106
106
  *
107
+ * Findings the Phase 5 focused-fix routing already fixed on-branch are
108
+ * rendered under a **"Fixed on-branch"** heading (Story #4399) with a โœ…
109
+ * prefix so they no longer parse as open findings. As a belt-and-suspenders
110
+ * guard the parser also skips every line inside a Fixed-on-branch section
111
+ * outright, so a remediated ๐ŸŸก Medium never spawns a ghost follow-up issue
112
+ * even if its line retains its original severity emoji.
113
+ *
107
114
  * @param {string} body
108
115
  * @returns {Array<{ severity: 'high'|'medium'|'low', path: string, summary: string, index: number }>}
109
116
  */
@@ -112,9 +119,19 @@ export function parseFindings(body) {
112
119
  const findings = [];
113
120
  const lines = body.split(/\r?\n/);
114
121
  let idx = 0;
122
+ let inFixedSection = false;
115
123
  for (const line of lines) {
116
124
  const trimmed = line.trim();
117
125
  if (trimmed.length === 0) continue;
126
+ // Any markdown heading resets the Fixed-on-branch guard; a
127
+ // "Fixed on-branch" heading opens a section whose entries never
128
+ // graduate (Story #4399).
129
+ const headingMatch = trimmed.match(/^#{1,6}\s+(.+)$/);
130
+ if (headingMatch) {
131
+ inFixedSection = /fixed on-branch/i.test(headingMatch[1]);
132
+ continue;
133
+ }
134
+ if (inFixedSection) continue;
118
135
  let severity = null;
119
136
  if (trimmed.startsWith('๐ŸŸ ')) severity = 'high';
120
137
  else if (trimmed.startsWith('๐ŸŸก')) severity = 'medium';
@@ -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
  /**
@@ -152,6 +152,40 @@ export function findWorktreePathForBranch(branch, worktrees) {
152
152
  return null;
153
153
  }
154
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
+
155
189
  /**
156
190
  * Reap a single branch. Best-effort worktree remove โ†’ fallback to `--force`
157
191
  * โ†’ fallback to filesystem rm โ†’ `git worktree prune` โ†’ `git branch -D`.
@@ -164,7 +198,7 @@ export function findWorktreePathForBranch(branch, worktrees) {
164
198
  * rmSyncFn?: (path: string, opts: object) => void,
165
199
  * logger?: { info?: Function, warn?: Function },
166
200
  * }} opts
167
- * @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 }}
168
202
  */
169
203
  export function reapBranch(opts) {
170
204
  const { branch, cwd, worktreePath, gitSpawn, rmSyncFn, logger } = opts;
@@ -201,16 +235,18 @@ export function reapBranch(opts) {
201
235
  gitSpawn(cwd, 'worktree', 'prune');
202
236
  }
203
237
 
204
- // 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).
205
241
  const branchDel = gitSpawn(cwd, 'branch', '-D', branch);
206
- const branchDeleted = branchDel.status === 0;
207
- const stderr =
208
- !branchDeleted && branchDel.stderr ? branchDel.stderr.trim() : undefined;
242
+ const { branchDeleted, alreadyAbsent, stderr } =
243
+ classifyBranchDeletion(branchDel);
209
244
 
210
245
  return {
211
246
  branch,
212
247
  worktreeReaped,
213
248
  branchDeleted,
249
+ alreadyAbsent,
214
250
  method: method ?? 'unknown',
215
251
  ...(stderr ? { stderr } : {}),
216
252
  };
@@ -81,6 +81,10 @@
81
81
  */
82
82
 
83
83
  import { composeStoryBody } from '../../providers/github/tickets.js';
84
+ import {
85
+ extractFrameworkStamp,
86
+ stampFrameworkVersion,
87
+ } from '../framework-version.js';
84
88
  import { assertPlanLabelAllowList } from './epic-spec-reconciler-discriminator.js';
85
89
  import {
86
90
  closeOp,
@@ -233,12 +237,21 @@ function stripFooter(body) {
233
237
  * `composeStoryBody` directly makes that divergence structurally
234
238
  * impossible going forward.
235
239
  *
240
+ * Story #4382 โ€” the create-time authoring stamp (`mandrel_version` /
241
+ * `authored_at` meta field + visible marker) is provenance, not spec-derived
242
+ * content, so this UPDATE path never mints or bumps it (which would also break
243
+ * this function's purity โ€” the stamp uses a clock). Instead the stamp already
244
+ * present on the **live** GH body (`obsBody`) is preserved verbatim, and a
245
+ * legacy stamp-less body is left stamp-less (no backfill โ€” see the Story's
246
+ * Out-of-Scope). Footer recomposition therefore runs in `stamp: false` mode.
247
+ *
236
248
  * @param {{entity: string, parentSlug?: string|null, dependsOn?: string[]}} specEntity
237
249
  * @param {string} specBody
238
250
  * @param {{state?: StateInput}} ctx
251
+ * @param {string} [obsBody] - The live GH body, source of the preserved stamp.
239
252
  * @returns {string}
240
253
  */
241
- function composeBodyWithFooter(specEntity, specBody, ctx) {
254
+ function composeBodyWithFooter(specEntity, specBody, ctx, obsBody = '') {
242
255
  const state = ctx?.state ?? {};
243
256
  const mapping = state.mapping ?? {};
244
257
  const parentSlug = specEntity.parentSlug ?? null;
@@ -264,7 +277,25 @@ function composeBodyWithFooter(specEntity, specBody, ctx) {
264
277
  // included) or emits a canonical-form body. With the strip, the
265
278
  // function is idempotent against its own output.
266
279
  const head = stripFooter(specBody);
267
- return composeStoryBody({ body: head, parentId, epicId, dependencies });
280
+ // Preserve the live body's create-time authoring stamp (Story #4382). When
281
+ // the spec head already carries it, `stampFrameworkVersion` is a no-op
282
+ // (immutable); when it doesn't, the stamp is restored from the live body so
283
+ // the recomposed form matches GH and no spurious body Update fires. A
284
+ // legacy body with no stamp stays stamp-less (no backfill).
285
+ const priorStamp = extractFrameworkStamp(obsBody);
286
+ const stampedHead = priorStamp
287
+ ? stampFrameworkVersion(head, {
288
+ version: priorStamp.version,
289
+ authoredAt: priorStamp.authoredAt,
290
+ })
291
+ : head;
292
+ return composeStoryBody({
293
+ body: stampedHead,
294
+ parentId,
295
+ epicId,
296
+ dependencies,
297
+ stamp: false,
298
+ });
268
299
  }
269
300
 
270
301
  /**
@@ -329,7 +360,7 @@ function fieldChanges(specEntity, obs, mapping, ctx = {}) {
329
360
  // Emit a body change only when the canonical form differs from
330
361
  // what is on GH today โ€” and write the canonical form back, so the
331
362
  // footer cascade-readers depend on stays intact across resumes.
332
- const after = composeBodyWithFooter(specEntity, specBody, ctx);
363
+ const after = composeBodyWithFooter(specEntity, specBody, ctx, obsBody);
333
364
  if (after !== obsBody) {
334
365
  changes.body = { before: obsBody, after };
335
366
  }
@@ -21,6 +21,7 @@ import {
21
21
  } from './branches-reap.js';
22
22
  import { computeProtectedReason } from './filters.js';
23
23
  import {
24
+ branchLastCommitAt,
24
25
  branchTipSha,
25
26
  classifyLatestPr,
26
27
  currentBranch as defaultCurrentBranch,
@@ -28,14 +29,18 @@ import {
28
29
  listMergedBranches,
29
30
  listRemoteBranches,
30
31
  probeAllPrs,
32
+ probeContentEquivalent,
31
33
  probeLatestPr,
32
34
  pruneRemoteTracking,
33
35
  readProtectedConfig,
36
+ refExists,
34
37
  removeWorktree,
35
38
  worktreesByBranch,
36
39
  } from './git-probes.js';
37
40
  import { parsePrunedRefs } from './prune.js';
38
41
 
42
+ const TAG = '[git-cleanup]';
43
+
39
44
  function skipEntryFromVerdict(branch, verdict) {
40
45
  const entry = { branch, reason: verdict.reason };
41
46
  if (verdict.prNumber != null) entry.prNumber = verdict.prNumber;
@@ -44,8 +49,37 @@ function skipEntryFromVerdict(branch, verdict) {
44
49
  return entry;
45
50
  }
46
51
 
52
+ /**
53
+ * Story #4395 โ€” the third detection signal. Reached only when the branch
54
+ * has no reapable PR verdict and is not an ancestor of `<base>` (or
55
+ * `origin/<base>`). Probes content-equivalence via `git merge-tree
56
+ * --write-tree` and, when the probe is conclusive and the merge is a
57
+ * no-op, classifies the branch as `content-merged` instead of falling
58
+ * through to the `not-merged` skip.
59
+ */
60
+ function evaluateContentEquivalence({
61
+ branch,
62
+ baseBranch,
63
+ cwd,
64
+ contentEquivalentFn,
65
+ branchLastCommitFn,
66
+ }) {
67
+ const verdict = contentEquivalentFn({ cwd, base: baseBranch, branch });
68
+ if (verdict?.supported && verdict.equivalent) {
69
+ return { detectedBy: 'content-merged' };
70
+ }
71
+ return {
72
+ skip: {
73
+ branch,
74
+ reason: 'not-merged',
75
+ lastCommitAt: branchLastCommitFn(cwd, branch),
76
+ },
77
+ };
78
+ }
79
+
47
80
  function evaluateLocalBranch({
48
81
  branch,
82
+ baseBranch,
49
83
  classify,
50
84
  filter,
51
85
  mergedByGit,
@@ -54,6 +88,8 @@ function evaluateLocalBranch({
54
88
  wtMap,
55
89
  remoteName,
56
90
  branchTipShaFn,
91
+ contentEquivalentFn,
92
+ branchLastCommitFn,
57
93
  }) {
58
94
  const protectedReason = classify(branch);
59
95
  if (protectedReason) return { skip: { branch, reason: protectedReason } };
@@ -78,7 +114,15 @@ function evaluateLocalBranch({
78
114
  } else if (mergedByGit.has(branch)) {
79
115
  detectedBy = 'git-merged';
80
116
  } else {
81
- return { skip: { branch, reason: 'not-merged' } };
117
+ const out = evaluateContentEquivalence({
118
+ branch,
119
+ baseBranch,
120
+ cwd,
121
+ contentEquivalentFn,
122
+ branchLastCommitFn,
123
+ });
124
+ if (out.skip) return out;
125
+ detectedBy = out.detectedBy;
82
126
  }
83
127
  const wt = wtMap.get(branch);
84
128
  return {
@@ -156,7 +200,43 @@ function collectRemoteOnlyCandidates({
156
200
  * the per-branch fallback for head refs absent from the bulk page (a PR
157
201
  * that fell outside the fetch window), so correctness is preserved for
158
202
  * every branch. Injecting `prProbe` bypasses the bulk fetch entirely.
203
+ *
204
+ * Story #4395 adds three refinements:
205
+ * - **Content-equivalence signal.** A local branch with no reapable PR
206
+ * verdict and no ancestry match gets one more chance via
207
+ * {@link probeContentEquivalent} (`git merge-tree --write-tree`):
208
+ * when merging it into `baseBranch` would be a content no-op, it is
209
+ * classified `detectedBy: 'content-merged'` instead of skipped.
210
+ * - **Fresh ancestry anchor.** The ancestry signal (`git branch --merged`)
211
+ * is unioned against `origin/<base>` (via `refExistsFn` + `mergedLister`)
212
+ * whenever that remote-tracking ref exists, so a stale local `<base>`
213
+ * no longer hides a branch already merged on the remote.
214
+ * - **Graceful `gh` degradation.** A throwing `gh` runner (auth failure,
215
+ * rate limit, missing binary) inside the bulk index fetch or the
216
+ * per-branch fallback is caught, logged once, and degrades to
217
+ * git-only signals (ancestry + content-equivalence) rather than
218
+ * aborting the whole plan. The returned envelope's `ghDegraded` flag
219
+ * records whether this happened.
159
220
  */
221
+ function buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade }) {
222
+ let prIndex;
223
+ try {
224
+ prIndex = prIndexFn(cwd);
225
+ } catch (err) {
226
+ onDegrade(err);
227
+ prIndex = new Map();
228
+ }
229
+ return (branch, c) => {
230
+ if (prIndex.has(branch)) return prIndex.get(branch);
231
+ try {
232
+ return prFallback(branch, c);
233
+ } catch (err) {
234
+ onDegrade(err);
235
+ return null;
236
+ }
237
+ };
238
+ }
239
+
160
240
  export function planCleanup(ctx) {
161
241
  const {
162
242
  cwd,
@@ -170,18 +250,26 @@ export function planCleanup(ctx) {
170
250
  prIndexFn = probeAllPrs,
171
251
  prFallback = probeLatestPr,
172
252
  branchTipShaFn = branchTipSha,
253
+ contentEquivalentFn = probeContentEquivalent,
254
+ branchLastCommitFn = branchLastCommitAt,
255
+ refExistsFn = refExists,
173
256
  filter = () => true,
174
257
  includeRemoteOnly = false,
175
258
  remoteLister = listRemoteBranches,
176
259
  remoteName = 'origin',
260
+ logger = Logger,
177
261
  } = ctx;
262
+ let ghDegraded = false;
263
+ const onDegrade = (err) => {
264
+ if (ghDegraded) return;
265
+ ghDegraded = true;
266
+ logger.warn?.(
267
+ `${TAG} โš ๏ธ gh probe failed (${err?.message ?? err}); continuing with git-only signals`,
268
+ );
269
+ };
178
270
  const prProbe =
179
271
  injectedPrProbe ??
180
- (() => {
181
- const prIndex = prIndexFn(cwd);
182
- return (branch, c) =>
183
- prIndex.has(branch) ? prIndex.get(branch) : prFallback(branch, c);
184
- })();
272
+ buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade });
185
273
  const resolvedCurrent = currentBranchFn(cwd);
186
274
  const resolvedConfigured = protectedConfigFn(cwd);
187
275
  const classify = (branch) =>
@@ -193,6 +281,10 @@ export function planCleanup(ctx) {
193
281
  });
194
282
  const wtMap = worktreesFn(cwd);
195
283
  const mergedByGit = new Set(mergedLister(cwd, baseBranch));
284
+ const remoteBaseRef = `${remoteName}/${baseBranch}`;
285
+ if (refExistsFn(cwd, remoteBaseRef)) {
286
+ for (const b of mergedLister(cwd, remoteBaseRef)) mergedByGit.add(b);
287
+ }
196
288
  const localBranches = localLister(cwd);
197
289
  const localSet = new Set(localBranches);
198
290
  const candidates = [];
@@ -200,6 +292,7 @@ export function planCleanup(ctx) {
200
292
  for (const branch of localBranches) {
201
293
  const out = evaluateLocalBranch({
202
294
  branch,
295
+ baseBranch,
203
296
  classify,
204
297
  filter,
205
298
  mergedByGit,
@@ -208,6 +301,8 @@ export function planCleanup(ctx) {
208
301
  wtMap,
209
302
  remoteName,
210
303
  branchTipShaFn,
304
+ contentEquivalentFn,
305
+ branchLastCommitFn,
211
306
  });
212
307
  if (out.skip) skipped.push(out.skip);
213
308
  else candidates.push(out.candidate);
@@ -227,7 +322,7 @@ export function planCleanup(ctx) {
227
322
  }),
228
323
  );
229
324
  }
230
- return { candidates, skipped };
325
+ return { candidates, skipped, ghDegraded };
231
326
  }
232
327
 
233
328
  /**