forge-workflow 0.1.0-beta.3 → 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 (196) 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 +21 -1
  5. package/bin/forge.js +16 -369
  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 +9 -4
  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 +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -26,7 +26,6 @@ const commands = [
26
26
  { file: "add.js", module: require("./add") },
27
27
  { file: "audit.js", module: require("./audit") },
28
28
  { file: "blocked.js", module: require("./blocked") },
29
- { file: "board.js", module: require("./board") },
30
29
  { file: "claim.js", module: require("./claim") },
31
30
  { file: "claims.js", module: require("./claims") },
32
31
  { file: "clean.js", module: require("./clean") },
@@ -73,6 +72,7 @@ const commands = [
73
72
  { file: "shepherd.js", module: require("./shepherd") },
74
73
  { file: "ship.js", module: require("./ship") },
75
74
  { file: "show.js", module: require("./show") },
75
+ { file: "skill.js", module: require("./skill") },
76
76
  { file: "stage.js", module: require("./stage") },
77
77
  { file: "stale.js", module: require("./stale") },
78
78
  { file: "status.js", module: require("./status") },
@@ -201,8 +201,8 @@ async function executeCommand(commands, commandName, args, flags, projectRoot, o
201
201
  }
202
202
 
203
203
  // Lazy `.forge/` home (activation foundation): the FIRST mutating verb in a
204
- // bare repo materializes the gates-disabled skeleton on demand. Read-only
205
- // verbs never enter this branch, so they write nothing; an already-inited
204
+ // bare repo materializes the default-enabled config skeleton on demand.
205
+ // Read-only verbs never enter this branch, so they write nothing; an already-inited
206
206
  // repo is a no-op (never clobbered). Failure to create the home must not
207
207
  // crash the command — degrade to a warning. Opt out via `skipEnsureHome`.
208
208
  if (projectRoot && options.skipEnsureHome !== true && isMutatingVerb(commandName, command)) {
@@ -5,15 +5,17 @@
5
5
  * handler (the 4th arg). For issue/alias commands this:
6
6
  *
7
7
  * 1. Strips the selector tokens (`--kernel`, `--issue-backend <val>`) from the
8
- * args BEFORE they reach the handler / bd. This is load-bearing: bin
9
- * parseFlags() early-returns for issue passthrough commands, so these tokens
10
- * otherwise flow untouched into the handler and (beads path) into bd, which
11
- * rejects the unknown flag — and the positional `kernel`/`beads` value would
12
- * be mistaken for an issue id.
8
+ * args BEFORE they reach the handler. This is load-bearing: bin parseFlags()
9
+ * early-returns for issue passthrough commands, so these tokens otherwise flow
10
+ * untouched into the handler and the positional `kernel` value would be
11
+ * mistaken for an issue id.
13
12
  * 2. Resolves the backend (flag > env > config > default) via the backend
14
13
  * authority module (lib/issue-backend.js). The CLI flag tokens are mapped to
15
14
  * that module's `deps.issueBackend` signal; env/config/default precedence is
16
- * applied by the resolver itself.
15
+ * applied by the resolver itself. `kernel` is the only accepted value — a
16
+ * retired backend named on the FLAG is a hard error (the user typed it in this
17
+ * invocation, so silently ignoring it would run the wrong store), while the
18
+ * same value in env/config only warns and falls back.
17
19
  * 3. When kernel, assembles the kernel deps (driver + flag) via the CLI broker
18
20
  * factory so createKernelIssueBackend has a real driver (B1).
19
21
  *
@@ -24,7 +26,7 @@
24
26
  * @module commands/resolve-command-opts
25
27
  */
26
28
 
27
- const { resolveIssueBackend } = require('../issue-backend');
29
+ const { resolveIssueBackend, removedBackendHint } = require('../issue-backend');
28
30
  const {
29
31
  buildMigratedKernelIssueDeps: defaultBuildKernelIssueDeps,
30
32
  } = require('../kernel/cli-broker-factory');
@@ -45,14 +47,11 @@ const ISSUE_COMMANDS = new Set([
45
47
  'stale',
46
48
  'orphans',
47
49
  'lint',
48
- // Epic grouping read — must be kernel-routed (its issue.children rollup has no
49
- // verified Beads equivalent), so it belongs in ISSUE_COMMANDS like the rest.
50
+ // Epic grouping read (issue.children rollup).
50
51
  'children',
51
- // Lease-ownership verification — a kernel-only read (leases live in the Kernel),
52
- // so it is kernel-routed like the rest of the issue surface.
52
+ // Lease-ownership verification (leases live in the Kernel).
53
53
  'owns',
54
- // Active-lease listing — a kernel-only read (leases live in the Kernel), so it
55
- // is kernel-routed like the rest of the issue surface.
54
+ // Active-lease listing (leases live in the Kernel).
56
55
  'claims',
57
56
  'search',
58
57
  'stats',
@@ -101,7 +100,7 @@ function stripSelectorTokens(rawArgs = []) {
101
100
  flags.issueBackend = next;
102
101
  i += 1;
103
102
  } else {
104
- throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel or beads.`);
103
+ throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
105
104
  }
106
105
  continue;
107
106
  }
@@ -109,7 +108,7 @@ function stripSelectorTokens(rawArgs = []) {
109
108
  // Equals form: reject an empty value (`--issue-backend=`) for the same reason.
110
109
  const value = token.slice(ISSUE_BACKEND_FLAG.length + 1).trim();
111
110
  if (!value) {
112
- throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel or beads.`);
111
+ throw new Error(`${ISSUE_BACKEND_FLAG} requires a value: kernel.`);
113
112
  }
114
113
  flags.issueBackend = value;
115
114
  continue;
@@ -122,12 +121,14 @@ function stripSelectorTokens(rawArgs = []) {
122
121
  /**
123
122
  * Reduce the stripped selector flags to a single explicit backend value (or null
124
123
  * when no flag selects one), honoring `--kernel` / `--issue-backend` equivalence
125
- * and rejecting a mutually-exclusive conflict. Returned values are NOT validated
126
- * here — the backend authority module normalizes/warns on unknown values.
124
+ * and rejecting a mutually-exclusive conflict. A RETIRED backend value is rejected
125
+ * here with the migrate pointer; any other value is NOT validated here — the backend
126
+ * authority module normalizes/warns on unknown values.
127
127
  *
128
128
  * @param {{ kernel?: boolean, issueBackend?: string }} flags
129
129
  * @returns {string|null}
130
- * @throws {Error} when --kernel and --issue-backend select different backends.
130
+ * @throws {Error} when --kernel and --issue-backend select different backends, or
131
+ * when --issue-backend names a removed backend.
131
132
  */
132
133
  function resolveFlagBackend(flags = {}) {
133
134
  const fromBackend = typeof flags.issueBackend === 'string' && flags.issueBackend.trim()
@@ -142,6 +143,19 @@ function resolveFlagBackend(flags = {}) {
142
143
  );
143
144
  }
144
145
 
146
+ // Hard error (not the resolver's warn+fallback) because the value was typed into
147
+ // THIS invocation: silently running the kernel after the user explicitly asked for
148
+ // a different store is the kind of surprise that loses writes.
149
+ if (fromBackend) {
150
+ const removedHint = removedBackendHint(fromBackend);
151
+ if (removedHint) {
152
+ throw new Error(
153
+ `${ISSUE_BACKEND_FLAG} ${fromBackend} is no longer supported: ${removedHint}. `
154
+ + `Only '${KERNEL}' is accepted.`,
155
+ );
156
+ }
157
+ }
158
+
145
159
  return fromKernel || fromBackend || null;
146
160
  }
147
161
 
@@ -188,22 +202,15 @@ async function resolveCommandOpts(command, rawArgs = [], deps = {}) {
188
202
 
189
203
  const { args, flags } = stripSelectorTokens(rawArgs);
190
204
  const flagBackend = resolveFlagBackend(flags);
191
- const issueBackend = resolveIssueBackend({
205
+ // Still routed through the authority module (rather than hardcoding 'kernel') so a
206
+ // retired/unknown env or config value produces its warning exactly once, here, on
207
+ // the way to the kernel fallback.
208
+ resolveIssueBackend({
192
209
  deps: flagBackend ? { issueBackend: flagBackend } : {},
193
210
  env,
194
211
  projectRoot: deps.projectRoot,
195
212
  });
196
213
 
197
- if (issueBackend !== KERNEL) {
198
- return {
199
- commandOpts: {
200
- issueBackend,
201
- useKernelBroker: false,
202
- },
203
- args,
204
- };
205
- }
206
-
207
214
  // Kernel backend: assemble the driver (B1) + migrated broker (B2). The factory
208
215
  // builder constructs the driver, builds the broker, and runs initialize().
209
216
  const kernelDeps = await buildKernelIssueDeps({
@@ -3,10 +3,8 @@
3
3
  const { runIssueSubcommand } = require('./_issue');
4
4
 
5
5
  // `forge claim <id>` claims an issue through the SHARED issue dispatch
6
- // (runIssueSubcommand): backend resolution (Kernel via --kernel /
7
- // --issue-backend kernel / FORGE_ISSUE_BACKEND=kernel, Beads otherwise; the
8
- // Beads backend translates claim to `update <id> --claim`), contract
9
- // normalization, AND the check-after-write verification loop
6
+ // (runIssueSubcommand): backend resolution (the Kernel is the only backend),
7
+ // contract normalization, AND the check-after-write verification loop
10
8
  // (gate.issue_verify). This command previously inlined its own copy of the
11
9
  // dispatch, which silently BYPASSED the boundary verify — the exact surface
12
10
  // where the d71a824b phantom-claim replay lied to a losing agent — so it now
@@ -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
  *