mandrel 1.86.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 (30) hide show
  1. package/.agents/instructions.md +7 -0
  2. package/.agents/rules/git-conventions.md +13 -1
  3. package/.agents/scripts/boot-sweep.js +36 -4
  4. package/.agents/scripts/git-cleanup.js +8 -0
  5. package/.agents/scripts/lib/checks/subagent-agent-tool-required.js +107 -30
  6. package/.agents/scripts/lib/epic-plan-ideation.js +24 -3
  7. package/.agents/scripts/lib/framework-version.js +210 -0
  8. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +7 -22
  9. package/.agents/scripts/lib/orchestration/epic-cleanup.js +41 -5
  10. package/.agents/scripts/lib/orchestration/epic-spec-reconciler-diff.js +34 -3
  11. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +102 -7
  12. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +85 -1
  13. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +34 -3
  14. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +71 -4
  15. package/.agents/scripts/lib/single-story-sweep.js +60 -5
  16. package/.agents/scripts/lib/story-body/story-body.js +81 -4
  17. package/.agents/scripts/providers/github/tickets.js +18 -1
  18. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -2
  19. package/.agents/skills/core/epic-plan-premortem/SKILL.md +8 -2
  20. package/.agents/skills/skills.index.json +3 -3
  21. package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +36 -8
  22. package/.agents/workflows/git-cleanup.md +72 -18
  23. package/.agents/workflows/helpers/acceptance-self-eval.md +23 -1
  24. package/.agents/workflows/helpers/deliver-epic.md +22 -3
  25. package/.agents/workflows/helpers/epic-audit.md +60 -2
  26. package/.agents/workflows/helpers/parallel-tooling.md +9 -2
  27. package/.agents/workflows/helpers/plan-epic.md +32 -14
  28. package/.agents/workflows/loops/nightly-audit.md +9 -1
  29. package/docs/CHANGELOG.md +15 -0
  30. package/package.json +1 -1
@@ -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
  /**
@@ -344,7 +344,91 @@ export function branchTipSha({
344
344
  return res.status !== 0 ? null : validSha(firstLsRemoteSha(res.stdout));
345
345
  }
346
346
 
347
- export const __testing = { validSha, firstLsRemoteSha };
347
+ /* node:coverage ignore next */
348
+ // Story #4395: ancestry-anchor freshness check. `planCleanup` calls this
349
+ // before unioning `git branch --merged origin/<base>` into the ancestry
350
+ // signal so a stale local `<base>` (fast-forward phase skipped or
351
+ // `--branches` run alone) doesn't hide a branch that's already merged on
352
+ // the remote.
353
+ export function refExists(cwd, ref) {
354
+ const res = gitSpawn(cwd, 'rev-parse', '--verify', '--quiet', ref);
355
+ return res.status === 0;
356
+ }
357
+
358
+ /* node:coverage ignore next */
359
+ // Story #4395: last-commit timestamp for the dry-run `not-merged`
360
+ // skip-visibility line (branch name + last-commit age).
361
+ export function branchLastCommitAt(cwd, branch) {
362
+ const res = gitSpawn(
363
+ cwd,
364
+ 'log',
365
+ '-1',
366
+ '--format=%cI',
367
+ `refs/heads/${branch}`,
368
+ '--',
369
+ );
370
+ if (res.status !== 0) return null;
371
+ return res.stdout.trim() || null;
372
+ }
373
+
374
+ /**
375
+ * First non-empty trimmed stdout line — the resulting tree OID on a clean
376
+ * `git merge-tree --write-tree` run.
377
+ *
378
+ * @param {string} stdout
379
+ * @returns {string}
380
+ */
381
+ function firstStdoutLine(stdout) {
382
+ const first = (stdout ?? '')
383
+ .split('\n')
384
+ .map((l) => l.trim())
385
+ .find(Boolean);
386
+ return first ?? '';
387
+ }
388
+
389
+ /**
390
+ * Probe content-equivalence between `base` and `branch` via
391
+ * `git merge-tree --write-tree <base> <branch>` (git >= 2.38, Story #4395).
392
+ *
393
+ * A clean merge (exit 0) whose resulting tree OID equals `<base>`'s own
394
+ * tree OID means applying `branch`'s changes on top of `base` is a no-op —
395
+ * `branch`'s content already lives in `base` by another route (a
396
+ * squash-merged Epic PR, a cherry-pick, a manual `merge --squash`) that
397
+ * neither the PR probe nor the ancestry check can see.
398
+ *
399
+ * Both the "unsupported" case (git < 2.38 rejects `--write-tree`) and the
400
+ * "real conflict" case (branch and base diverge and cannot auto-merge)
401
+ * surface as a non-zero exit. This probe treats them identically — the
402
+ * signal is inconclusive, so the caller keeps the branch's current
403
+ * `not-merged` classification rather than guessing.
404
+ *
405
+ * @param {{ cwd: string, base: string, branch: string, spawn?: typeof gitSpawn }} args
406
+ * @returns {{ supported: false } | { supported: true, equivalent: boolean }}
407
+ */
408
+ export function probeContentEquivalent({
409
+ cwd,
410
+ base,
411
+ branch,
412
+ spawn = gitSpawn,
413
+ }) {
414
+ const merged = spawn(cwd, 'merge-tree', '--write-tree', base, branch);
415
+ if (merged.status !== 0) return { supported: false };
416
+ const mergedTree = validSha(firstStdoutLine(merged.stdout));
417
+ if (!mergedTree) return { supported: false };
418
+ const baseTreeRes = spawn(
419
+ cwd,
420
+ 'rev-parse',
421
+ '--verify',
422
+ '--quiet',
423
+ `${base}^{tree}`,
424
+ );
425
+ if (baseTreeRes.status !== 0) return { supported: false };
426
+ const baseTree = validSha(baseTreeRes.stdout);
427
+ if (!baseTree) return { supported: false };
428
+ return { supported: true, equivalent: mergedTree === baseTree };
429
+ }
430
+
431
+ export const __testing = { validSha, firstLsRemoteSha, firstStdoutLine };
348
432
 
349
433
  /**
350
434
  * Pure-ish: classify a latest-PR probe row into a planner verdict.
@@ -190,6 +190,22 @@ export async function runPrunePhase(opts, cwd) {
190
190
  // Branch phase
191
191
  // =====================================================================
192
192
 
193
+ /**
194
+ * Count of a plan's *actionable* candidates — the ones `executeCleanup`
195
+ * will actually delete given the current `--remote` setting. Story #4395
196
+ * always enumerates remote-only candidates in `plan.candidates` (so the
197
+ * operator sees them in the dry-run list), but their deletion still
198
+ * requires `--remote`; without it, `executeCleanup` no-ops on every
199
+ * `localExists: false` candidate. Counting only the actionable subset
200
+ * keeps the "no-candidates" short-circuit and the confirmation prompt's
201
+ * "Reap N" count honest about what will actually happen.
202
+ */
203
+ function countActionableCandidates(candidates, remote) {
204
+ return remote
205
+ ? candidates.length
206
+ : candidates.filter((c) => c.localExists !== false).length;
207
+ }
208
+
193
209
  /**
194
210
  * Pure: decide what the branch-reap phase should do given the plan.
195
211
  *
@@ -209,7 +225,11 @@ export function decideBranchPhase(state) {
209
225
  if (opts.dryRun) {
210
226
  return { kind: 'dry-run', plan, result: { plan, result: null } };
211
227
  }
212
- if (plan.candidates.length === 0) {
228
+ const actionableCount = countActionableCandidates(
229
+ plan.candidates,
230
+ opts.remote,
231
+ );
232
+ if (actionableCount === 0) {
213
233
  return { kind: 'no-candidates', plan, result: { plan, result: null } };
214
234
  }
215
235
  const executeArgs = {
@@ -218,10 +238,17 @@ export function decideBranchPhase(state) {
218
238
  remote: opts.remote,
219
239
  };
220
240
  if (!opts.yes) {
241
+ const contentMergedCount = plan.candidates.filter(
242
+ (c) => c.detectedBy === 'content-merged',
243
+ ).length;
244
+ const weakSignalNote =
245
+ contentMergedCount > 0
246
+ ? ` (${contentMergedCount} content-merged — weaker signal, verify before confirming)`
247
+ : '';
221
248
  return {
222
249
  kind: 'prompt-then-execute',
223
250
  plan,
224
- promptMessage: `${TAG} Reap ${plan.candidates.length} merged branch(es)${opts.remote ? ' (including origin)' : ''}?`,
251
+ promptMessage: `${TAG} Reap ${actionableCount} merged branch(es)${opts.remote ? ' (including origin)' : ''}${weakSignalNote}?`,
225
252
  declinedResult: { plan, result: null, declined: true },
226
253
  executeArgs,
227
254
  };
@@ -254,11 +281,15 @@ export async function runBranchPhase(opts, cwd, baseBranch) {
254
281
  include: opts.include,
255
282
  exclude: opts.exclude,
256
283
  });
284
+ // Story #4395: always enumerate remote-only merged branches so the
285
+ // dry-run / prompt shows them without requiring `--remote`. Deletion of
286
+ // a remote-only candidate still requires `--remote` — `executeCleanup`
287
+ // no-ops on `localExists: false` candidates otherwise (unchanged).
257
288
  const plan = planCleanup({
258
289
  cwd,
259
290
  baseBranch,
260
291
  filter,
261
- includeRemoteOnly: opts.remote === true,
292
+ includeRemoteOnly: true,
262
293
  });
263
294
  emitDryRunHuman(plan, baseBranch);
264
295
  const action = decideBranchPhase({ plan, opts, cwd });
@@ -9,10 +9,61 @@
9
9
  */
10
10
 
11
11
  const TAG = '[git-cleanup]';
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+
14
+ /**
15
+ * Pure: format a last-commit ISO timestamp as a short relative-age string
16
+ * for the `not-merged` skip-visibility line (Story #4395). Returns
17
+ * `'unknown'` when `iso` is missing or unparseable — a branch whose commit
18
+ * date could not be resolved (e.g. `gh`-degraded run, deleted ref) still
19
+ * gets a line, just without an age.
20
+ *
21
+ * @param {string|null|undefined} iso
22
+ * @param {number} now Epoch-ms reference clock (injectable for tests).
23
+ * @returns {string}
24
+ */
25
+ function formatCommitAge(iso, now) {
26
+ if (!iso) return 'unknown';
27
+ const then = Date.parse(iso);
28
+ if (!Number.isFinite(then)) return 'unknown';
29
+ const days = Math.max(0, Math.floor((now - then) / DAY_MS));
30
+ if (days === 0) return 'today';
31
+ if (days === 1) return '1 day ago';
32
+ return `${days} days ago`;
33
+ }
34
+
35
+ /**
36
+ * Pure: render a single `not-merged` skip-visibility line (Story #4395).
37
+ * `renderDryRun` previously kept `not-merged` survivors silent; this
38
+ * surfaces each one with its last-commit age so the operator can see why
39
+ * a leftover branch isn't reaped instead of hunting for it by hand.
40
+ *
41
+ * @param {{ branch: string, reason: string, lastCommitAt?: string|null }} skip
42
+ * @param {{ now?: number }} [opts]
43
+ * @returns {string | null}
44
+ */
45
+ export function renderNotMergedSkipLine(skip, opts = {}) {
46
+ if (!skip || skip.reason !== 'not-merged') return null;
47
+ const now = opts.now ?? Date.now();
48
+ const age = formatCommitAge(skip.lastCommitAt, now);
49
+ return `${TAG} ⏭️ ${skip.branch} skipped — not merged (last commit: ${age})`;
50
+ }
51
+
52
+ /**
53
+ * Pure: render a single content-merged candidate annotation line
54
+ * (Story #4395). `content-merged` is a weaker signal than a merged PR or
55
+ * git ancestry — this note lets the operator tell it apart in both the
56
+ * dry-run list and the confirmation prompt.
57
+ */
58
+ function contentMergedNote(candidate) {
59
+ return candidate.detectedBy === 'content-merged'
60
+ ? ' (weaker signal — verify before deleting)'
61
+ : '';
62
+ }
12
63
 
13
64
  /** Pure: render the dry-run plan as the operator-facing text block. */
14
65
  export function renderDryRun(plan, opts = {}) {
15
- const { baseBranch = null } = opts;
66
+ const { baseBranch = null, now } = opts;
16
67
  const lines = [
17
68
  `${TAG} DRY RUN (nothing deleted) — ${plan.candidates.length} candidate(s)`,
18
69
  ];
@@ -23,7 +74,9 @@ export function renderDryRun(plan, opts = {}) {
23
74
  const pr = c.prNumber ? `PR #${c.prNumber}` : c.detectedBy;
24
75
  const wt = c.hasWorktree ? ` (worktree: ${c.worktreePath})` : '';
25
76
  const remoteOnly = c.localExists === false ? ' (remote-only)' : '';
26
- lines.push(` • ${c.branch} — ${pr}${wt}${remoteOnly}`);
77
+ lines.push(
78
+ ` • ${c.branch} — ${pr}${wt}${remoteOnly}${contentMergedNote(c)}`,
79
+ );
27
80
  }
28
81
  }
29
82
  const skipped = plan.skipped ?? [];
@@ -40,6 +93,15 @@ export function renderDryRun(plan, opts = {}) {
40
93
  const line = renderLatestPrSkipLine(skip);
41
94
  if (line) lines.push(line);
42
95
  }
96
+ for (const skip of skipped) {
97
+ const line = renderNotMergedSkipLine(skip, { now });
98
+ if (line) lines.push(line);
99
+ }
100
+ if (plan.ghDegraded) {
101
+ lines.push(
102
+ `${TAG} ⚠️ gh probe degraded — candidates rely on git-only signals (ancestry + content-equivalence) for this run`,
103
+ );
104
+ }
43
105
  return lines;
44
106
  }
45
107
 
@@ -47,7 +109,8 @@ export function renderDryRun(plan, opts = {}) {
47
109
  * Pure: render a single latest-PR-state skip line. Returns null when the
48
110
  * skip reason is not one of the latest-PR family — `renderDryRun` filters
49
111
  * by truthy return value so unrelated skip reasons (`protected`,
50
- * `current-head`, `filtered`, `not-merged`) stay quiet.
112
+ * `current-head`, `filtered`) stay quiet here. `not-merged` gets its own
113
+ * renderer ({@link renderNotMergedSkipLine}).
51
114
  *
52
115
  * @param {{ branch: string, reason: string, prNumber?: number, tipSha?: string, mergedSha?: string }} skip
53
116
  * @returns {string | null}
@@ -64,7 +127,10 @@ export function renderLatestPrSkipLine(skip) {
64
127
  if (skip.reason === 'tip-diverged-from-merge') {
65
128
  const tip = skip.tipSha ? skip.tipSha.slice(0, 7) : '<unknown>';
66
129
  const merged = skip.mergedSha ? skip.mergedSha.slice(0, 7) : '<unknown>';
67
- return `${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push)`;
130
+ return (
131
+ `${TAG} ⏭️ ${skip.branch} skipped — tip ${tip} diverges from ${prRef}'s merged ${merged} (post-merge force-push); ` +
132
+ `resolve by deleting manually (\`git branch -D ${skip.branch}\`) or pushing the follow-up commit`
133
+ );
68
134
  }
69
135
  if (skip.reason === 'latest-pr-unknown-state') {
70
136
  return `${TAG} ⏭️ ${skip.branch} skipped — ${prRef} has an unrecognized state`;
@@ -160,6 +226,7 @@ export function buildJsonEnvelope({
160
226
  baseBranch,
161
227
  candidates: plan.candidates,
162
228
  skipped: plan.skipped,
229
+ ghDegraded: plan.ghDegraded ?? false,
163
230
  worktrees: r.worktrees,
164
231
  local: r.local,
165
232
  remote: r.remote,