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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +20 -0
  5. package/bin/forge.js +16 -374
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +8 -5
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +54 -25
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/pr-state-adapter.js +344 -142
  19. package/lib/audit-evidence.js +71 -110
  20. package/lib/capped-jsonl-log.js +236 -0
  21. package/lib/commands/_registry.js +2 -2
  22. package/lib/commands/clean.js +196 -32
  23. package/lib/commands/dev.js +4 -33
  24. package/lib/commands/hooks.js +223 -25
  25. package/lib/commands/insights.js +8 -3
  26. package/lib/commands/merge.js +600 -40
  27. package/lib/commands/pr.js +1 -1
  28. package/lib/commands/preflight.js +11 -2
  29. package/lib/commands/prime.js +21 -8
  30. package/lib/commands/push.js +41 -51
  31. package/lib/commands/recall.js +60 -16
  32. package/lib/commands/recap.js +6 -1
  33. package/lib/commands/release.js +17 -2
  34. package/lib/commands/setup.js +191 -94
  35. package/lib/commands/shepherd.js +13 -1
  36. package/lib/commands/ship.js +22 -23
  37. package/lib/commands/skill.js +119 -11
  38. package/lib/commands/status.js +17 -1
  39. package/lib/commands/test.js +24 -34
  40. package/lib/commands/worktree.js +220 -42
  41. package/lib/core/runtime-graph.js +1 -1
  42. package/lib/doc-assertions.js +297 -0
  43. package/lib/existing-tdd-gate.js +253 -0
  44. package/lib/forge-context.js +1 -4
  45. package/lib/forge-issues.js +56 -32
  46. package/lib/git-defaults.js +56 -0
  47. package/lib/harness-capability-matrix.js +3 -3
  48. package/lib/hook-renderer.js +93 -4
  49. package/lib/insights.js +96 -80
  50. package/lib/kernel/backing-issue.js +14 -2
  51. package/lib/kernel/broker.js +16 -0
  52. package/lib/kernel/cli-broker-factory.js +12 -1
  53. package/lib/kernel/close-on-merge.js +154 -0
  54. package/lib/kernel/fs-class.js +42 -25
  55. package/lib/kernel/sqlite-driver.js +153 -29
  56. package/lib/lefthook-wiring.js +21 -1
  57. package/lib/memory/router.js +16 -1
  58. package/lib/memory-digest.js +47 -15
  59. package/lib/memory-recall-events.js +145 -0
  60. package/lib/memory-recall.js +71 -10
  61. package/lib/merge-rules.js +8 -4
  62. package/lib/npm-publish-workflow.js +272 -0
  63. package/lib/orientation.js +68 -43
  64. package/lib/plugin-catalog.js +14 -4
  65. package/lib/pr-bundle.js +5 -6
  66. package/lib/pr-monitor/journal.js +18 -2
  67. package/lib/pr-monitor/reconcile-executor.js +224 -41
  68. package/lib/pr-monitor/render-summary.js +196 -0
  69. package/lib/pr-monitor/shepherd-lease.js +10 -1
  70. package/lib/pr-monitor/watch-lifecycle.js +13 -1
  71. package/lib/pr-pull.js +33 -14
  72. package/lib/pr-shepherd.js +34 -8
  73. package/lib/preflight/gates.js +65 -18
  74. package/lib/preflight/runner.js +5 -0
  75. package/lib/project-memory.js +33 -1
  76. package/lib/protected-state-authority.js +305 -0
  77. package/lib/protected-state-surfaces.js +64 -44
  78. package/lib/release-readiness.js +51 -4
  79. package/lib/shell-utils.js +1 -1
  80. package/lib/skills-sync.js +6 -3
  81. package/lib/smart-merge.js +28 -4
  82. package/lib/symlink-utils.js +74 -26
  83. package/lib/upgrade-safety.js +39 -0
  84. package/lib/using-forge.js +19 -6
  85. package/package.json +6 -7
  86. package/scripts/doc-asserting-tests.js +158 -0
  87. package/scripts/lib/behavioral-eval-runner.js +310 -0
  88. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  89. package/scripts/lib/eval-evidence.js +328 -0
  90. package/scripts/lib/eval-runner.js +81 -41
  91. package/scripts/lib/immutable-eval-corpus.js +309 -0
  92. package/scripts/lib/promotion-evidence-loader.js +94 -0
  93. package/scripts/lib/promotion-scorecard.js +314 -0
  94. package/scripts/npm-release-receipt.js +134 -0
  95. package/scripts/process-tree.js +761 -0
  96. package/scripts/protected-state-check.js +47 -22
  97. package/scripts/run-command-eval.js +29 -1
  98. package/scripts/sync-d20-audit.js +172 -0
  99. package/scripts/test-full-suite.js +249 -37
  100. package/scripts/test.js +176 -43
  101. package/skills/review/SKILL.md +4 -11
  102. package/skills/review/evals/scorecard.json +3 -3
  103. package/skills/rollback/SKILL.md +4 -11
  104. package/skills/rollback/evals/scorecard.json +3 -3
  105. package/skills/shepherd/SKILL.md +20 -14
  106. package/skills/shepherd/evals/scorecard.json +2 -2
  107. package/skills/ship/SKILL.md +4 -12
  108. package/skills/ship/evals/scorecard.json +3 -3
  109. package/skills/worktree/SKILL.md +6 -1
  110. package/skills/worktree/evals/scorecard.json +2 -2
  111. package/lib/beads-setup.js +0 -538
  112. package/lib/beads-sync-scaffold.js +0 -189
  113. package/lib/pat-setup.js +0 -207
  114. package/lib/pr-monitor/render-sticky.js +0 -206
  115. package/lib/pr-monitor/upsert-sticky.js +0 -169
  116. package/scripts/beads-context.sh +0 -577
  117. package/scripts/beads-migrate-to-dolt.sh +0 -7
  118. package/scripts/beads-upgrade-smoke.sh +0 -284
  119. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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,118 @@ 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
+
102
112
  /**
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
113
+ * Direct dependencies the worktree declares. Optional/peer deps are excluded —
114
+ * they may legitimately be absent. An unreadable package.json yields none, so an
115
+ * unverifiable worktree is left alone rather than force-reinstalled.
116
+ * @param {string} worktreePath - Absolute path to the worktree
108
117
  * @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.
118
+ * @returns {string[]} Declared package names
111
119
  */
112
- function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
113
- const pkgManager = detectPackageManager(projectRoot, fsApi);
114
- if (!pkgManager) return { linked: false, installed: false };
120
+ function declaredDependencies(worktreePath, fsApi) {
121
+ try {
122
+ const pkg = JSON.parse(fsApi.readFileSync(path.join(worktreePath, 'package.json'), 'utf8'));
123
+ return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {})];
124
+ } catch (_err) { /* intentional: no readable package.json → nothing to verify */ // NOSONAR S2486
125
+ return [];
126
+ }
127
+ }
115
128
 
129
+ /**
130
+ * Verify the install actually landed: every declared direct dependency must be a
131
+ * real package in the worktree's node_modules.
132
+ *
133
+ * The package managers cannot answer this. `bun pm ls`, `bun list`, `bun pm why`
134
+ * and `bun install --dry-run` all read the LOCKFILE and exit 0 while packages are
135
+ * physically missing; `pnpm ls --depth=0` does the same; `bun -e require.resolve`
136
+ * is defeated by Bun's auto-install. Only `npm ls --depth=0` exits non-zero, so a
137
+ * filesystem check is the one probe that holds for every manager.
138
+ *
139
+ * @param {string} worktreePath - Absolute path to the worktree
140
+ * @param {object} fsApi - fs module (for DI)
141
+ * @returns {boolean} true when nothing is missing
142
+ */
143
+ function dependenciesPresent(worktreePath, fsApi) {
144
+ const deps = declaredDependencies(worktreePath, fsApi);
145
+ return deps.every((name) => fsApi.existsSync(path.join(worktreePath, 'node_modules', name, 'package.json')));
146
+ }
147
+
148
+ const STDERR_DETAIL_LIMIT = 400;
149
+
150
+ /**
151
+ * The install's captured stderr, trimmed and bounded — stdio 'pipe' otherwise
152
+ * hides why the install failed.
153
+ * @param {object} result - spawnSync result
154
+ * @returns {string} A leading-newline detail block, or '' when there is nothing
155
+ */
156
+ function stderrDetail(result) {
157
+ const text = result && result.stderr ? String(result.stderr).trim() : '';
158
+ if (!text) return '';
159
+ return `\n${text.length > STDERR_DETAIL_LIMIT ? `${text.slice(0, STDERR_DETAIL_LIMIT)}...` : text}`;
160
+ }
161
+
162
+ /**
163
+ * Spawn one install, surfacing spawn errors, kills, and non-zero exits.
164
+ * @param {string} pkgManager - Detected package manager command
165
+ * @param {string[]} args - Install args (e.g. ['install'] or ['install', '--force'])
166
+ * @param {string} worktreePath - cwd for the install
167
+ * @param {Function} spawnFn - spawnSync-compatible function
168
+ * @throws {Error} when the install cannot be spawned, is killed, or exits non-zero.
169
+ */
170
+ function spawnInstall(pkgManager, args, worktreePath, spawnFn) {
116
171
  // shell:true on Windows: npm/pnpm/yarn are .cmd shims that cannot be spawned
117
172
  // directly (ENOENT / EINVAL). pkgManager comes from lockfile detection (fixed
118
173
  // set) and args are hardcoded, so no user input reaches the shell.
119
- const result = spawnFn(pkgManager, ['install'], {
174
+ const result = spawnFn(pkgManager, args, {
120
175
  cwd: worktreePath,
121
176
  stdio: 'pipe',
122
177
  shell: process.platform === 'win32',
123
178
  });
179
+ const label = `${pkgManager} ${args.join(' ')}`;
124
180
  if (result && result.error) {
125
- throw new Error(`Dependency install failed: could not run '${pkgManager} install' in ${worktreePath}: ${result.error.message}`);
181
+ throw new Error(`Dependency install failed: could not run '${label}' in ${worktreePath}: ${result.error.message}${stderrDetail(result)}`);
182
+ }
183
+ // A killed install (OOM, SIGTERM, CI cancel) reports status null with a signal
184
+ // set — never a non-zero code — so it must be checked on its own.
185
+ if (result && result.signal) {
186
+ throw new Error(`Dependency install failed: '${label}' was killed by ${result.signal} in ${worktreePath}${stderrDetail(result)}`);
126
187
  }
127
188
  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}`);
189
+ throw new Error(`Dependency install failed: '${label}' exited with code ${result.status} in ${worktreePath}${stderrDetail(result)}`);
129
190
  }
130
- return { linked: false, installed: true };
191
+ }
192
+
193
+ /**
194
+ * Run package install in the new worktree if a package manager is detected, then
195
+ * VERIFY it. A stale shared store makes a plain install report "no changes" while
196
+ * leaving packages and binaries missing, so the exit code alone is not evidence —
197
+ * on a failed probe, reinstall once with --force and re-probe.
198
+ * @param {string} worktreePath - Absolute path to the new worktree
199
+ * @param {string} projectRoot - Absolute path to the project root
200
+ * @param {Function} spawnFn - spawnSync-compatible function
201
+ * @param {object} fsApi - fs module (for DI)
202
+ * @returns {{ linked: boolean, installed: boolean, healed: boolean }}
203
+ * @throws {Error} when the install fails, or the tree is still broken after --force.
204
+ */
205
+ function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
206
+ const pkgManager = detectPackageManager(projectRoot, fsApi);
207
+ if (!pkgManager) return { linked: false, installed: false, healed: false };
208
+
209
+ spawnInstall(pkgManager, ['install'], worktreePath, spawnFn);
210
+
211
+ if (PROBE_LESS_MANAGERS.has(pkgManager) || dependenciesPresent(worktreePath, fsApi)) {
212
+ return { linked: false, installed: true, healed: false };
213
+ }
214
+
215
+ spawnInstall(pkgManager, ['install', '--force'], worktreePath, spawnFn);
216
+ if (!dependenciesPresent(worktreePath, fsApi)) {
217
+ 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.`);
218
+ }
219
+ return { linked: false, installed: true, healed: true };
131
220
  }
132
221
 
133
222
  /**
@@ -142,7 +231,7 @@ function runInstall(worktreePath, projectRoot, spawnFn, fsApi) {
142
231
  * @param {Function} deps.spawnFn - spawnSync-compatible function
143
232
  * @param {object} deps.fsApi - fs module (for DI)
144
233
  * @param {string} deps.platform - process.platform value
145
- * @returns {{ linked: boolean, installed: boolean }}
234
+ * @returns {{ linked: boolean, installed: boolean, healed: boolean }}
146
235
  * @throws {Error} when linking fails for a non-privilege reason or install fails.
147
236
  */
148
237
  function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform }) {
@@ -150,14 +239,14 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
150
239
  const destModules = path.join(worktreePath, 'node_modules');
151
240
 
152
241
  // Already populated (e.g. git checkout carried it) — nothing to do.
153
- if (fsApi.existsSync(destModules)) return { linked: false, installed: false };
242
+ if (fsApi.existsSync(destModules)) return { linked: false, installed: false, healed: false };
154
243
 
155
244
  // Fast path: link to the shared install when the main repo has one.
156
245
  if (fsApi.existsSync(srcModules)) {
157
246
  const linkType = platform === 'win32' ? 'junction' : 'dir';
158
247
  try {
159
248
  fsApi.symlinkSync(srcModules, destModules, linkType);
160
- return { linked: true, installed: false };
249
+ return { linked: true, installed: false, healed: false };
161
250
  } catch (error) {
162
251
  // Degrade to a real install only when the link was refused for lack of
163
252
  // privilege/support; any other failure is surfaced to the caller.
@@ -168,6 +257,72 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
168
257
  return runInstall(worktreePath, projectRoot, spawnFn, fsApi);
169
258
  }
170
259
 
260
+ function readExistingWorktreeLinkage(driver, worktreePath, reuse) {
261
+ if (!reuse || !driver || typeof driver.getWorktreeLinkage !== 'function') return null;
262
+ return driver.getWorktreeLinkage({ path: worktreePath }) || null;
263
+ }
264
+
265
+ function detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse }) {
266
+ if (!reuse || !existing) return null;
267
+ const existingIssue = existing.issue_id || null;
268
+ const existingFolder = existing.work_folder || null;
269
+ const folderChanged = workFolder && existingFolder
270
+ && path.resolve(projectRoot, workFolder) !== path.resolve(projectRoot, existingFolder);
271
+ if (!((issueId && existingIssue && issueId !== existingIssue) || folderChanged)) return null;
272
+ return {
273
+ issueId: issueId || existingIssue,
274
+ workFolder: workFolder || existingFolder,
275
+ reason: 'worktree linkage mismatch; existing issue/work-folder was not changed',
276
+ conflict: true,
277
+ };
278
+ }
279
+
280
+ function isAbsoluteWorkFolder(workFolder) {
281
+ return path.isAbsolute(workFolder) || path.posix.isAbsolute(workFolder) || path.win32.isAbsolute(workFolder);
282
+ }
283
+
284
+ function markerPathConflict(reason) {
285
+ return { conflict: true, reason: `worktree linkage marker path rejected: ${reason}` };
286
+ }
287
+
288
+ function validateMarkerTarget(projectRoot, workFolder, fsApi) {
289
+ if (!workFolder) return null;
290
+ if (isAbsoluteWorkFolder(workFolder)) return markerPathConflict('work-folder must be repo-relative');
291
+
292
+ const resolved = resolveRepoRelativePath(projectRoot, workFolder);
293
+ if (!resolved.insideRoot) return markerPathConflict('work-folder escapes project root');
294
+ const ancestorEscape = assertNoAncestorSymlinkEscape(resolved.root, resolved.target);
295
+ if (ancestorEscape) return markerPathConflict(ancestorEscape.reason);
296
+
297
+ const statSync = fsApi.statSync || fs.statSync;
298
+ const folderStat = fsApi.existsSync(resolved.target) ? statSync.call(fsApi, resolved.target) : null;
299
+ if (folderStat && !folderStat.isDirectory()) return markerPathConflict('work-folder is not a directory');
300
+ const markerPath = path.join(resolved.target, '.forge-issue');
301
+ const markerEscape = assertNoSymlinkEscape(resolved.root, markerPath);
302
+ if (markerEscape) {
303
+ return markerPathConflict(markerEscape.reason);
304
+ }
305
+ return { folderAbs: resolved.target, markerPath, folderExists: Boolean(folderStat) };
306
+ }
307
+
308
+ function reconcileIssueMarker({ target, linkedIssue, linkedFolder, fsApi }) {
309
+ if (!target || !linkedIssue || !target.folderExists) return null;
310
+ let markerIssue = null;
311
+ try {
312
+ if (fsApi.existsSync(target.markerPath)) markerIssue = fsApi.readFileSync(target.markerPath, 'utf8').trim() || null;
313
+ } catch { /* unreadable marker — let the write below repair it */ }
314
+ if (markerIssue && markerIssue !== linkedIssue) {
315
+ return {
316
+ issueId: linkedIssue,
317
+ workFolder: linkedFolder,
318
+ reason: 'worktree linkage mismatch; existing issue marker was not changed',
319
+ conflict: true,
320
+ };
321
+ }
322
+ fsApi.writeFileSync(target.markerPath, `${linkedIssue}\n`, 'utf8');
323
+ return null;
324
+ }
325
+
171
326
  /**
172
327
  * Best-effort: record the issue → worktree → work-folder linkage in the Kernel so
173
328
  * `forge worktree list` and orientation READ it instead of guessing the work-folder by
@@ -185,30 +340,44 @@ function setupWorktreeDeps(worktreePath, projectRoot, { spawnFn, fsApi, platform
185
340
  * @param {string|null} params.issueId
186
341
  * @param {string|null} params.workFolder - Repo-relative work-folder path.
187
342
  * @param {object} params.opts - DI options (may inject `_kernelDriver`, `_exec`, `_fs`).
343
+ * @param {boolean} [params.reuse] - Validate existing linkage before reconciling.
188
344
  * @returns {Promise<{registered: boolean, issueId: string|null, workFolder: string|null, reason?: string}>}
189
345
  */
190
- async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts }) {
346
+ async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issueId, workFolder, opts, reuse = false }) {
191
347
  const runFile = opts._exec || execFileSync;
192
348
  const fsApi = opts._fs || fs;
193
349
  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
350
  let driver = opts._kernelDriver;
202
351
  if (!driver) {
203
352
  // No reachable git repo → no shared Kernel; skip quietly (orientation still works
204
353
  // 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' };
354
+ if (fsApi.existsSync(path.join(projectRoot, '.git'))) {
355
+ // Lazy require: keep the worktree command light and avoid paying kernel setup
356
+ // when linkage is not needed. Migrated deps guarantee the 007 columns exist.
357
+ const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
358
+ driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
207
359
  }
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;
360
+ }
361
+
362
+ const existing = readExistingWorktreeLinkage(driver, worktreePath, reuse);
363
+ const linkageConflict = detectLinkageConflict({ projectRoot, existing, issueId, workFolder, reuse });
364
+ if (linkageConflict) return { registered: false, ...linkageConflict };
365
+
366
+ const existingIssue = existing?.issue_id || null;
367
+ const existingFolder = existing?.work_folder || null;
368
+ const linkedIssue = issueId || (reuse ? existingIssue : null);
369
+ const linkedFolder = workFolder || (reuse ? existingFolder : null);
370
+ const markerTarget = validateMarkerTarget(projectRoot, linkedFolder, fsApi);
371
+ if (markerTarget?.conflict) {
372
+ return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, ...markerTarget };
373
+ }
374
+ const markerConflict = reconcileIssueMarker({ target: markerTarget, linkedIssue, linkedFolder, fsApi });
375
+ if (markerConflict) {
376
+ return { registered: false, ...markerConflict };
377
+ }
378
+
379
+ if (!driver) {
380
+ return { registered: false, issueId: linkedIssue || null, workFolder: linkedFolder || null, reason: 'no git repository' };
212
381
  }
213
382
 
214
383
  const { resolveGitCommonDir } = require('../kernel/broker');
@@ -219,13 +388,13 @@ async function registerWorktreeLinkage({ projectRoot, worktreePath, branch, issu
219
388
  path: worktreePath,
220
389
  branch,
221
390
  actor: process.env.FORGE_ACTOR || null,
222
- issue_id: issueId || null,
223
- work_folder: workFolder || null,
391
+ issue_id: linkedIssue || null,
392
+ work_folder: linkedFolder || null,
224
393
  registered_at: new Date().toISOString(),
225
394
  state: 'active',
226
395
  });
227
396
 
228
- return { registered: true, issueId: issueId || null, workFolder: workFolder || null };
397
+ return { registered: true, issueId: linkedIssue || null, workFolder: linkedFolder || null };
229
398
  } catch (error) {
230
399
  // Non-fatal: keep the worktree usable. Only warn when the caller explicitly asked
231
400
  // for linkage (--issue/--work-folder); a plain `worktree create` stays quiet.
@@ -331,7 +500,10 @@ async function handleCreate(slug, flags, projectRoot, opts) {
331
500
  // A detached worktree reports the literal "HEAD" — don't persist that; keep branchName.
332
501
  if (head && head !== 'HEAD') existingBranch = head;
333
502
  } catch (_error) { /* not a resolvable worktree HEAD — fall back to branchName */ }
334
- const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts });
503
+ const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: existingBranch, issueId, workFolder, opts, reuse: true });
504
+ if (linkage.conflict) {
505
+ return { success: false, reused: true, error: linkage.reason, worktreePath, linkage };
506
+ }
335
507
  const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: existingBranch, issueId, opts });
336
508
  return {
337
509
  success: true,
@@ -350,13 +522,13 @@ async function handleCreate(slug, flags, projectRoot, opts) {
350
522
  // the repo's DEFAULT branch — NOT the checkout's current HEAD — so the worktree
351
523
  // never silently inherits unrelated WIP commits (B2). An existing branch is
352
524
  // checked out as-is (no base applies).
353
- const hasBranch = branchExists(branchName, runFile);
525
+ const hasBranch = branchExists(branchName, projectRoot, runFile);
354
526
  let base = null;
355
527
  if (hasBranch) {
356
- runFile('git', ['worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
528
+ runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, branchName], { stdio: 'pipe' });
357
529
  } else {
358
530
  base = explicitBase || resolveDefaultBase(projectRoot, runFile);
359
- runFile('git', ['worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
531
+ runFile('git', ['-C', projectRoot, 'worktree', 'add', worktreePath, '-b', branchName, base], { stdio: 'pipe' });
360
532
  }
361
533
 
362
534
  // Step 3: Populate node_modules (link to the shared install, else install).
@@ -375,11 +547,16 @@ async function handleCreate(slug, flags, projectRoot, opts) {
375
547
  }
376
548
 
377
549
  const linkage = await registerWorktreeLinkage({ projectRoot, worktreePath, branch: branchName, issueId, workFolder, opts });
550
+ if (linkage.conflict) {
551
+ return { success: false, error: linkage.reason, worktreePath, branch: branchName, linkage };
552
+ }
378
553
  const backing = await autoFileBackingIssue({ projectRoot, worktreePath, branch: branchName, issueId, opts });
379
554
 
380
555
  // Report the base so the fork point is never silent. `base` is null when an
381
556
  // existing branch was checked out (no fork happened).
382
557
  const baseNote = base ? `based on ${base}` : `existing branch ${branchName}`;
558
+ // Never let a heal be silent — a stale store is worth knowing about.
559
+ const healNote = deps.healed ? ' Healed dependencies (store was stale): reinstalled with --force.' : '';
383
560
  return {
384
561
  success: true,
385
562
  worktreePath,
@@ -387,9 +564,10 @@ async function handleCreate(slug, flags, projectRoot, opts) {
387
564
  base,
388
565
  depsLinked: deps.linked,
389
566
  depsInstalled: deps.installed,
567
+ depsHealed: deps.healed,
390
568
  linkage,
391
569
  backing,
392
- output: `Created worktree ${worktreePath} on ${branchName} (${baseNote}).`,
570
+ output: `Created worktree ${worktreePath} on ${branchName} (${baseNote}).${healNote}`,
393
571
  };
394
572
  }
395
573
 
@@ -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',