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

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 +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -13,6 +13,7 @@ const { existsSync, readdirSync } = require('node:fs');
13
13
  const path = require('node:path');
14
14
  const { normalizeStageId } = require('../workflow/stages');
15
15
  const { ensureForgeHome, isMutatingVerb } = require('../activation/ensure-forge-home');
16
+ const { createGithubContext } = require('../github-context');
16
17
 
17
18
  // Static command manifest (bundleable fast path). This is a static require so
18
19
  // `bun build --compile` can bundle the command graph; the file is generated by
@@ -36,6 +37,7 @@ try {
36
37
  * @property {function(Array, Object, string, Object=): Promise<*>} handler - Async command handler (4th arg: resolved command opts)
37
38
  * @property {string} [usage] - Usage string (optional)
38
39
  * @property {Object<string, string>} [flags] - Flag descriptions (optional)
40
+ * @property {boolean|function(Array, Object, Object): boolean} [githubAuth] - Whether this invocation needs repository GitHub context
39
41
  */
40
42
 
41
43
  /**
@@ -57,6 +59,9 @@ function validateCommand(mod) {
57
59
  if (typeof mod.handler !== 'function') {
58
60
  return { valid: false, reason: 'missing or invalid "handler" export' };
59
61
  }
62
+ if (mod.githubAuth !== undefined && !['boolean', 'function'].includes(typeof mod.githubAuth)) {
63
+ return { valid: false, reason: 'invalid "githubAuth" export: expected boolean or function' };
64
+ }
60
65
  return { valid: true };
61
66
  }
62
67
 
@@ -174,6 +179,31 @@ function isStageCommand(commandName) {
174
179
  return normalizeStageId(commandName) !== null;
175
180
  }
176
181
 
182
+ function isHelpInvocation(args, flags) {
183
+ if (flags.help || flags['--help'] || flags['-h']) return true;
184
+ // Child arguments belong to the launcher, including its help flags.
185
+ const delimiter = args.indexOf('--');
186
+ const ownArgs = delimiter === -1 ? args : args.slice(0, delimiter);
187
+ return ownArgs.includes('--help') || ownArgs.includes('-h');
188
+ }
189
+
190
+ async function enforceCommandStage(options, commandName, args, flags, projectRoot, command) {
191
+ if (typeof options.enforceStage !== 'function' || !isStageCommand(commandName)) return null;
192
+ return options.enforceStage({ commandName, args, flags, projectRoot, command });
193
+ }
194
+
195
+ async function resolveCommandOptions(command, commandName, args, flags, projectRoot, options) {
196
+ const commandOpts = options.commandOpts ?? {};
197
+ if (!command.githubAuth || isHelpInvocation(args, flags)) return commandOpts;
198
+ const required = typeof command.githubAuth === 'function'
199
+ ? await command.githubAuth(args, flags, { commandName, projectRoot, command })
200
+ : command.githubAuth;
201
+ if (typeof required !== 'boolean') throw new TypeError('Invalid GitHub auth predicate result');
202
+ if (!required) return commandOpts;
203
+ const githubContext = await (options.prepareGithubContext || createGithubContext)(projectRoot);
204
+ return { ...commandOpts, githubContext };
205
+ }
206
+
177
207
  async function executeCommand(commands, commandName, args, flags, projectRoot, options = {}) {
178
208
  const command = commands.get(commandName);
179
209
  if (!command) {
@@ -181,28 +211,18 @@ async function executeCommand(commands, commandName, args, flags, projectRoot, o
181
211
  }
182
212
 
183
213
  try {
184
- let enforcement = null;
185
- if (typeof options.enforceStage === 'function' && isStageCommand(commandName)) {
186
- enforcement = await options.enforceStage({
187
- commandName,
188
- args,
189
- flags,
190
- projectRoot,
191
- command,
192
- });
193
-
194
- if (enforcement?.allowed === false) {
195
- return {
196
- success: false,
197
- error: enforcement.error ?? `Stage ${commandName} is blocked.`,
198
- enforcement,
199
- };
200
- }
214
+ const enforcement = await enforceCommandStage(options, commandName, args, flags, projectRoot, command);
215
+ if (enforcement?.allowed === false) {
216
+ return {
217
+ success: false,
218
+ error: enforcement.error ?? `Stage ${commandName} is blocked.`,
219
+ enforcement,
220
+ };
201
221
  }
202
222
 
203
223
  // 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
224
+ // bare repo materializes the default-enabled config skeleton on demand.
225
+ // Read-only verbs never enter this branch, so they write nothing; an already-inited
206
226
  // repo is a no-op (never clobbered). Failure to create the home must not
207
227
  // crash the command — degrade to a warning. Opt out via `skipEnsureHome`.
208
228
  if (projectRoot && options.skipEnsureHome !== true && isMutatingVerb(commandName, command)) {
@@ -217,7 +237,17 @@ async function executeCommand(commands, commandName, args, flags, projectRoot, o
217
237
  // alias handlers read it (shouldUseKernelBroker(opts)); other handlers ignore
218
238
  // it. Backward-compatible: defaults to {} so existing 3-arg handlers are
219
239
  // unaffected.
220
- const result = await command.handler(args, flags, projectRoot, options.commandOpts ?? {});
240
+ let commandOpts;
241
+ try {
242
+ commandOpts = await resolveCommandOptions(command, commandName, args, flags, projectRoot, options);
243
+ } catch {
244
+ // Even injected errors can contain subprocess output or credentials.
245
+ return {
246
+ success: false,
247
+ error: 'Unable to verify this clone\'s GitHub account. Run forge github status for recovery.',
248
+ };
249
+ }
250
+ const result = await command.handler(args, flags, projectRoot, commandOpts);
221
251
 
222
252
  // Record stage COMPLETION into the kernel only when the command SUCCEEDED, so
223
253
  // a stage counts as 'done' (which gates ship/review) only when its command
@@ -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,140 @@ 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
+
620
+ async function reconcileDeadClaims(projectRoot, runFile, opts = {}) {
621
+ try {
622
+ if (typeof opts._reconcileClaims === 'function') {
623
+ return await opts._reconcileClaims({ projectRoot });
624
+ }
625
+
626
+ let driver = opts._kernelDriver;
627
+ let owned = false;
628
+ if (!driver) {
629
+ const commonDirOutput = tryRun(runFile, 'git', ['-C', projectRoot, 'rev-parse', '--git-common-dir']);
630
+ if (!commonDirOutput) {
631
+ return { examined: 0, released: [] };
632
+ }
633
+ const gitCommonDir = path.isAbsolute(commonDirOutput)
634
+ ? commonDirOutput
635
+ : path.resolve(projectRoot, commonDirOutput);
636
+ const {
637
+ buildMigratedKernelIssueDeps,
638
+ resolveKernelDatabasePath,
639
+ } = require('../kernel/cli-broker-factory');
640
+ const databasePath = resolveKernelDatabasePath({ gitCommonDir });
641
+ const fsApi = opts._fs || fs;
642
+ if (!fsApi.existsSync(databasePath)) return { examined: 0, released: [] };
643
+ const deps = await buildMigratedKernelIssueDeps({ projectRoot, gitCommonDir, databasePath });
644
+ driver = deps.kernelDriver;
645
+ owned = true;
646
+ }
647
+ if (!driver) return { examined: 0, released: [] };
648
+
649
+ try {
650
+ const { reconcileKernelClaims } = require('../kernel/claim-reconciler');
651
+ return await reconcileKernelClaims({
652
+ driver,
653
+ fsApi: opts._fs || fs,
654
+ manifestDir: opts._manifestDir,
655
+ isProcessAlive: opts._isProcessAlive,
656
+ getProcessIdentity: opts._getProcessIdentity,
657
+ });
658
+ } finally {
659
+ if (owned && typeof driver.close === 'function') driver.close();
660
+ }
661
+ } catch (_e) { /* best-effort: unverifiable authority never aborts clean */ // NOSONAR S2486
662
+ return { examined: 0, released: [] };
663
+ }
664
+ }
665
+
479
666
  /**
480
667
  * Main handler for the clean command.
481
668
  * @param {string[]} _args - Positional arguments (unused)
@@ -492,10 +679,11 @@ function formatOutput(summary) {
492
679
  * Scan .worktrees/ and remove the merged ones (squash-aware). Returns the tallies;
493
680
  * a no-op ({0,0,[],[]}) when .worktrees/ is absent or empty. Extracted from handler
494
681
  * so the top-level orchestration stays under the complexity gate.
495
- * @returns {Promise<{ cleaned: number, active: number, survivors: object[], dirty: string[] }>}
682
+ * @param {{close: Function}|null} [closer] - Close-on-merge bridge (null in dry-run)
683
+ * @returns {Promise<{ cleaned: number, active: number, survivors: object[], dirty: string[], closedIssues: object[] }>}
496
684
  */
497
- async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
498
- const acc = { cleaned: 0, active: 0, survivors: [], dirty: [] };
685
+ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts, closer = null) {
686
+ const acc = { cleaned: 0, active: 0, survivors: [], dirty: [], closedIssues: [] };
499
687
  if (!fsApi.existsSync(worktreesDir)) return acc;
500
688
 
501
689
  const entries = fsApi.readdirSync(worktreesDir, { withFileTypes: true });
@@ -512,8 +700,11 @@ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
512
700
  // ancestry fast path instead of falling through to the slower squash tier.
513
701
  mergedBranches = mergedOut.split('\n').map(b => b.trim().replace(/^[*+]\s*/, '')).filter(Boolean);
514
702
  }
515
- const ghMergedRefs = getGhMergedRefs(runFile);
516
- const ctx = { defaultBranch, mergedBranches, ghMergedRefs, runFile };
703
+ const ghRunner = opts.githubContext?.bound
704
+ ? (_cmd, args, options) => opts.githubContext.runGh(args, options)
705
+ : runFile;
706
+ const ghMergedPrs = getGhMergedPrs(ghRunner);
707
+ const ctx = { defaultBranch, mergedBranches, ghMergedPrs, runFile };
517
708
  const isMergedFn = opts._isMerged || (branch => detectMerged(branch, ctx));
518
709
 
519
710
  const listOutput = tryRun(runFile, 'git', ['worktree', 'list', '--porcelain']);
@@ -525,6 +716,16 @@ async function cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts) {
525
716
  else if (res.status === 'survivor') acc.survivors.push(res);
526
717
  else if (res.status === 'dirty') acc.dirty.push(res.path);
527
718
  else acc.active++;
719
+
720
+ // Close only what THIS outcome authorized (18f1988e, tightened by c5ab529e):
721
+ // `closeIssue` is set by cleanWorktree alongside the removal decision, so a
722
+ // worktree held back for any reason cannot have its issue closed.
723
+ if (closer && res.branch && res.closeIssue) {
724
+ const outcome = await closer.close(res.branch, prEvidenceFor(ghMergedPrs, res.branch));
725
+ if (outcome && outcome.closed) {
726
+ acc.closedIssues.push({ branch: res.branch, issueId: outcome.issueId });
727
+ }
728
+ }
528
729
  }
529
730
 
530
731
  if (!dryRun && acc.cleaned > 0) {
@@ -541,7 +742,20 @@ async function handler(_args, flags, projectRoot, opts = {}) {
541
742
  const worktreesDir = path.resolve(projectRoot, '.worktrees');
542
743
 
543
744
  // 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);
745
+ // A dry run detects merges but must not mutate the kernel, so it gets no closer.
746
+ const closer = dryRun ? null : createIssueCloser(projectRoot, runFile, opts);
747
+ let scan;
748
+ try {
749
+ scan = await cleanWorktrees(worktreesDir, runFile, fsApi, dryRun, opts, closer);
750
+ } finally {
751
+ if (closer) closer.dispose();
752
+ }
753
+ const { cleaned, active, survivors, dirty, closedIssues } = scan;
754
+
755
+ // Claim repair is an explicit clean-lifecycle maintenance action. Run after
756
+ // worktree removal so a newly-missing linked checkout can be observed, while
757
+ // dry-run remains mutation-free. Evidence gaps and Kernel failures fail closed.
758
+ if (!dryRun) await reconcileDeadClaims(projectRoot, runFile, opts);
545
759
 
546
760
  // Post-merge master auto-update (default-on; skipped in dry-run).
547
761
  let masterSync = null;
@@ -550,7 +764,7 @@ async function handler(_args, flags, projectRoot, opts = {}) {
550
764
  masterSync = await doSync();
551
765
  }
552
766
 
553
- return finalize({ success: true, cleaned, active, dryRun, survivors, dirty }, masterSync);
767
+ return finalize({ success: true, cleaned, active, dryRun, survivors, dirty, closedIssues }, masterSync);
554
768
  }
555
769
 
556
770
  /**
@@ -563,6 +777,7 @@ function finalize(summary, masterSync) {
563
777
 
564
778
  module.exports = {
565
779
  name: 'clean',
780
+ githubAuth: true,
566
781
  description: 'Remove worktrees for merged branches (squash-aware) and fast-forward the default branch',
567
782
  usage: 'forge clean [--dry-run] [--no-master-sync]',
568
783
  flags: {
@@ -576,8 +791,13 @@ module.exports = {
576
791
  normalizeWorktreeKey,
577
792
  parseWorktreeList,
578
793
  parseMainWorktree,
579
- getGhMergedRefs,
794
+ getGhMergedPrs,
795
+ prEvidenceFor,
796
+ createIssueCloser,
797
+ reconcileDeadClaims,
580
798
  isSquashMerged,
799
+ hasMergedPrEvidence,
800
+ isUnstartedBranch,
581
801
  detectMerged,
582
802
  isWorktreeDirty,
583
803
  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
  *
@@ -38,10 +38,39 @@ function buildMemoryCheck(projectRoot, env) {
38
38
  };
39
39
  }
40
40
  const serverPath = graphiti.mcpServerPath;
41
- const serverPathExists = fs.existsSync(serverPath);
42
- const reach = serverPathExists
43
- ? `mcp_server present at ${serverPath}`
44
- : `mcp_server path not found locally (${serverPath}); run it before agents can use graph memory`;
41
+ const path = require('node:path');
42
+ const resolvedServerPath = path.isAbsolute(serverPath)
43
+ ? serverPath
44
+ : path.resolve(projectRoot, serverPath);
45
+ const serverPathExists = fs.existsSync(resolvedServerPath);
46
+ let serverPathIsDirectory = false;
47
+ if (serverPathExists) {
48
+ try {
49
+ serverPathIsDirectory = fs.statSync(resolvedServerPath).isDirectory();
50
+ } catch {
51
+ serverPathIsDirectory = false;
52
+ }
53
+ }
54
+ const entrypointPath = path.join(resolvedServerPath, 'main.py');
55
+ let entrypointExists = false;
56
+ if (serverPathIsDirectory) {
57
+ try {
58
+ entrypointExists = fs.statSync(entrypointPath).isFile();
59
+ } catch {
60
+ entrypointExists = false;
61
+ }
62
+ }
63
+ const serverReady = serverPathExists && serverPathIsDirectory && entrypointExists;
64
+ let reach;
65
+ if (!serverPathExists) {
66
+ reach = `mcp_server path not found locally (${serverPath}); run it before agents can use graph memory`;
67
+ } else if (!serverPathIsDirectory) {
68
+ reach = `mcp_server path is not a directory (${serverPath})`;
69
+ } else if (!entrypointExists) {
70
+ reach = `mcp_server entrypoint not found (${path.join(serverPath, 'main.py')})`;
71
+ } else {
72
+ reach = `mcp_server present at ${serverPath}`;
73
+ }
45
74
  return {
46
75
  // Reflect reality in the line symbol: a graphiti backend whose MCP server
47
76
  // isn't present yet renders as `!` (warn), not a misleading `✓`. This does
@@ -49,13 +78,15 @@ function buildMemoryCheck(projectRoot, env) {
49
78
  // filesystem-class check (see buildDoctorReport), so an uninstalled opt-in
50
79
  // memory server warns without failing `forge doctor`.
51
80
  id: 'memory-backend',
52
- ok: serverPathExists,
81
+ ok: serverReady,
53
82
  backend,
54
83
  serverPathExists,
84
+ serverPathIsDirectory,
85
+ entrypointExists,
55
86
  detail: `memory backend: graphiti — served to agents via the graphiti-memory MCP server; ${reach}`,
56
87
  };
57
88
  } catch (err) {
58
- const backend = memoryRouter.resolveMemoryBackend({ projectRoot, env, warn: () => {} });
89
+ const backend = err.backend || memoryRouter.resolveMemoryBackend({ projectRoot, env, warn: () => {} });
59
90
  return { id: 'memory-backend', ok: false, backend, detail: err.message };
60
91
  }
61
92
  }