forge-workflow 0.1.0-beta.4 → 0.1.0-beta.5

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 (119) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +20 -0
  5. package/bin/forge.js +16 -374
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +8 -5
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -118,13 +118,15 @@ function parseMainWorktree(output) {
118
118
  }
119
119
 
120
120
  /**
121
- * GitHub tier: collect head refs of merged PRs (one memoized call).
122
- * Returns an empty Set when gh is unavailable or errors — a safe no-signal.
121
+ * GitHub tier: collect merged PRs keyed by head branch (one memoized call).
122
+ * Returns an empty Map when gh is unavailable or errors — a safe no-signal.
123
+ * The number/title/merge-commit ride along so a merge can be CITED as evidence on
124
+ * the kernel issue the branch was linked to (18f1988e), not just detected.
123
125
  * @param {Function} runFile - execFileSync-compatible function
124
- * @returns {Map<string, string>} Map of merged PR head branch name -> head commit OID
126
+ * @returns {Map<string, {headRefOid: string, number: number, title: string, mergeCommitOid: string|null}>}
125
127
  */
126
- function getGhMergedRefs(runFile) {
127
- const out = tryRun(runFile, 'gh', ['pr', 'list', '--state', 'merged', '--json', 'headRefName,headRefOid', '--limit', '200']);
128
+ function getGhMergedPrs(runFile) {
129
+ const out = tryRun(runFile, 'gh', ['pr', 'list', '--state', 'merged', '--json', 'number,title,headRefName,headRefOid,mergeCommit', '--limit', '200']);
128
130
  if (!out) return new Map();
129
131
  try {
130
132
  const arr = JSON.parse(out);
@@ -133,7 +135,14 @@ function getGhMergedRefs(runFile) {
133
135
  // Record the head OID too: a reused/advanced branch name must NOT be treated as
134
136
  // merged unless its current tip still matches the OID GitHub merged.
135
137
  for (const p of arr) {
136
- if (p && p.headRefName && p.headRefOid) map.set(p.headRefName, p.headRefOid);
138
+ if (p && p.headRefName && p.headRefOid) {
139
+ map.set(p.headRefName, {
140
+ headRefOid: p.headRefOid,
141
+ number: p.number,
142
+ title: p.title,
143
+ mergeCommitOid: (p.mergeCommit && p.mergeCommit.oid) || null,
144
+ });
145
+ }
137
146
  }
138
147
  return map;
139
148
  } catch (_e) { /* intentional: malformed gh output → no signal */ // NOSONAR S2486
@@ -166,30 +175,63 @@ function isSquashMerged(branch, defaultBranch, runFile) {
166
175
  return cherry.startsWith('-');
167
176
  }
168
177
 
178
+ /**
179
+ * GitHub tier: a merged PR whose head OID still matches the branch tip. This is
180
+ * direct evidence THIS tip was merged, not a git-topology inference — a
181
+ * reused/advanced branch name falls through to the git-only tiers instead.
182
+ * @param {string} branch - Branch name
183
+ * @param {object} ctx - Detection context (see detectMerged)
184
+ * @returns {boolean} True iff a merged PR is pinned to the current branch tip
185
+ */
186
+ function hasMergedPrEvidence(branch, ctx) {
187
+ const pr = ctx.ghMergedPrs && ctx.ghMergedPrs.get(branch);
188
+ if (!pr || !pr.headRefOid) return false;
189
+ const tip = tryRun(ctx.runFile, 'git', ['rev-parse', branch]);
190
+ return Boolean(tip) && tip === pr.headRefOid;
191
+ }
192
+
193
+ /**
194
+ * Whether a branch has contributed nothing yet — zero commits ahead of the
195
+ * default branch (kernel c5ab529e).
196
+ *
197
+ * A branch created but not yet committed to points AT a default-branch commit,
198
+ * so `git branch --merged` lists it and squash patch-equivalence finds an empty
199
+ * diff already present: every git-only tier reads it as merged. It is unstarted
200
+ * work, and `forge clean` deleted a live agent's fresh worktree and closed its
201
+ * issue on that reading. Only a VERIFIED count of 0 counts as unstarted; an
202
+ * unreadable count leaves detection exactly as it was.
203
+ *
204
+ * @param {string} branch - Branch name
205
+ * @param {object} ctx - Detection context (see detectMerged)
206
+ * @returns {boolean} True iff the branch is verified to have no commits of its own
207
+ */
208
+ function isUnstartedBranch(branch, ctx) {
209
+ const out = tryRun(ctx.runFile, 'git', ['rev-list', '--count', `${ctx.defaultBranch}..${branch}`]);
210
+ return out !== '' && parseInt(out, 10) === 0;
211
+ }
212
+
169
213
  /**
170
214
  * Squash-aware merged detection over three short-circuiting tiers:
171
- * (a) ancestry list (`git branch --merged`), (b) merged-PR head refs (gh),
215
+ * (a) merged-PR head refs (gh), (b) ancestry list (`git branch --merged`),
172
216
  * (c) git-only squash patch-equivalence. A branch confirmed by none stays.
217
+ *
218
+ * The gh tier runs first because it is the only tier that cites real merge
219
+ * evidence, so it still recognizes a fast-forward/merge-commit PR whose branch
220
+ * ends up 0 commits ahead. The git-only tiers below cannot tell that case apart
221
+ * from an unstarted branch, so they are gated on the branch having commits.
222
+ *
173
223
  * @param {string} branch - Branch name
174
224
  * @param {object} ctx - Detection context
175
225
  * @param {string} ctx.defaultBranch - Default branch name
176
226
  * @param {string[]} ctx.mergedBranches - Ancestry-merged branch names
177
- * @param {Map<string, string>} ctx.ghMergedRefs - Merged PR head branch -> head OID
227
+ * @param {Map<string, object>} ctx.ghMergedPrs - Merged PR head branch -> PR evidence
178
228
  * @param {Function} ctx.runFile - execFileSync-compatible function
179
229
  * @returns {boolean} True iff the branch is merged (any tier)
180
230
  */
181
231
  function detectMerged(branch, ctx) {
232
+ if (hasMergedPrEvidence(branch, ctx)) return true;
233
+ if (isUnstartedBranch(branch, ctx)) return false;
182
234
  if (ctx.mergedBranches.includes(branch)) return true;
183
- if (ctx.ghMergedRefs) {
184
- const mergedOid = ctx.ghMergedRefs.get(branch);
185
- // Only trust the gh "merged" signal when the merged PR's head OID still matches
186
- // the branch tip; a reused/advanced branch name with new commits falls through
187
- // to the squash patch-equivalence check (never remove on doubt).
188
- if (mergedOid) {
189
- const tip = tryRun(ctx.runFile, 'git', ['rev-parse', branch]);
190
- if (tip && tip === mergedOid) return true;
191
- }
192
- }
193
235
  return isSquashMerged(branch, ctx.defaultBranch, ctx.runFile);
194
236
  }
195
237
 
@@ -270,32 +312,40 @@ async function removeWorktreeRobust(wtPath, runFile, fsApi, opts = {}) {
270
312
  * @param {Function} runFile - execFileSync-compatible function
271
313
  * @param {object} fsApi - fs-compatible module
272
314
  * @param {object} opts - Removal injection options
273
- * @returns {Promise<{ status: string, path: string, branch: string|null, error?: string }>}
315
+ * @returns {Promise<{ status: string, path: string, branch: string|null,
316
+ * closeIssue: boolean, error?: string }>}
274
317
  */
275
318
  async function cleanWorktree(dir, worktreeMap, isMergedFn, worktreesDir, dryRun, runFile, fsApi, opts) {
276
319
  const wtPath = path.resolve(worktreesDir, dir);
277
320
  // Lookup by canonical key (86b04c20): porcelain emits C:/ paths, resolve emits C:\.
278
321
  const branch = worktreeMap.get(normalizeWorktreeKey(wtPath)) || null;
279
322
 
323
+ // `closeIssue` rides on the SAME outcome that decides removal (kernel c5ab529e).
324
+ // Closing is irreversible — `done` is terminal — so it may never be recomputed
325
+ // independently and drift away from what cleanup actually decided to do.
280
326
  if (!branch || !isMergedFn(branch)) {
281
- return { status: 'active', path: wtPath, branch };
327
+ return { status: 'active', path: wtPath, branch, closeIssue: false };
282
328
  }
283
329
 
284
330
  // Merged, but never blow away uncommitted local edits. Checked BEFORE the dry-run
285
331
  // branch so a dry run reports the same "dirty" skip the real run would take.
332
+ // Held back for safety => the work is not confirmed finished => never closed.
286
333
  if (isWorktreeDirty(wtPath, runFile)) {
287
- return { status: 'dirty', path: wtPath, branch };
334
+ return { status: 'dirty', path: wtPath, branch, closeIssue: false };
288
335
  }
289
336
 
337
+ // A dry run reports what it would do and mutates nothing, kernel included.
290
338
  if (dryRun) {
291
- return { status: 'cleaned', path: wtPath, branch };
339
+ return { status: 'cleaned', path: wtPath, branch, closeIssue: false };
292
340
  }
293
341
 
294
342
  const outcome = await removeWorktreeRobust(wtPath, runFile, fsApi, opts);
295
343
  if (outcome.removed) {
296
- return { status: 'cleaned', path: wtPath, branch, method: outcome.method };
344
+ return { status: 'cleaned', path: wtPath, branch, method: outcome.method, closeIssue: true };
297
345
  }
298
- return { status: 'survivor', path: wtPath, branch, error: outcome.error };
346
+ // Merged with a clean tree; only the directory removal lost to an FS lock. The
347
+ // work IS in the default branch, so the issue is finished either way.
348
+ return { status: 'survivor', path: wtPath, branch, error: outcome.error, closeIssue: true };
299
349
  }
300
350
 
301
351
  /**
@@ -449,6 +499,13 @@ function renderSurvivors(survivors, lines) {
449
499
  for (const s of survivors) lines.push(` - ${s.path}${s.error ? ` (${s.error})` : ''}`);
450
500
  }
451
501
 
502
+ /** Append the closed-linked-issue lines. */
503
+ function renderClosedIssues(closedIssues, lines) {
504
+ if (!closedIssues || closedIssues.length === 0) return;
505
+ lines.push(`Closed ${closedIssues.length} linked issue(s) on merge:`);
506
+ for (const c of closedIssues) lines.push(` - ${c.issueId} (${c.branch})`);
507
+ }
508
+
452
509
  /** Append the master-sync outcome line(s). */
453
510
  function renderMasterSync(masterSync, lines) {
454
511
  if (!masterSync || !masterSync.attempted) return;
@@ -472,10 +529,94 @@ function formatOutput(summary) {
472
529
  lines.push(`${verb} ${summary.cleaned} merged worktree(s); ${summary.active} active kept.`);
473
530
  renderDirty(summary.dirty, lines);
474
531
  renderSurvivors(summary.survivors, lines);
532
+ renderClosedIssues(summary.closedIssues, lines);
475
533
  renderMasterSync(summary.masterSync, lines);
476
534
  return lines.join('\n');
477
535
  }
478
536
 
537
+ /**
538
+ * Strip the merged-PR record down to the evidence cited on the kernel issue.
539
+ * @param {Map<string, object>} ghMergedPrs
540
+ * @param {string} branch
541
+ * @returns {{number: number, title: string, mergeCommitOid: string|null}|null}
542
+ */
543
+ function prEvidenceFor(ghMergedPrs, branch) {
544
+ const pr = ghMergedPrs && ghMergedPrs.get(branch);
545
+ if (!pr || !pr.number) return null;
546
+ return { number: pr.number, title: pr.title, mergeCommitOid: pr.mergeCommitOid };
547
+ }
548
+
549
+ /**
550
+ * Bridge from a merged branch to the close-on-merge primitive (kernel 18f1988e).
551
+ *
552
+ * The kernel driver + issue runner are built ONCE and only on the first merged
553
+ * branch, so a clean run that removes nothing pays nothing. The driver is closed
554
+ * by `dispose()` — and ONLY when this helper owns it, since an injected driver
555
+ * belongs to the caller. Leaving an owned sqlite handle open keeps the kernel file
556
+ * locked and fails Windows cleanup.
557
+ *
558
+ * @param {string} projectRoot
559
+ * @param {Function} runFile - execFileSync-compatible function (repo probe)
560
+ * @param {object} opts - DI (`_closeLinkedIssue`, `_kernelDriver`, `_runIssueOperation`)
561
+ * @returns {{ close: Function, dispose: Function }}
562
+ */
563
+ function createIssueCloser(projectRoot, runFile, opts) {
564
+ const injected = opts._closeLinkedIssue;
565
+ let built = null;
566
+ const unavailable = { driver: null, run: null, owned: false };
567
+
568
+ async function resolveDeps() {
569
+ if (built) return built;
570
+ if (opts._kernelDriver && opts._runIssueOperation) {
571
+ built = { driver: opts._kernelDriver, run: opts._runIssueOperation, owned: false };
572
+ return built;
573
+ }
574
+ // Only reach for the kernel inside a REAL git repo. buildMigratedKernelIssueDeps
575
+ // CREATES the store at <git-common-dir>/forge/kernel.sqlite, so probing first is
576
+ // what stops a clean run pointed at a non-repo path from minting a stray kernel
577
+ // there. An empty result (rev-parse failed) means "not a repo".
578
+ if (!tryRun(runFile, 'git', ['-C', projectRoot, 'rev-parse', '--git-common-dir'])) {
579
+ built = unavailable;
580
+ return built;
581
+ }
582
+ try {
583
+ const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
584
+ const { runIssueOperation } = require('../forge-issues');
585
+ const { kernelDriver, kernelBroker } = await buildMigratedKernelIssueDeps({ projectRoot });
586
+ // Thread the broker through so every op reuses THIS driver instead of opening
587
+ // a fresh kernel handle per call.
588
+ built = {
589
+ driver: kernelDriver,
590
+ run: (operation, args, root) => runIssueOperation(operation, args, root, { kernelBroker }),
591
+ owned: true,
592
+ };
593
+ } catch (_e) { /* intentional: no kernel → close nothing, clean normally */ // NOSONAR S2486
594
+ built = unavailable;
595
+ }
596
+ return built;
597
+ }
598
+
599
+ return {
600
+ async close(branch, pr) {
601
+ try {
602
+ if (injected) return await injected({ branch, pr, projectRoot });
603
+ const { driver, run } = await resolveDeps();
604
+ if (!driver || !run) return { closed: false, reason: 'unavailable' };
605
+ const { closeLinkedIssueOnMerge } = require('../kernel/close-on-merge');
606
+ return await closeLinkedIssueOnMerge({ branch, projectRoot, pr, driver, runIssueOperation: run });
607
+ } catch (error) {
608
+ // Best-effort: cleanup must never fail because tracking did.
609
+ return { closed: false, reason: 'error', error: error && error.message };
610
+ }
611
+ },
612
+ dispose() {
613
+ if (built && built.owned && built.driver && typeof built.driver.close === 'function') {
614
+ try { built.driver.close(); } catch (_e) { /* best-effort */ } // NOSONAR S2486
615
+ }
616
+ },
617
+ };
618
+ }
619
+
479
620
  /**
480
621
  * Main handler for the clean command.
481
622
  * @param {string[]} _args - Positional arguments (unused)
@@ -492,10 +633,11 @@ function formatOutput(summary) {
492
633
  * Scan .worktrees/ and remove the merged ones (squash-aware). Returns the tallies;
493
634
  * a no-op ({0,0,[],[]}) when .worktrees/ is absent or empty. Extracted from handler
494
635
  * so the top-level orchestration stays under the complexity gate.
495
- * @returns {Promise<{ cleaned: number, active: number, survivors: object[], dirty: string[] }>}
636
+ * @param {{close: Function}|null} [closer] - Close-on-merge bridge (null in dry-run)
637
+ * @returns {Promise<{ cleaned: number, active: number, survivors: object[], dirty: string[], closedIssues: object[] }>}
496
638
  */
497
- async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
498
- const acc = { cleaned: 0, active: 0, survivors: [], dirty: [] };
639
+ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts, closer = null) {
640
+ const acc = { cleaned: 0, active: 0, survivors: [], dirty: [], closedIssues: [] };
499
641
  if (!fsApi.existsSync(worktreesDir)) return acc;
500
642
 
501
643
  const entries = fsApi.readdirSync(worktreesDir, { withFileTypes: true });
@@ -512,8 +654,8 @@ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
512
654
  // ancestry fast path instead of falling through to the slower squash tier.
513
655
  mergedBranches = mergedOut.split('\n').map(b => b.trim().replace(/^[*+]\s*/, '')).filter(Boolean);
514
656
  }
515
- const ghMergedRefs = getGhMergedRefs(runFile);
516
- const ctx = { defaultBranch, mergedBranches, ghMergedRefs, runFile };
657
+ const ghMergedPrs = getGhMergedPrs(runFile);
658
+ const ctx = { defaultBranch, mergedBranches, ghMergedPrs, runFile };
517
659
  const isMergedFn = opts._isMerged || (branch => detectMerged(branch, ctx));
518
660
 
519
661
  const listOutput = tryRun(runFile, 'git', ['worktree', 'list', '--porcelain']);
@@ -525,6 +667,16 @@ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
525
667
  else if (res.status === 'survivor') acc.survivors.push(res);
526
668
  else if (res.status === 'dirty') acc.dirty.push(res.path);
527
669
  else acc.active++;
670
+
671
+ // Close only what THIS outcome authorized (18f1988e, tightened by c5ab529e):
672
+ // `closeIssue` is set by cleanWorktree alongside the removal decision, so a
673
+ // worktree held back for any reason cannot have its issue closed.
674
+ if (closer && res.branch && res.closeIssue) {
675
+ const outcome = await closer.close(res.branch, prEvidenceFor(ghMergedPrs, res.branch));
676
+ if (outcome && outcome.closed) {
677
+ acc.closedIssues.push({ branch: res.branch, issueId: outcome.issueId });
678
+ }
679
+ }
528
680
  }
529
681
 
530
682
  if (!dryRun && acc.cleaned > 0) {
@@ -541,7 +693,15 @@ async function handler(_args, flags, projectRoot, opts = {}) {
541
693
  const worktreesDir = path.resolve(projectRoot, '.worktrees');
542
694
 
543
695
  // Worktree cleanup (no-op when .worktrees/ absent); master auto-update is independent.
544
- const { cleaned, active, survivors, dirty } = await cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts);
696
+ // A dry run detects merges but must not mutate the kernel, so it gets no closer.
697
+ const closer = dryRun ? null : createIssueCloser(projectRoot, runFile, opts);
698
+ let scan;
699
+ try {
700
+ scan = await cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts, closer);
701
+ } finally {
702
+ if (closer) closer.dispose();
703
+ }
704
+ const { cleaned, active, survivors, dirty, closedIssues } = scan;
545
705
 
546
706
  // Post-merge master auto-update (default-on; skipped in dry-run).
547
707
  let masterSync = null;
@@ -550,7 +710,7 @@ async function handler(_args, flags, projectRoot, opts = {}) {
550
710
  masterSync = await doSync();
551
711
  }
552
712
 
553
- return finalize({ success: true, cleaned, active, dryRun, survivors, dirty }, masterSync);
713
+ return finalize({ success: true, cleaned, active, dryRun, survivors, dirty, closedIssues }, masterSync);
554
714
  }
555
715
 
556
716
  /**
@@ -576,8 +736,12 @@ module.exports = {
576
736
  normalizeWorktreeKey,
577
737
  parseWorktreeList,
578
738
  parseMainWorktree,
579
- getGhMergedRefs,
739
+ getGhMergedPrs,
740
+ prEvidenceFor,
741
+ createIssueCloser,
580
742
  isSquashMerged,
743
+ hasMergedPrEvidence,
744
+ isUnstartedBranch,
581
745
  detectMerged,
582
746
  isWorktreeDirty,
583
747
  removeWorktreeRobust,
@@ -460,50 +460,21 @@ function withDevAuditEvidence(featureName, phase, result, options = {}) {
460
460
  },
461
461
  };
462
462
 
463
+ // Evidence is best-effort: it records what the dev cycle did, so a log the
464
+ // process could not write never changes the outcome it was recording.
463
465
  try {
464
- const auditEvidence = emitImplementerAuditEvidence(auditEvent, options.auditOptions || {});
465
- if (auditEvidence.record?.success === false) {
466
- const auditError = auditEvidence.record.error || 'bd audit record failed';
467
- return {
468
- ...result,
469
- success: false,
470
- error: result.success === false ? result.error : `Audit evidence persistence failed: ${auditError}`,
471
- auditEvidence,
472
- };
473
- }
474
-
475
466
  return {
476
467
  ...result,
477
- auditEvidence,
468
+ auditEvidence: emitImplementerAuditEvidence(auditEvent, options.auditOptions || {}),
478
469
  };
479
470
  } catch (error) {
480
- if (isAuditCommandUnavailableError(error)) {
481
- return {
482
- ...result,
483
- auditEvidence: {
484
- success: false,
485
- skipped: true,
486
- error: error.message,
487
- },
488
- };
489
- }
490
-
491
471
  return {
492
472
  ...result,
493
- success: false,
494
- error: result.success === false ? result.error : `Audit evidence persistence failed: ${error.message}`,
495
- auditEvidence: {
496
- success: false,
497
- error: error.message,
498
- },
473
+ auditEvidence: { success: false, error: error.message },
499
474
  };
500
475
  }
501
476
  }
502
477
 
503
- function isAuditCommandUnavailableError(error) {
504
- return error?.code === 'ENOENT' || /\bbd\b.*not found/i.test(error?.message || '');
505
- }
506
-
507
478
  /**
508
479
  * Decision gate route constants
509
480
  *