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
@@ -3,7 +3,12 @@
3
3
  const { execFileSync, spawnSync } = require('node:child_process');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
- const { detectDefaultBranch } = require('../beads-sync-scaffold');
6
+ const { detectDefaultBranch } = require('../git-defaults');
7
+ const {
8
+ resolveRepoRelativePath,
9
+ assertNoAncestorSymlinkEscape,
10
+ assertNoSymlinkEscape,
11
+ } = require('../protected-state-surfaces');
7
12
 
8
13
  /**
9
14
  * Forge Worktree Command
@@ -42,12 +47,13 @@ function detectPackageManager(projectRoot, fsApi) {
42
47
  /**
43
48
  * Check if a git branch already exists.
44
49
  * @param {string} branchName - Branch name to check
50
+ * @param {string} projectRoot - Repo root to run git in
45
51
  * @param {Function} runFile - execFileSync function (for DI)
46
52
  * @returns {boolean}
47
53
  */
48
- function branchExists(branchName, runFile) {
54
+ function branchExists(branchName, projectRoot, runFile) {
49
55
  try {
50
- const output = runFile('git', ['branch', '--list', branchName], { stdio: 'pipe' });
56
+ const output = runFile('git', ['-C', projectRoot, 'branch', '--list', branchName], { stdio: 'pipe' });
51
57
  return output.toString().trim().length > 0;
52
58
  } catch (_err) { /* intentional: branch doesn't exist */ // NOSONAR S2486
53
59
  return false;
@@ -99,35 +105,226 @@ function isLinkPermissionError(error) {
99
105
  return ['EPERM', 'EACCES', 'ENOSYS', 'UV_EPERM'].includes(error && error.code);
100
106
  }
101
107
 
108
+ // Yarn is probe-less here: a Plug'n'Play install produces no node_modules tree to
109
+ // inspect, and `yarn check` was removed in Berry, so there is nothing to verify.
110
+ const PROBE_LESS_MANAGERS = new Set(['yarn']);
111
+
112
+ function samePath(left, right) {
113
+ return path.relative(left, right) === '';
114
+ }
115
+
102
116
  /**
103
- * Run package install in the new worktree if a package manager is detected, and
104
- * SURFACE failures (spawn error or non-zero exit) instead of swallowing them.
105
- * @param {string} worktreePath - Absolute path to the new worktree
106
- * @param {string} projectRoot - Absolute path to the project root
107
- * @param {Function} spawnFn - spawnSync-compatible function
117
+ * Direct dependencies the root and its workspaces declare. Optional/peer deps
118
+ * are excluded — they may legitimately be absent. Workspace discovery fails
119
+ * closed so an incomplete manifest set cannot prove an install complete.
120
+ * @param {string} worktreePath - Absolute path to the worktree
108
121
  * @param {object} fsApi - fs module (for DI)
109
- * @returns {{ linked: boolean, installed: boolean }}
110
- * @throws {Error} when the install cannot be spawned or exits non-zero.
122
+ * @returns {Array<{dir: string, name: string, workspaceDir: string|null}>} Declaring directory and package name
111
123
  */
112
- function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
113
- const pkgManager = detectPackageManager(projectRoot, fsApi);
114
- if (!pkgManager) return { linked: false, installed: false };
124
+ function declaredDependencies(worktreePath, fsApi) {
125
+ let rootPkg;
126
+ try {
127
+ rootPkg = JSON.parse(fsApi.readFileSync(path.join(worktreePath, 'package.json'), 'utf8'));
128
+ } catch (error) {
129
+ throw new Error(`Cannot verify dependencies: unreadable worktree package.json: ${error.message}`);
130
+ }
131
+
132
+ let workspacePatterns = [];
133
+ if (rootPkg.workspaces != null) {
134
+ workspacePatterns = Array.isArray(rootPkg.workspaces)
135
+ ? rootPkg.workspaces
136
+ : rootPkg.workspaces.packages;
137
+ if (!Array.isArray(workspacePatterns)) {
138
+ throw new Error('Cannot verify dependencies: workspaces must be an array or an object with a packages array');
139
+ }
140
+ }
141
+
142
+ if (workspacePatterns.length && typeof fsApi.globSync !== 'function') {
143
+ throw new Error('Cannot verify dependencies: this runtime does not provide workspace glob support');
144
+ }
145
+
146
+ const manifestPaths = new Set();
147
+ for (const rawPattern of workspacePatterns) {
148
+ if (typeof rawPattern !== 'string' || !rawPattern.trim()) {
149
+ throw new Error(`Cannot verify dependencies: invalid workspace pattern ${JSON.stringify(rawPattern)}`);
150
+ }
151
+ const exclude = rawPattern.startsWith('!');
152
+ const pattern = (exclude ? rawPattern.slice(1) : rawPattern).replace(/\\/g, '/').replace(/\/+$/, '');
153
+ const resolvedPattern = resolveRepoRelativePath(worktreePath, pattern);
154
+ if (!pattern || pattern.includes('\0') || path.isAbsolute(pattern) || !resolvedPattern.insideRoot) {
155
+ throw new Error(`Cannot verify dependencies: workspace pattern resolves outside the worktree: ${rawPattern}`);
156
+ }
115
157
 
158
+ let matches;
159
+ try {
160
+ matches = fsApi.globSync(`${pattern}/package.json`, {
161
+ cwd: worktreePath,
162
+ exclude: candidate => String(candidate).split(/[\\/]/).includes('node_modules'),
163
+ });
164
+ } catch (error) {
165
+ throw new Error(`Cannot verify dependencies: workspace glob failed for ${rawPattern}: ${error.message}`);
166
+ }
167
+ for (const match of matches) {
168
+ const resolved = resolveRepoRelativePath(worktreePath, String(match));
169
+ if (!resolved.insideRoot) {
170
+ throw new Error(`Cannot verify dependencies: workspace glob resolved outside the worktree: ${match}`);
171
+ }
172
+ const existing = [...manifestPaths].find(manifestPath => samePath(manifestPath, resolved.target));
173
+ if (exclude && existing) manifestPaths.delete(existing);
174
+ else if (!exclude && !existing) manifestPaths.add(resolved.target);
175
+ }
176
+ }
177
+
178
+ const manifests = [{ dir: worktreePath, pkg: rootPkg }];
179
+ for (const manifestPath of manifestPaths) {
180
+ try {
181
+ manifests.push({
182
+ dir: path.dirname(manifestPath),
183
+ pkg: JSON.parse(fsApi.readFileSync(manifestPath, 'utf8')),
184
+ });
185
+ } catch (error) {
186
+ if (error && ['ENOENT', 'ENOTDIR'].includes(error.code)) continue;
187
+ throw new Error(`Cannot verify dependencies: unreadable workspace manifest ${manifestPath}: ${error.message}`);
188
+ }
189
+ }
190
+
191
+ const workspaceDirs = new Map();
192
+ for (const { dir, pkg } of manifests.slice(1)) {
193
+ if (!pkg.name) continue;
194
+ if (workspaceDirs.has(pkg.name) && !samePath(workspaceDirs.get(pkg.name), dir)) {
195
+ throw new Error(`Cannot verify dependencies: duplicate workspace package name ${pkg.name}`);
196
+ }
197
+ workspaceDirs.set(pkg.name, dir);
198
+ }
199
+ return manifests.flatMap(({ dir, pkg }) => [
200
+ ...Object.keys(pkg.dependencies || {}),
201
+ ...Object.keys(pkg.devDependencies || {}),
202
+ ].map(name => ({ dir, name, workspaceDir: workspaceDirs.get(name) || null })));
203
+ }
204
+
205
+ function dependencyCandidatePresent(candidate, workspaceDir, fsApi) {
206
+ if (!fsApi.existsSync(path.join(candidate, 'package.json'))) return false;
207
+ if (!workspaceDir) return true;
208
+ try {
209
+ return path.relative(fsApi.realpathSync(workspaceDir), fsApi.realpathSync(candidate)) === '';
210
+ } catch (_err) { /* intentional: an unresolvable workspace link is not installed */ // NOSONAR S2486
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Verify the install actually landed: every declared direct dependency must be a
217
+ * real package in the worktree's node_modules.
218
+ *
219
+ * The package managers cannot answer this. `bun pm ls`, `bun list`, `bun pm why`
220
+ * and `bun install --dry-run` all read the LOCKFILE and exit 0 while packages are
221
+ * physically missing; `pnpm ls --depth=0` does the same; `bun -e require.resolve`
222
+ * is defeated by Bun's auto-install. Only `npm ls --depth=0` exits non-zero, so a
223
+ * filesystem check is the one probe that holds for every manager.
224
+ *
225
+ * @param {string} worktreePath - Absolute path to the worktree
226
+ * @param {object} fsApi - fs module (for DI)
227
+ * @param {string|null} pkgManager - Detected package manager
228
+ * @returns {boolean} true when nothing is missing
229
+ */
230
+ function dependenciesPresent(worktreePath, fsApi, pkgManager) {
231
+ if (!pkgManager) return true;
232
+ const deps = declaredDependencies(worktreePath, fsApi);
233
+ return deps.every(({ dir, name, workspaceDir }) => {
234
+ const localPackage = path.join(dir, 'node_modules', name);
235
+ if (fsApi.existsSync(localPackage)) {
236
+ return dependencyCandidatePresent(localPackage, workspaceDir, fsApi);
237
+ }
238
+
239
+ const rootPackage = path.join(worktreePath, 'node_modules', name);
240
+ if (dir === worktreePath || (pkgManager === 'bun' && !workspaceDir)) return false;
241
+ return dependencyCandidatePresent(rootPackage, workspaceDir, fsApi);
242
+ });
243
+ }
244
+
245
+ function detachModulesLink(modulesPath, fsApi) {
246
+ try {
247
+ if (!fsApi.lstatSync(modulesPath).isSymbolicLink()) return false;
248
+ fsApi.unlinkSync(modulesPath);
249
+ return true;
250
+ } catch (error) {
251
+ if (error && error.code === 'ENOENT') return false;
252
+ throw error;
253
+ }
254
+ }
255
+
256
+ const STDERR_DETAIL_LIMIT = 400;
257
+
258
+ /**
259
+ * The install's captured stderr, trimmed and bounded — stdio 'pipe' otherwise
260
+ * hides why the install failed.
261
+ * @param {object} result - spawnSync result
262
+ * @returns {string} A leading-newline detail block, or '' when there is nothing
263
+ */
264
+ function stderrDetail(result) {
265
+ const text = result && result.stderr ? String(result.stderr).trim() : '';
266
+ if (!text) return '';
267
+ return `\n${text.length > STDERR_DETAIL_LIMIT ? `${text.slice(0, STDERR_DETAIL_LIMIT)}...` : text}`;
268
+ }
269
+
270
+ /**
271
+ * Spawn one install, surfacing spawn errors, kills, and non-zero exits.
272
+ * @param {string} pkgManager - Detected package manager command
273
+ * @param {string[]} args - Install args (e.g. ['install'] or ['install', '--force'])
274
+ * @param {string} worktreePath - cwd for the install
275
+ * @param {Function} spawnFn - spawnSync-compatible function
276
+ * @throws {Error} when the install cannot be spawned, is killed, or exits non-zero.
277
+ */
278
+ function spawnInstall(pkgManager, args, worktreePath, spawnFn) {
116
279
  // shell:true on Windows: npm/pnpm/yarn are .cmd shims that cannot be spawned
117
280
  // directly (ENOENT / EINVAL). pkgManager comes from lockfile detection (fixed
118
281
  // set) and args are hardcoded, so no user input reaches the shell.
119
- const result = spawnFn(pkgManager, ['install'], {
282
+ const result = spawnFn(pkgManager, args, {
120
283
  cwd: worktreePath,
121
284
  stdio: 'pipe',
122
285
  shell: process.platform === 'win32',
123
286
  });
287
+ const label = `${pkgManager} ${args.join(' ')}`;
124
288
  if (result && result.error) {
125
- throw new Error(`Dependency install failed: could not run '${pkgManager} install' in ${worktreePath}: ${result.error.message}`);
289
+ throw new Error(`Dependency install failed: could not run '${label}' in ${worktreePath}: ${result.error.message}${stderrDetail(result)}`);
290
+ }
291
+ // A killed install (OOM, SIGTERM, CI cancel) reports status null with a signal
292
+ // set — never a non-zero code — so it must be checked on its own.
293
+ if (result && result.signal) {
294
+ throw new Error(`Dependency install failed: '${label}' was killed by ${result.signal} in ${worktreePath}${stderrDetail(result)}`);
126
295
  }
127
296
  if (result && typeof result.status === 'number' && result.status !== 0) {
128
- throw new Error(`Dependency install failed: '${pkgManager} install' exited with code ${result.status} in ${worktreePath}`);
297
+ throw new Error(`Dependency install failed: '${label}' exited with code ${result.status} in ${worktreePath}${stderrDetail(result)}`);
129
298
  }
130
- return { linked: false, installed: true };
299
+ }
300
+
301
+ /**
302
+ * Run package install in the new worktree if a package manager is detected, then
303
+ * VERIFY it. A stale shared store makes a plain install report "no changes" while
304
+ * leaving packages and binaries missing, so the exit code alone is not evidence —
305
+ * on a failed probe, reinstall once with --force and re-probe.
306
+ * @param {string} worktreePath - Absolute path to the new worktree
307
+ * @param {string} projectRoot - Absolute path to the project root
308
+ * @param {Function} spawnFn - spawnSync-compatible function
309
+ * @param {object} fsApi - fs module (for DI)
310
+ * @param {string|null} [pkgManager] - Detected package manager
311
+ * @returns {{ linked: boolean, installed: boolean, healed: boolean }}
312
+ * @throws {Error} when the install fails, or the tree is still broken after --force.
313
+ */
314
+ function runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager = detectPackageManager(projectRoot, fsApi)) {
315
+ if (!pkgManager) return { linked: false, installed: false, healed: false };
316
+
317
+ spawnInstall(pkgManager, ['install'], worktreePath, spawnFn);
318
+
319
+ if (PROBE_LESS_MANAGERS.has(pkgManager) || dependenciesPresent(worktreePath, fsApi, pkgManager)) {
320
+ return { linked: false, installed: true, healed: false };
321
+ }
322
+
323
+ spawnInstall(pkgManager, ['install', '--force'], worktreePath, spawnFn);
324
+ if (!dependenciesPresent(worktreePath, fsApi, pkgManager)) {
325
+ throw new Error(`Dependency install could not be verified: packages are still missing in ${worktreePath} after '${pkgManager} install --force'. Run it manually there and inspect the output.`);
326
+ }
327
+ return { linked: false, installed: true, healed: true };
131
328
  }
132
329
 
133
330
  /**
@@ -142,30 +339,110 @@ function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
142
339
  * @param {Function} deps.spawnFn - spawnSync-compatible function
143
340
  * @param {object} deps.fsApi - fs module (for DI)
144
341
  * @param {string} deps.platform - process.platform value
145
- * @returns {{ linked: boolean, installed: boolean }}
342
+ * @returns {{ linked: boolean, installed: boolean, healed: boolean }}
146
343
  * @throws {Error} when linking fails for a non-privilege reason or install fails.
147
344
  */
148
345
  function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform }) {
149
346
  const srcModules = path.join(projectRoot, 'node_modules');
150
347
  const destModules = path.join(worktreePath, 'node_modules');
348
+ const pkgManager = detectPackageManager(projectRoot, fsApi);
349
+
350
+ // Keep the zero-install fast path only when root and workspace dependencies
351
+ // are complete; Bun's isolated linker keeps workspace packages below each workspace.
352
+ if (fsApi.existsSync(destModules)) {
353
+ if (dependenciesPresent(worktreePath, fsApi, pkgManager)) return { linked: false, installed: false, healed: false };
354
+ detachModulesLink(destModules, fsApi);
355
+ return runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager);
356
+ }
151
357
 
152
- // Already populated (e.g. git checkout carried it) — nothing to do.
153
- if (fsApi.existsSync(destModules)) return { linked: false, installed: false };
358
+ // existsSync follows links, so remove a dangling directory entry before
359
+ // either replacing it with the shared install or installing in its place.
360
+ detachModulesLink(destModules, fsApi);
154
361
 
155
362
  // Fast path: link to the shared install when the main repo has one.
156
363
  if (fsApi.existsSync(srcModules)) {
157
364
  const linkType = platform === 'win32' ? 'junction' : 'dir';
158
365
  try {
159
366
  fsApi.symlinkSync(srcModules, destModules, linkType);
160
- return { linked: true, installed: false };
161
367
  } catch (error) {
162
368
  // Degrade to a real install only when the link was refused for lack of
163
369
  // privilege/support; any other failure is surfaced to the caller.
164
370
  if (!isLinkPermissionError(error)) throw error;
371
+ return runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager);
165
372
  }
373
+ if (dependenciesPresent(worktreePath, fsApi, pkgManager)) {
374
+ return { linked: true, installed: false, healed: false };
375
+ }
376
+ fsApi.unlinkSync(destModules);
377
+ }
378
+
379
+ return runInstall(worktreePath, projectRoot, spawnFn, fsApi, pkgManager);
380
+ }
381
+
382
+ function readExistingWorktreeLinkage(driver, worktreePath, reuse) {
383
+ if (!reuse || !driver || typeof driver.getWorktreeLinkage !== 'function') return null;
384
+ return driver.getWorktreeLinkage({ path: worktreePath }) || null;
385
+ }
386
+
387
+ function detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse }) {
388
+ if (!reuse || !existing) return null;
389
+ const existingIssue = existing.issue_id || null;
390
+ const existingFolder = existing.work_folder || null;
391
+ const folderChanged = workFolder && existingFolder
392
+ && path.resolve(projectRoot, workFolder) !== path.resolve(projectRoot, existingFolder);
393
+ if (!((issueId && existingIssue && issueId !== existingIssue) || folderChanged)) return null;
394
+ return {
395
+ issueId: issueId || existingIssue,
396
+ workFolder: workFolder || existingFolder,
397
+ reason: 'worktree linkage mismatch; existing issue/work-folder was not changed',
398
+ conflict: true,
399
+ };
400
+ }
401
+
402
+ function isAbsoluteWorkFolder(workFolder) {
403
+ return path.isAbsolute(workFolder) || path.posix.isAbsolute(workFolder) || path.win32.isAbsolute(workFolder);
404
+ }
405
+
406
+ function markerPathConflict(reason) {
407
+ return { conflict: true, reason: `worktree linkage marker path rejected: ${reason}` };
408
+ }
409
+
410
+ function validateMarkerTarget(projectRoot, workFolder, fsApi) {
411
+ if (!workFolder) return null;
412
+ if (isAbsoluteWorkFolder(workFolder)) return markerPathConflict('work-folder must be repo-relative');
413
+
414
+ const resolved = resolveRepoRelativePath(projectRoot, workFolder);
415
+ if (!resolved.insideRoot) return markerPathConflict('work-folder escapes project root');
416
+ const ancestorEscape = assertNoAncestorSymlinkEscape(resolved.root, resolved.target);
417
+ if (ancestorEscape) return markerPathConflict(ancestorEscape.reason);
418
+
419
+ const statSync = fsApi.statSync || fs.statSync;
420
+ const folderStat = fsApi.existsSync(resolved.target) ? statSync.call(fsApi, resolved.target) : null;
421
+ if (folderStat && !folderStat.isDirectory()) return markerPathConflict('work-folder is not a directory');
422
+ const markerPath = path.join(resolved.target, '.forge-issue');
423
+ const markerEscape = assertNoSymlinkEscape(resolved.root, markerPath);
424
+ if (markerEscape) {
425
+ return markerPathConflict(markerEscape.reason);
166
426
  }
427
+ return { folderAbs: resolved.target, markerPath, folderExists: Boolean(folderStat) };
428
+ }
167
429
 
168
- return runInstall(worktreePath, projectRoot, spawnFn, fsApi);
430
+ function reconcileIssueMarker({ target, linkedIssue, linkedFolder, fsApi }) {
431
+ if (!target || !linkedIssue || !target.folderExists) return null;
432
+ let markerIssue = null;
433
+ try {
434
+ if (fsApi.existsSync(target.markerPath)) markerIssue = fsApi.readFileSync(target.markerPath, 'utf8').trim() || null;
435
+ } catch { /* unreadable marker — let the write below repair it */ }
436
+ if (markerIssue && markerIssue !== linkedIssue) {
437
+ return {
438
+ issueId: linkedIssue,
439
+ workFolder: linkedFolder,
440
+ reason: 'worktree linkage mismatch; existing issue marker was not changed',
441
+ conflict: true,
442
+ };
443
+ }
444
+ fsApi.writeFileSync(target.markerPath, `${linkedIssue}\n`, 'utf8');
445
+ return null;
169
446
  }
170
447
 
171
448
  /**
@@ -185,30 +462,44 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
185
462
  * @param {string|null} params.issueId
186
463
  * @param {string|null} params.workFolder - Repo-relative work-folder path.
187
464
  * @param {object} params.opts - DI options (may inject `_kernelDriver`, `_exec`, `_fs`).
465
+ * @param {boolean} [params.reuse] - Validate existing linkage before reconciling.
188
466
  * @returns {Promise<{registered: boolean, issueId: string|null, workFolder: string|null, reason?: string}>}
189
467
  */
190
- async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts }) {
468
+ async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts, reuse = false }) {
191
469
  const runFile = opts._exec || execFileSync;
192
470
  const fsApi = opts._fs || fs;
193
471
  try {
194
- // Drop the folder → issue marker first — deterministic even if the DB write fails.
195
- if (workFolder && issueId) {
196
- const folderAbs = path.resolve(projectRoot, workFolder);
197
- fsApi.mkdirSync(folderAbs, { recursive: true });
198
- fsApi.writeFileSync(path.join(folderAbs, '.forge-issue'), `${issueId}\n`, 'utf8');
199
- }
200
-
201
472
  let driver = opts._kernelDriver;
202
473
  if (!driver) {
203
474
  // No reachable git repo → no shared Kernel; skip quietly (orientation still works
204
475
  // off the folder heuristic). Avoids spawning `git` in a non-repo directory.
205
- if (!fsApi.existsSync(path.join(projectRoot, '.git'))) {
206
- return { registered: false, issueId: issueId || null, workFolder: workFolder || null, reason: 'no git repository' };
476
+ if (fsApi.existsSync(path.join(projectRoot, '.git'))) {
477
+ // Lazy require: keep the worktree command light and avoid paying kernel setup
478
+ // when linkage is not needed. Migrated deps guarantee the 007 columns exist.
479
+ const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
480
+ driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
207
481
  }
208
- // Lazy require: keep the worktree command light and avoid paying kernel setup
209
- // when linkage is not needed. Migrated deps guarantee the 007 columns exist.
210
- const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
211
- driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
482
+ }
483
+
484
+ const existing = readExistingWorktreeLinkage(driver, worktreePath, reuse);
485
+ const linkageConflict = detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse });
486
+ if (linkageConflict) return { registered: false, ...linkageConflict };
487
+
488
+ const existingIssue = existing?.issue_id || null;
489
+ const existingFolder = existing?.work_folder || null;
490
+ const linkedIssue = issueId || (reuse ? existingIssue : null);
491
+ const linkedFolder = workFolder || (reuse ? existingFolder : null);
492
+ const markerTarget = validateMarkerTarget(projectRoot, linkedFolder, fsApi);
493
+ if (markerTarget?.conflict) {
494
+ return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, ...markerTarget };
495
+ }
496
+ const markerConflict = reconcileIssueMarker({ target: markerTarget, linkedIssue, linkedFolder, fsApi });
497
+ if (markerConflict) {
498
+ return { registered: false, ...markerConflict };
499
+ }
500
+
501
+ if (!driver) {
502
+ return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, reason: 'no git repository' };
212
503
  }
213
504
 
214
505
  const { resolveGitCommonDir } = require('../kernel/broker');
@@ -219,13 +510,13 @@ async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issu
219
510
  path: worktreePath,
220
511
  branch,
221
512
  actor: process.env.FORGE_ACTOR || null,
222
- issue_id: issueId || null,
223
- work_folder: workFolder || null,
513
+ issue_id: linkedIssue || null,
514
+ work_folder: linkedFolder || null,
224
515
  registered_at: new Date().toISOString(),
225
516
  state: 'active',
226
517
  });
227
518
 
228
- return { registered: true, issueId: issueId || null, workFolder: workFolder || null };
519
+ return { registered: true, issueId: linkedIssue || null, workFolder: linkedFolder || null };
229
520
  } catch (error) {
230
521
  // Non-fatal: keep the worktree usable. Only warn when the caller explicitly asked
231
522
  // for linkage (--issue/--work-folder); a plain `worktree create` stays quiet.
@@ -331,13 +622,30 @@ async function handleCreate(slug, flags, projectRoot, opts) {
331
622
  // A detached worktree reports the literal "HEAD" — don't persist that; keep branchName.
332
623
  if (head && head !== 'HEAD') existingBranch = head;
333
624
  } catch (_error) { /* not a resolvable worktree HEAD — fall back to branchName */ }
334
- const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts });
625
+ const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts, reuse: true });
626
+ if (linkage.conflict) {
627
+ return { success: false, reused: true, error: linkage.reason, worktreePath, linkage };
628
+ }
629
+ let deps;
630
+ try {
631
+ deps = setupWorktreeDeps(worktreePath, projectRoot, {
632
+ spawnFn: runSpawn,
633
+ fsApi,
634
+ platform: opts._platform || process.platform,
635
+ });
636
+ } catch (error) {
637
+ return { success: false, reused: true, error: error.message, worktreePath, linkage };
638
+ }
335
639
  const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: existingBranch, issueId, opts });
640
+ const healNote = deps.healed ? ' Healed dependencies (store was stale): reinstalled with --force.' : '';
336
641
  return {
337
642
  success: true,
338
643
  reused: true,
339
- message: `Worktree already exists at ${worktreePath}`,
644
+ message: `Worktree already exists at ${worktreePath}.${healNote}`,
340
645
  worktreePath,
646
+ depsLinked: deps.linked,
647
+ depsInstalled: deps.installed,
648
+ depsHealed: deps.healed,
341
649
  linkage,
342
650
  backing,
343
651
  };
@@ -350,13 +658,13 @@ async function handleCreate(slug, flags, projectRoot, opts) {
350
658
  // the repo's DEFAULT branch — NOT the checkout's current HEAD — so the worktree
351
659
  // never silently inherits unrelated WIP commits (B2). An existing branch is
352
660
  // checked out as-is (no base applies).
353
- const hasBranch = branchExists(branchName, runFile);
661
+ const hasBranch = branchExists(branchName, projectRoot, runFile);
354
662
  let base = null;
355
663
  if (hasBranch) {
356
- runFile('git', ['worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
664
+ runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
357
665
  } else {
358
666
  base = explicitBase || resolveDefaultBase(projectRoot, runFile);
359
- runFile('git', ['worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
667
+ runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
360
668
  }
361
669
 
362
670
  // Step 3: Populate node_modules (link to the shared install, else install).
@@ -375,11 +683,16 @@ async function handleCreate(slug, flags, projectRoot, opts) {
375
683
  }
376
684
 
377
685
  const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: branchName, issueId, workFolder, opts });
686
+ if (linkage.conflict) {
687
+ return { success: false, error: linkage.reason, worktreePath, branch: branchName, linkage };
688
+ }
378
689
  const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: branchName, issueId, opts });
379
690
 
380
691
  // Report the base so the fork point is never silent. `base` is null when an
381
692
  // existing branch was checked out (no fork happened).
382
693
  const baseNote = base ? `based on ${base}` : `existing branch ${branchName}`;
694
+ // Never let a heal be silent — a stale store is worth knowing about.
695
+ const healNote = deps.healed ? ' Healed dependencies (store was stale): reinstalled with --force.' : '';
383
696
  return {
384
697
  success: true,
385
698
  worktreePath,
@@ -387,9 +700,10 @@ async function handleCreate(slug, flags, projectRoot, opts) {
387
700
  base,
388
701
  depsLinked: deps.linked,
389
702
  depsInstalled: deps.installed,
703
+ depsHealed: deps.healed,
390
704
  linkage,
391
705
  backing,
392
- output: `Created worktree ${worktreePath} on ${branchName} (${baseNote}).`,
706
+ output: `Created worktree ${worktreePath} on ${branchName} (${baseNote}).${healNote}`,
393
707
  };
394
708
  }
395
709
 
@@ -534,7 +534,7 @@ const RESOLVED_RUNTIME_GRAPH = {
534
534
  id: 'adapter.issue',
535
535
  kind: 'issue',
536
536
  label: 'Issue tracking adapter',
537
- config: { primary: 'beads', mirrors: ['github'] },
537
+ config: { primary: 'kernel', mirrors: ['github'] },
538
538
  }),
539
539
  Adapter({
540
540
  id: 'adapter.harness',