mandrel 2.53.0 → 2.55.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 (114) hide show
  1. package/.agents/agents/story-worker.md +24 -23
  2. package/.agents/audit-checklists/accessibility.md +0 -3
  3. package/.agents/audit-checklists/mobile.md +0 -4
  4. package/.agents/docs/agentrc-reference.json +4 -2
  5. package/.agents/docs/configuration.md +2 -0
  6. package/.agents/schemas/agentrc.schema.json +15 -1
  7. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +2 -1
  8. package/.agents/schemas/story-deliver-terminal.schema.json +1 -0
  9. package/.agents/scripts/audit-to-stories.js +158 -7
  10. package/.agents/scripts/check-audit-attribution.js +119 -62
  11. package/.agents/scripts/check-test-portability.js +512 -0
  12. package/.agents/scripts/coverage-capture.js +17 -10
  13. package/.agents/scripts/evidence-gate.js +31 -4
  14. package/.agents/scripts/generate-workflows-doc.js +65 -14
  15. package/.agents/scripts/git-cleanup.js +4 -0
  16. package/.agents/scripts/lib/ITicketingProvider.js +78 -0
  17. package/.agents/scripts/lib/audit-advisories.js +195 -0
  18. package/.agents/scripts/lib/audit-attribution.js +22 -0
  19. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +68 -5
  20. package/.agents/scripts/lib/audit-to-stories/issue-index.js +83 -0
  21. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +60 -114
  22. package/.agents/scripts/lib/audit-to-stories/ledger-pr.js +347 -0
  23. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +169 -44
  24. package/.agents/scripts/lib/baselines/merge-envelopes.js +298 -32
  25. package/.agents/scripts/lib/bootstrap/baseline-merge-driver.js +180 -14
  26. package/.agents/scripts/lib/cli-args.js +26 -0
  27. package/.agents/scripts/lib/close-validation/gates.js +113 -7
  28. package/.agents/scripts/lib/close-validation/process.js +7 -3
  29. package/.agents/scripts/lib/close-validation/runner.js +62 -11
  30. package/.agents/scripts/lib/config/ci.js +28 -9
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +7 -0
  32. package/.agents/scripts/lib/config-settings-schema.js +19 -1
  33. package/.agents/scripts/lib/coverage-capture-fullscope.js +23 -11
  34. package/.agents/scripts/lib/coverage-capture-incremental.js +22 -16
  35. package/.agents/scripts/lib/coverage-capture-usage.js +5 -1
  36. package/.agents/scripts/lib/coverage-capture.js +77 -3
  37. package/.agents/scripts/lib/findings/route-finding.js +4 -2
  38. package/.agents/scripts/lib/full-suite-lock.js +232 -6
  39. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  40. package/.agents/scripts/lib/git/sync-from-base.js +130 -13
  41. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  42. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +10 -2
  43. package/.agents/scripts/lib/orchestration/check-baselines/phases/refresh-ack.js +75 -15
  44. package/.agents/scripts/lib/orchestration/deliver-recover.js +82 -43
  45. package/.agents/scripts/lib/orchestration/dependency-candidates.js +8 -4
  46. package/.agents/scripts/lib/orchestration/epic-candidates.js +9 -4
  47. package/.agents/scripts/lib/orchestration/epic-container.js +66 -4
  48. package/.agents/scripts/lib/orchestration/epic-rollup.js +241 -84
  49. package/.agents/scripts/lib/orchestration/file-assumptions.js +218 -16
  50. package/.agents/scripts/lib/orchestration/git-cleanup/phases/branches.js +93 -7
  51. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes.js +22 -6
  52. package/.agents/scripts/lib/orchestration/git-cleanup/phases/parse-args.js +26 -5
  53. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +13 -2
  54. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +35 -5
  55. package/.agents/scripts/lib/orchestration/merge-block-class.js +18 -3
  56. package/.agents/scripts/lib/orchestration/merge-poll.js +284 -40
  57. package/.agents/scripts/lib/orchestration/plan-persist/epic-adoption.js +49 -2
  58. package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +43 -7
  59. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +24 -1
  60. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +5 -0
  61. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -0
  62. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +119 -6
  63. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +110 -0
  64. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +130 -40
  65. package/.agents/scripts/lib/orchestration/resolve-stories.js +44 -1
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +31 -11
  67. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +27 -24
  68. package/.agents/scripts/lib/orchestration/run-epilogue.js +59 -38
  69. package/.agents/scripts/lib/orchestration/single-story-close/close-note.js +81 -0
  70. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +40 -51
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +10 -2
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +101 -0
  73. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +351 -28
  74. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +27 -6
  75. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +117 -22
  76. package/.agents/scripts/lib/orchestration/story-close/baseline-upward-writeback.js +94 -12
  77. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +6 -1
  78. package/.agents/scripts/lib/orchestration/ticket-validator.js +25 -14
  79. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +70 -6
  80. package/.agents/scripts/lib/orchestration/verify-credit.js +37 -0
  81. package/.agents/scripts/lib/pinned-override-notes.js +41 -53
  82. package/.agents/scripts/lib/pinned-override-resolve.js +212 -0
  83. package/.agents/scripts/lib/qa/resolve-qa-contract.js +18 -0
  84. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +173 -9
  85. package/.agents/scripts/lib/skills/walk-skill-files.js +24 -7
  86. package/.agents/scripts/lib/test-temp.js +167 -30
  87. package/.agents/scripts/lib/validation-evidence.js +37 -0
  88. package/.agents/scripts/lib/wave-runner/footprint.js +167 -14
  89. package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
  90. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  91. package/.agents/scripts/merge-baseline.js +175 -21
  92. package/.agents/scripts/providers/github/errors.js +22 -1
  93. package/.agents/scripts/providers/github/issues.js +106 -1
  94. package/.agents/scripts/providers/github/sub-issue-add.js +18 -1
  95. package/.agents/scripts/providers/github.js +6 -0
  96. package/.agents/scripts/resolve-stories.js +44 -34
  97. package/.agents/scripts/single-story-close.js +5 -0
  98. package/.agents/scripts/stories-wave-tick.js +37 -13
  99. package/.agents/templates/docs/audit-sweep-runbook.md +41 -7
  100. package/.agents/workflows/audit-accessibility.md +16 -31
  101. package/.agents/workflows/audit-mobile.md +20 -37
  102. package/.agents/workflows/git-cleanup.md +17 -3
  103. package/.agents/workflows/helpers/audit-lens-core.md +45 -0
  104. package/.agents/workflows/helpers/deliver-digest.md +7 -6
  105. package/.agents/workflows/helpers/deliver-reference.md +40 -16
  106. package/.agents/workflows/helpers/deliver-story-reference.md +7 -4
  107. package/.agents/workflows/helpers/deliver-story.md +15 -12
  108. package/.agents/workflows/helpers/plan-reference.md +8 -1
  109. package/.agents/workflows/mandrel-plan.md +4 -7
  110. package/.agents/workflows/memory-consolidate.md +14 -9
  111. package/docs/CHANGELOG.md +34 -0
  112. package/lib/cli/registry.js +64 -21
  113. package/lib/cli/sync.js +27 -2
  114. package/package.json +7 -4
@@ -15,13 +15,19 @@
15
15
  *
16
16
  * Rules (one error per mismatched path):
17
17
  * - `creates` + path **exists** → error (Story would clobber).
18
- * - `refactors-existing` (via `changes`) + path **absent** →
19
- * auto-normalized to `creates` with a logged warning (#4496 fix 5):
20
- * a refactor declaration against a base-untracked path is
21
- * deterministically a create, so rejecting it only forces a
22
- * reject→amend→re-persist cycle for a mechanical rewrite. Genuine
23
- * mismatches keep failing — a `references`-sourced `refactors-existing`
24
- * on an absent path is a missing read dependency and stays an error.
18
+ * - `refactors-existing` (via `changes`) + path **absent and never
19
+ * tracked** at `baseBranchRef` → auto-normalized to `creates` with a
20
+ * logged warning (#4496 fix 5): a refactor declaration against a
21
+ * path with no history is deterministically a create, so rejecting it
22
+ * only forces a reject→amend→re-persist cycle for a mechanical rewrite.
23
+ * - `refactors-existing` (via `changes`) + path **absent but present in
24
+ * that ref's history** → hard error naming the removing commit and, when
25
+ * git detects one, the rename target (Story #5265). The normalization
26
+ * rescues a mislabel; it must not rescue a plan authored against a file
27
+ * the tree deleted, which propagates into acceptance criteria nothing
28
+ * can satisfy. Genuine mismatches keep failing — a `references`-sourced
29
+ * `refactors-existing` on an absent path is a missing read dependency
30
+ * and stays an error.
25
31
  * - `exists` + path **absent** → error (read dependency missing).
26
32
  * - `deletes` + path **absent** → error (nothing to delete).
27
33
  *
@@ -78,6 +84,73 @@ function defaultGitRunner({ baseBranchRef, path, cwd }) {
78
84
  return result.status === 0;
79
85
  }
80
86
 
87
+ /**
88
+ * Parse the `--name-status` line for the commit that last touched `path`
89
+ * into the history verdict (Story #5265).
90
+ *
91
+ * `-M` reports a rename as `R<score>\t<old>\t<new>`. The scan is run over the
92
+ * commit's **whole** rename diff rather than a pathspec-limited one on
93
+ * purpose: rename detection pairs a delete with an add, and restricting the
94
+ * pathspec to the source path filters the add out, so git falls back to
95
+ * reporting a plain `D` and the target is lost. No matching `R` line means
96
+ * the path was deleted outright — still a removal, just without a successor
97
+ * to name.
98
+ *
99
+ * @param {string} stdout
100
+ * @param {string} path
101
+ * @returns {string|null} The rename target, or `null`.
102
+ */
103
+ function parseRenameTarget(stdout, path) {
104
+ for (const line of String(stdout ?? '').split('\n')) {
105
+ const fields = line.split('\t');
106
+ if (fields.length < 3) continue;
107
+ if (!fields[0].startsWith('R')) continue;
108
+ if (fields[1].trim() !== path) continue;
109
+ const target = fields[2].trim();
110
+ if (target) return target;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Default git **history** probe: did `baseBranchRef` ever track `path`, and
117
+ * if so, which commit stopped tracking it (Story #5265)?
118
+ *
119
+ * The existence probe above cannot tell a mechanical mislabel ("extend a file
120
+ * I am actually creating") from a plan authored against stale documentation
121
+ * ("extend a file deleted three weeks ago"). Both look identical at the tip —
122
+ * the path is absent — and only history separates them. Injectable exactly
123
+ * like {@link defaultGitRunner} so the discrimination is unit-testable with
124
+ * no repository fixture.
125
+ *
126
+ * Fails **open**: an unreadable ref, a git that errors, or an empty history
127
+ * all report `hadHistory: false`, which preserves the pre-#5265
128
+ * auto-normalisation rather than manufacturing a hard error out of a probe
129
+ * failure.
130
+ *
131
+ * @param {{ baseBranchRef: string, path: string, cwd?: string }} opts
132
+ * @returns {{ hadHistory: boolean, commit: string|null, renamedTo: string|null }}
133
+ */
134
+ function defaultHistoryRunner({ baseBranchRef, path, cwd }) {
135
+ const absent = { hadHistory: false, commit: null, renamedTo: null };
136
+ const root = cwd ?? process.cwd();
137
+ const last = gitSpawn(root, 'rev-list', '-1', baseBranchRef, '--', path);
138
+ const commit = last.status === 0 ? String(last.stdout ?? '').trim() : '';
139
+ if (!commit) return absent;
140
+ const status = gitSpawn(
141
+ root,
142
+ 'show',
143
+ '--name-status',
144
+ '-M',
145
+ '--diff-filter=R',
146
+ '--format=',
147
+ commit,
148
+ );
149
+ const renamedTo =
150
+ status.status === 0 ? parseRenameTarget(status.stdout, path) : null;
151
+ return { hadHistory: true, commit, renamedTo };
152
+ }
153
+
81
154
  /**
82
155
  * Pull every `(path, assumption, source)` triple from a Story body.
83
156
  * `source` is one of `'changes' | 'references'` so error messages can
@@ -168,8 +241,11 @@ export function hasLegacyChangeBullets(story) {
168
241
  * - `'predecessor-conflict'` — wave-aware: a concurrent Story (no
169
242
  * `depends_on` ordering) also creates this path. Cross-references the
170
243
  * shared-editor conflict finding rather than re-deriving its prose.
244
+ * - `'present-was-removed'` — the base branch **once tracked** this path
245
+ * and no longer does (Story #5265). Names the removing commit, and the
246
+ * rename target when git detected one.
171
247
  *
172
- * @param {{ slug: string, source: string, path: string, assumption: string, expected: string, producerSlug?: string }} mismatch
248
+ * @param {{ slug: string, source: string, path: string, assumption: string, expected: string, producerSlug?: string, removedInCommit?: string, renamedTo?: string|null }} mismatch
173
249
  * @returns {string}
174
250
  */
175
251
  function renderMismatch({
@@ -179,6 +255,8 @@ function renderMismatch({
179
255
  assumption,
180
256
  expected,
181
257
  producerSlug,
258
+ removedInCommit,
259
+ renamedTo,
182
260
  }) {
183
261
  if (expected === 'refactors-existing') {
184
262
  return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but predecessor Story "${producerSlug}" already creates that path — declare assumption="refactors-existing" instead (the file exists in the simulated post-predecessor tree).`;
@@ -189,9 +267,48 @@ function renderMismatch({
189
267
  if (expected === 'present') {
190
268
  return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path is absent at the base branch.`;
191
269
  }
270
+ if (expected === 'present-was-removed') {
271
+ return renderRemovedPathMismatch({
272
+ slug,
273
+ source,
274
+ path,
275
+ assumption,
276
+ removedInCommit,
277
+ renamedTo,
278
+ });
279
+ }
192
280
  return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the path already exists at the base branch.`;
193
281
  }
194
282
 
283
+ /**
284
+ * Render the stale-documentation refusal (Story #5265).
285
+ *
286
+ * The auto-normalisation this replaces is right for a path that never
287
+ * existed and wrong for one the base branch used to track: "extend
288
+ * `<deleted file>`" is not a mechanical mislabel a rewrite can fix — it is a
289
+ * plan authored against documentation the tree has outgrown, and rewriting it
290
+ * to `creates` would resurrect a file somebody deliberately removed and
291
+ * propagate the stale premise into acceptance criteria nothing can satisfy.
292
+ * The removing commit is named because it is the shortest route to *what
293
+ * replaced it*.
294
+ *
295
+ * @param {{ slug: string, source: string, path: string, assumption: string, removedInCommit?: string, renamedTo?: string|null }} mismatch
296
+ * @returns {string}
297
+ */
298
+ function renderRemovedPathMismatch({
299
+ slug,
300
+ source,
301
+ path,
302
+ assumption,
303
+ removedInCommit,
304
+ renamedTo,
305
+ }) {
306
+ const successor = renamedTo
307
+ ? ` git detects it was renamed to ${renamedTo} — retarget the declaration there.`
308
+ : ' Retarget the declaration at the path that replaced it, or declare assumption="creates" if this Story genuinely reintroduces the file.';
309
+ return `"${slug}" → body.${source} declares assumption="${assumption}" for ${path} but the base branch removed that path in commit ${removedInCommit}. The plan is authored against stale documentation, not a mislabelled create, so it is refused rather than normalized.${successor}`;
310
+ }
311
+
195
312
  /**
196
313
  * Render an auto-normalization (#4496 fix 5) into a stable warning string.
197
314
  * Kept pure and exported through the report so callers log a
@@ -290,12 +407,21 @@ function predecessorMutator(index, path, predecessors) {
290
407
  * @param {object} opts
291
408
  * @param {object[]} opts.tickets
292
409
  * @param {string} opts.baseBranchRef
293
- * @param {Function} [opts.gitRunner]
410
+ * @param {Function} [opts.gitRunner] Existence probe at `baseBranchRef`.
411
+ * @param {Function} [opts.historyRunner] History probe (Story #5265),
412
+ * injectable exactly like `gitRunner`; returns
413
+ * `{ hadHistory, commit, renamedTo }`.
294
414
  * @param {string} [opts.cwd]
295
- * @returns {{ errors: string[], warnings: string[], mismatches: Array }}
415
+ * @returns {{ errors: string[], warnings: string[], mismatches: Array, normalizations: Array }}
296
416
  */
297
417
  export function validateStoryFileAssumptions(opts) {
298
- const { tickets, baseBranchRef, gitRunner = defaultGitRunner, cwd } = opts;
418
+ const {
419
+ tickets,
420
+ baseBranchRef,
421
+ gitRunner = defaultGitRunner,
422
+ historyRunner = defaultHistoryRunner,
423
+ cwd,
424
+ } = opts;
299
425
  if (!baseBranchRef || typeof baseBranchRef !== 'string') {
300
426
  throw new Error(
301
427
  'validateStoryFileAssumptions: baseBranchRef is required and must be a string.',
@@ -307,6 +433,15 @@ export function validateStoryFileAssumptions(opts) {
307
433
  const mismatches = [];
308
434
  const normalizations = [];
309
435
  const probeCache = new Map();
436
+ const historyCache = new Map();
437
+ const probeHistory = (path) =>
438
+ probeRemoval({
439
+ historyRunner,
440
+ baseBranchRef,
441
+ path,
442
+ cwd,
443
+ cache: historyCache,
444
+ });
310
445
 
311
446
  // Wave-aware setup (Story #3960): transitive predecessor sets over the
312
447
  // story-level `depends_on` graph, plus per-path create/delete indices so
@@ -371,13 +506,14 @@ export function validateStoryFileAssumptions(opts) {
371
506
  // Auto-normalization (#4496 fix 5): a deterministic
372
507
  // `refactors-existing`→`creates` rewrite is a warning, never a
373
508
  // rejection — genuine mismatches keep flowing to `errors`.
374
- if (mismatch.normalizedTo === 'creates') {
375
- normalizations.push(mismatch);
376
- warnings.push(renderNormalization(mismatch));
509
+ const { kind, finding } = classifyMismatch(mismatch, probeHistory);
510
+ if (kind === 'normalization') {
511
+ normalizations.push(finding);
512
+ warnings.push(renderNormalization(finding));
377
513
  continue;
378
514
  }
379
- mismatches.push(mismatch);
380
- errors.push(renderMismatch(mismatch));
515
+ mismatches.push(finding);
516
+ errors.push(renderMismatch(finding));
381
517
  continue;
382
518
  }
383
519
  // Wave-aware concurrent-create check (Story #3960): two Stories with
@@ -413,6 +549,72 @@ export function validateStoryFileAssumptions(opts) {
413
549
  return { errors, warnings, mismatches, normalizations };
414
550
  }
415
551
 
552
+ /**
553
+ * Route one mismatch to the errors channel or the normalization channel
554
+ * (Story #5265).
555
+ *
556
+ * `checkAssumption` marks a `changes`-sourced `refactors-existing` on an
557
+ * absent path with `normalizedTo: 'creates'` — the #4496 rescue. Whether that
558
+ * rescue actually applies is a *history* question the pure rules table cannot
559
+ * answer, so it is resolved here: no history keeps the rescue, history ending
560
+ * in a removal converts it into a refusal that names the commit.
561
+ *
562
+ * @param {object} mismatch
563
+ * @param {(path: string) => ({ commit: string|null, renamedTo: string|null }|null)} probeHistory
564
+ * @returns {{ kind: 'error'|'normalization', finding: object }}
565
+ */
566
+ function classifyMismatch(mismatch, probeHistory) {
567
+ if (mismatch.normalizedTo !== 'creates') {
568
+ return { kind: 'error', finding: mismatch };
569
+ }
570
+ const removal = probeHistory(mismatch.path);
571
+ if (removal === null) return { kind: 'normalization', finding: mismatch };
572
+ return {
573
+ kind: 'error',
574
+ finding: {
575
+ slug: mismatch.slug,
576
+ source: mismatch.source,
577
+ path: mismatch.path,
578
+ assumption: mismatch.assumption,
579
+ expected: 'present-was-removed',
580
+ actual: 'removed',
581
+ removedInCommit: removal.commit,
582
+ renamedTo: removal.renamedTo,
583
+ },
584
+ };
585
+ }
586
+
587
+ /**
588
+ * Memoized history probe: `{ commit, renamedTo }` when `baseBranchRef` once
589
+ * tracked `path` and no longer does, `null` when it never did (Story #5265).
590
+ *
591
+ * One cache per validation run, keyed on the path, because a plan commonly
592
+ * declares the same path across several Stories and the probe costs two git
593
+ * processes. A runner that throws is absorbed as "no history": the whole
594
+ * point of the discrimination is to *add* a refusal for a provable stale
595
+ * declaration, never to convert a probe failure into one.
596
+ *
597
+ * @param {{ historyRunner: Function, baseBranchRef: string, path: string, cwd?: string, cache: Map<string, object|null> }} args
598
+ * @returns {{ commit: string|null, renamedTo: string|null }|null}
599
+ */
600
+ function probeRemoval({ historyRunner, baseBranchRef, path, cwd, cache }) {
601
+ if (cache.has(path)) return cache.get(path);
602
+ let verdict = null;
603
+ try {
604
+ const report = historyRunner({ baseBranchRef, path, cwd });
605
+ if (report?.hadHistory) {
606
+ verdict = {
607
+ commit: report.commit ?? null,
608
+ renamedTo: report.renamedTo ?? null,
609
+ };
610
+ }
611
+ } catch {
612
+ verdict = null;
613
+ }
614
+ cache.set(path, verdict);
615
+ return verdict;
616
+ }
617
+
416
618
  /**
417
619
  * Find the first *concurrent* co-creator of `path` for the Story `slug`:
418
620
  * another Story that declares `creates` on the same path with no
@@ -50,6 +50,19 @@ import { parsePrunedRefs } from './prune.js';
50
50
 
51
51
  const TAG = '[git-cleanup]';
52
52
 
53
+ /**
54
+ * The detection signal the weak-signal guard withholds on, and the reason
55
+ * it records (Story #5283). `content-merged` comes from
56
+ * `git merge-tree --write-tree` finding the branch's changes already
57
+ * present in the base by *some* route — it cannot tell a squash-merge
58
+ * from a branch whose every change was independently reverted, so it is
59
+ * the one signal an unattended `--yes` run must not delete a remote ref
60
+ * on. Named here so the guard, the renderer and the tests share one
61
+ * spelling.
62
+ */
63
+ const WEAK_SIGNAL_DETECTOR = 'content-merged';
64
+ const WEAK_SIGNAL_REASON = 'weak-signal-needs-confirmation';
65
+
53
66
  function evaluateLocalBranch({
54
67
  branch,
55
68
  baseBranch,
@@ -167,16 +180,45 @@ function evaluateLocalBranch({
167
180
  * collections. Deletion is unchanged: a remote-only candidate still needs
168
181
  * `--remote`, whichever signal detected it.
169
182
  */
183
+ /**
184
+ * Normalize whatever `prIndexFn` returned into the
185
+ * `{ index, complete }` pair {@link probeAllPrs} emits (Story #5283).
186
+ *
187
+ * The seam is injectable, and a caller that hands back a bare `Map` —
188
+ * every pre-#5283 double does — means "here is the page" without
189
+ * claiming it was exhaustive. That reads as `complete: false`, which
190
+ * keeps the per-branch fallback armed: the conservative direction, since
191
+ * a wrongly-complete page suppresses a probe that would have found a
192
+ * real PR.
193
+ *
194
+ * @param {unknown} value
195
+ * @returns {{ index: Map, complete: boolean }}
196
+ */
197
+ function normalizePrIndex(value) {
198
+ if (value instanceof Map) return { index: value, complete: false };
199
+ return {
200
+ index: value?.index instanceof Map ? value.index : new Map(),
201
+ complete: value?.complete === true,
202
+ };
203
+ }
204
+
170
205
  function buildGuardedPrProbe({ cwd, prIndexFn, prFallback, onDegrade }) {
171
- let prIndex;
206
+ let bulk;
172
207
  try {
173
- prIndex = prIndexFn(cwd);
208
+ bulk = normalizePrIndex(prIndexFn(cwd));
174
209
  } catch (err) {
175
210
  onDegrade(err);
176
- prIndex = new Map();
211
+ bulk = { index: new Map(), complete: false };
177
212
  }
213
+ const { index: prIndex, complete } = bulk;
178
214
  return (branch, c) => {
179
215
  if (prIndex.has(branch)) return prIndex.get(branch);
216
+ // Story #5283: a complete page listed every PR in the repo, so this
217
+ // head ref demonstrably has none. Probing it per-branch spends a `gh`
218
+ // spawn to be told the same thing — once per PR-less branch, which on
219
+ // a checkout full of local scratch branches is the whole point of the
220
+ // bulk fetch undone.
221
+ if (complete) return null;
180
222
  try {
181
223
  return prFallback(branch, c);
182
224
  } catch (err) {
@@ -302,6 +344,21 @@ function worktreeRootFor(cand) {
302
344
  /**
303
345
  * Pure-ish: execute the branch reap plan.
304
346
  *
347
+ * ## Weak-signal guard (Story #5283)
348
+ *
349
+ * `skipWeakSignal` withholds the **remote** delete of any candidate
350
+ * detected only by content-equivalence, recording it on `remote[]` as
351
+ * `{ skipped: true, reason: 'weak-signal-needs-confirmation' }` instead
352
+ * of issuing `git push --delete`. The branch-phase driver arms it on the
353
+ * `--yes` path unless the operator passed `--include-content-merged`,
354
+ * mirroring the stash phase's `--drop-stashes` allowlist: an unattended
355
+ * run may not destroy a remote ref on the weakest merge signal without
356
+ * being told to. The interactive path leaves it disarmed — the prompt
357
+ * already names the weak-signal count and the operator answered it.
358
+ *
359
+ * Local deletion is deliberately untouched: a local ref is recoverable
360
+ * from the remote, which is exactly what the guard preserves.
361
+ *
305
362
  * Ref-reap is decoupled from worktree-reap (Story #3598): every
306
363
  * already-merged candidate has its local ref (and remote ref, in
307
364
  * `--remote` mode) deleted regardless of whether its worktree directory
@@ -319,12 +376,21 @@ export function executeCleanup(ctx) {
319
376
  remote,
320
377
  removeWorktreeFn = removeWorktree,
321
378
  deleteLocalFn = (b, c) => deleteBranchLocal(b, { cwd: c, force: true }),
322
- deleteRemoteFn = (b, c) => deleteBranchRemote(b, { cwd: c }),
379
+ deleteRemoteFn = (b, c, r) => deleteBranchRemote(b, { cwd: c, remote: r }),
323
380
  pruneRemoteFn = (c, r) => pruneRemoteTracking(c, r, parsePrunedRefs),
324
381
  recordPendingCleanupFn = recordPendingCleanup,
325
382
  remoteName = 'origin',
383
+ skipWeakSignal = false,
326
384
  logger = Logger,
327
385
  } = ctx;
386
+ // The remote name belongs to `executeCleanup`, not to the per-candidate
387
+ // reap helper, so bind it here (Story #5283). The default deleter used
388
+ // to drop it and let `deleteBranchRemote` fall back to `origin`, which
389
+ // sent every `--remote` delete of an `upstream`-configured checkout at
390
+ // the wrong remote. Binding — rather than closing over it — also hands
391
+ // an injected deleter the same `(branch, cwd, remote)` triple the git
392
+ // invocation is built from, so a test can see which remote was targeted.
393
+ const boundDeleteRemote = (b, c) => deleteRemoteFn(b, c, remoteName);
328
394
  const worktrees = [];
329
395
  const local = [];
330
396
  const remoteResults = [];
@@ -347,11 +413,31 @@ export function executeCleanup(ctx) {
347
413
  worktreeRoot: worktreeRootFor(cand),
348
414
  });
349
415
  if (!reapLocalRef({ cand, deleteLocalFn, cwd, local, failures })) continue;
350
- if (remote)
351
- reapRemoteRef({ cand, deleteRemoteFn, cwd, remoteResults, failures });
416
+ if (!remote) continue;
417
+ if (skipWeakSignal && cand.detectedBy === WEAK_SIGNAL_DETECTOR) {
418
+ remoteResults.push({
419
+ branch: cand.branch,
420
+ ok: true,
421
+ skipped: true,
422
+ reason: WEAK_SIGNAL_REASON,
423
+ alreadyGone: false,
424
+ detectedBy: cand.detectedBy,
425
+ });
426
+ continue;
427
+ }
428
+ reapRemoteRef({
429
+ cand,
430
+ deleteRemoteFn: boundDeleteRemote,
431
+ cwd,
432
+ remoteResults,
433
+ failures,
434
+ });
352
435
  }
353
436
  let prune = null;
354
- if (remote && remoteResults.length > 0) {
437
+ // Prune drops the tracking refs a remote *delete* left behind. A run
438
+ // whose every remote candidate was withheld deleted nothing, so there
439
+ // is nothing stale to prune and no reason to spend the fetch.
440
+ if (remote && remoteResults.some((r) => !r.skipped)) {
355
441
  prune = buildPruneSummary({ pruneRemoteFn, cwd, remoteName, failures });
356
442
  }
357
443
  return {
@@ -333,13 +333,28 @@ export function probeLatestPr(branch, cwd, runGh = defaultGhRunner) {
333
333
  * fallback for head refs absent from this page (a branch whose PR fell
334
334
  * outside the `--limit` window).
335
335
  *
336
- * Returns an empty Map on any failure (non-array, empty, or malformed
336
+ * Returns an empty index on any failure (non-array, empty, or malformed
337
337
  * JSON) so the caller transparently falls back to per-branch probing.
338
338
  *
339
+ * ## `complete` — when absence from the page is proof (Story #5283)
340
+ *
341
+ * The returned `complete` flag says whether the page enumerated **every**
342
+ * PR in the repository: true when `gh` returned fewer rows than the
343
+ * `--limit` it was given, which is the only way to know the window did
344
+ * not truncate. On a complete page a head ref's absence is not "the PR
345
+ * fell outside the window" — it is proof that no PR covers that ref at
346
+ * all, so the caller's per-branch fallback can only re-derive the same
347
+ * `null` at the cost of one `gh` spawn per PR-less branch.
348
+ *
349
+ * Every failure mode reports `complete: false`, because an unusable page
350
+ * proves nothing: empty stdout (a degraded `gh`), unparseable JSON, and a
351
+ * non-array payload must all leave the fallback armed. A *parsed* empty
352
+ * array is genuinely complete — a repository with no PRs at all.
353
+ *
339
354
  * @param {string} cwd
340
355
  * @param {(args: string[], opts: { cwd: string }) => string} runGh
341
356
  * @param {number} limit Max rows to fetch in the single page (default 1000).
342
- * @returns {Map<string, { number: number, state: string, mergedAt: string|null, closedAt: string|null, headRefOid: string|null }>}
357
+ * @returns {{ index: Map<string, { number: number, state: string, mergedAt: string|null, closedAt: string|null, headRefOid: string|null }>, complete: boolean }}
343
358
  */
344
359
  export function probeAllPrs(cwd, runGh = defaultGhRunner, limit = 1000) {
345
360
  const out = runGh(
@@ -357,14 +372,15 @@ export function probeAllPrs(cwd, runGh = defaultGhRunner, limit = 1000) {
357
372
  );
358
373
  const trimmed = (out ?? '').trim();
359
374
  const index = new Map();
360
- if (!trimmed) return index;
375
+ const truncated = { index, complete: false };
376
+ if (!trimmed) return truncated;
361
377
  let parsed;
362
378
  try {
363
379
  parsed = JSON.parse(trimmed);
364
380
  } catch {
365
- return index;
381
+ return truncated;
366
382
  }
367
- if (!Array.isArray(parsed)) return index;
383
+ if (!Array.isArray(parsed)) return truncated;
368
384
  for (const row of parsed) {
369
385
  const headRefName =
370
386
  typeof row?.headRefName === 'string' ? row.headRefName : null;
@@ -379,7 +395,7 @@ export function probeAllPrs(cwd, runGh = defaultGhRunner, limit = 1000) {
379
395
  headRefOid: row.headRefOid ?? null,
380
396
  });
381
397
  }
382
- return index;
398
+ return { index, complete: parsed.length < limit };
383
399
  }
384
400
 
385
401
  const SHA_RE = /^[0-9a-f]{7,40}$/i;
@@ -22,10 +22,23 @@ const CLI_OPTIONS = {
22
22
  include: { type: 'string', multiple: true, default: [] },
23
23
  exclude: { type: 'string', multiple: true, default: [] },
24
24
  'drop-stashes': { type: 'string', multiple: true, default: [] },
25
+ 'include-content-merged': { type: 'boolean', default: false },
25
26
  base: { type: 'string' },
26
27
  cwd: { type: 'string' },
27
28
  };
28
29
 
30
+ /**
31
+ * Normalize a repeatable flag's parsed value to a list. `parseArgs` yields
32
+ * an array for a `multiple: true` option, but only once the flag appears;
33
+ * this keeps the three repeatable flags reading identically.
34
+ *
35
+ * @param {unknown} value
36
+ * @returns {string[]}
37
+ */
38
+ function asList(value) {
39
+ return Array.isArray(value) ? value : [];
40
+ }
41
+
29
42
  function resolveActivePhases(values) {
30
43
  const anyPhaseFlag =
31
44
  values['fast-forward-main'] === true ||
@@ -44,6 +57,14 @@ function resolveActivePhases(values) {
44
57
  /**
45
58
  * Pure: parse argv into the normalized CLI option bag.
46
59
  *
60
+ * Every flag the CLI honours MUST be declared in {@link CLI_OPTIONS}:
61
+ * `parseArgs` runs with `strict: false`, so an undeclared flag is not
62
+ * rejected — it is silently absorbed and the option it was meant to set
63
+ * stays at its default. For `--include-content-merged` that failure mode
64
+ * is destructive in the quiet direction's opposite: the operator asks to
65
+ * include the weak-signal candidates, the flag is dropped, and the run
66
+ * withholds them anyway.
67
+ *
47
68
  * @param {string[]} argv
48
69
  * @returns {{
49
70
  * dryRun: boolean,
@@ -55,6 +76,7 @@ function resolveActivePhases(values) {
55
76
  * include: string[],
56
77
  * exclude: string[],
57
78
  * dropStashes: string[],
79
+ * includeContentMerged: boolean,
58
80
  * base: string|null,
59
81
  * cwd: string|null,
60
82
  * }}
@@ -73,11 +95,10 @@ export function parseCleanupArgs(argv) {
73
95
  yes: values.yes === true,
74
96
  json: values.json === true,
75
97
  phases: resolveActivePhases(values),
76
- include: Array.isArray(values.include) ? values.include : [],
77
- exclude: Array.isArray(values.exclude) ? values.exclude : [],
78
- dropStashes: Array.isArray(values['drop-stashes'])
79
- ? values['drop-stashes']
80
- : [],
98
+ include: asList(values.include),
99
+ exclude: asList(values.exclude),
100
+ dropStashes: asList(values['drop-stashes']),
101
+ includeContentMerged: values['include-content-merged'] === true,
81
102
  base: typeof values.base === 'string' ? values.base : null,
82
103
  cwd: typeof values.cwd === 'string' ? values.cwd : null,
83
104
  };
@@ -218,7 +218,8 @@ function countActionableCandidates(candidates, remote) {
218
218
  *
219
219
  * @param {object} state
220
220
  * @param {object} state.plan Output of `planCleanup`.
221
- * @param {object} state.opts CLI options (`dryRun`, `yes`, `remote`).
221
+ * @param {object} state.opts CLI options (`dryRun`, `yes`, `remote`,
222
+ * `includeContentMerged`).
222
223
  * @param {string} state.cwd Working directory.
223
224
  */
224
225
  export function decideBranchPhase(state) {
@@ -254,7 +255,17 @@ export function decideBranchPhase(state) {
254
255
  executeArgs,
255
256
  };
256
257
  }
257
- return { kind: 'execute', plan, executeArgs };
258
+ // Story #5283: the unattended arm. Nobody saw the weak-signal note
259
+ // above, so a `content-merged` candidate's remote ref is withheld
260
+ // unless the operator opted in with `--include-content-merged`.
261
+ return {
262
+ kind: 'execute',
263
+ plan,
264
+ executeArgs: {
265
+ ...executeArgs,
266
+ skipWeakSignal: opts.includeContentMerged !== true,
267
+ },
268
+ };
258
269
  }
259
270
 
260
271
  /**
@@ -227,18 +227,40 @@ export function renderLatestPrSkipLine(skip) {
227
227
  return null;
228
228
  }
229
229
 
230
- /** Pure: render a per-branch execution line. */
230
+ /**
231
+ * Remedy text per withheld-delete reason (Story #5283). A withheld entry
232
+ * that named no way to proceed would read as an unexplained refusal, so
233
+ * every reason the executor can record gets its own next step here.
234
+ */
235
+ const WITHHELD_HINTS = {
236
+ 'weak-signal-needs-confirmation':
237
+ 'detected only by content-equivalence; re-run interactively or pass --include-content-merged',
238
+ };
239
+
240
+ /**
241
+ * Pure: render a per-branch execution line.
242
+ *
243
+ * An entry marked `skipped` is a delete the executor deliberately
244
+ * withheld rather than one it attempted — it must not render with the
245
+ * `✅` of a completed reap, which is exactly the misreport that would let
246
+ * an operator believe an unattended run had cleaned up a ref it left
247
+ * standing.
248
+ */
231
249
  export function renderExecutionLine(entry, scope) {
232
- const icon = entry.ok ? '✅' : '❌';
233
250
  const label = scope.padEnd(8);
234
- const tag =
251
+ const tagName =
235
252
  scope === 'local' || scope === 'remote' ? entry.branch : entry.path;
253
+ if (entry.skipped) {
254
+ const hint = WITHHELD_HINTS[entry.reason];
255
+ return `${TAG} ⏭️ ${label} ${tagName} — withheld${hint ? ` (${hint})` : ` (${entry.reason})`}`;
256
+ }
257
+ const icon = entry.ok ? '✅' : '❌';
236
258
  const note = entry.alreadyGone
237
259
  ? ' (already gone)'
238
260
  : entry.dirty
239
261
  ? ' (forced — was dirty)'
240
262
  : '';
241
- return `${TAG} ${icon} ${label} ${tag}${note}`;
263
+ return `${TAG} ${icon} ${label} ${tagName}${note}`;
242
264
  }
243
265
 
244
266
  /** Pure: render the optional prune line. */
@@ -286,7 +308,15 @@ export function renderExecutionSummary(result) {
286
308
  deferredCount > 0
287
309
  ? ` (${deferredCount} worktree(s) deferred to sweep)`
288
310
  : '';
289
- return `${TAG} ✅ Reaped ${result.local.length} local + ${result.remote.length} remote + ${result.worktrees.length} worktree(s)${pruneNote}.${deferredNote}`;
311
+ // Story #5283: withheld remote entries are recorded on `remote[]` but
312
+ // were never deleted — counting them would overstate the reap.
313
+ const remoteDeleted = result.remote.filter((r) => !r.skipped).length;
314
+ const withheldCount = result.remote.length - remoteDeleted;
315
+ const withheldNote =
316
+ withheldCount > 0
317
+ ? ` (${withheldCount} remote delete(s) withheld — weaker signal)`
318
+ : '';
319
+ return `${TAG} ✅ Reaped ${result.local.length} local + ${remoteDeleted} remote + ${result.worktrees.length} worktree(s)${pruneNote}.${deferredNote}${withheldNote}`;
290
320
  }
291
321
 
292
322
  const EMPTY_RESULT = Object.freeze({