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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/AGENTS.md +14 -7
  2. package/CHANGELOG.md +43 -1
  3. package/README.md +6 -2
  4. package/bin/forge-cmd.js +21 -1
  5. package/bin/forge.js +16 -369
  6. package/docs/INDEX.md +1 -1
  7. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  8. package/docs/guides/MIGRATION.md +4 -4
  9. package/docs/guides/SETUP.md +16 -16
  10. package/docs/reference/COMMANDS.md +9 -4
  11. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  12. package/docs/reference/RELEASE.md +5 -3
  13. package/docs/reference/TOOLCHAIN.md +8 -0
  14. package/docs/reference/protected-state-surfaces.md +4 -4
  15. package/docs/reference/shepherd.md +117 -17
  16. package/lefthook.yml +12 -0
  17. package/lib/activation/ensure-forge-home.js +33 -15
  18. package/lib/adapters/greptile-review-adapter.js +1 -1
  19. package/lib/adapters/pr-state-adapter.js +397 -100
  20. package/lib/agents-config.js +5 -0
  21. package/lib/audit-evidence.js +71 -110
  22. package/lib/capped-jsonl-log.js +236 -0
  23. package/lib/commands/_issue.js +31 -46
  24. package/lib/commands/_manifest.js +1 -1
  25. package/lib/commands/_registry.js +2 -2
  26. package/lib/commands/_resolve-command-opts.js +36 -29
  27. package/lib/commands/claim.js +2 -4
  28. package/lib/commands/clean.js +196 -32
  29. package/lib/commands/dev.js +4 -33
  30. package/lib/commands/hooks.js +358 -13
  31. package/lib/commands/insights.js +8 -3
  32. package/lib/commands/merge.js +600 -40
  33. package/lib/commands/plan.js +23 -115
  34. package/lib/commands/pr.js +1 -1
  35. package/lib/commands/preflight.js +11 -2
  36. package/lib/commands/prime.js +23 -3
  37. package/lib/commands/push.js +41 -51
  38. package/lib/commands/recall.js +60 -16
  39. package/lib/commands/recap.js +6 -1
  40. package/lib/commands/release.js +18 -4
  41. package/lib/commands/serve.js +5 -2
  42. package/lib/commands/setup.js +191 -95
  43. package/lib/commands/shepherd.js +49 -4
  44. package/lib/commands/ship.js +22 -23
  45. package/lib/commands/skill.js +383 -0
  46. package/lib/commands/status.js +54 -33
  47. package/lib/commands/test.js +56 -34
  48. package/lib/commands/worktree.js +247 -43
  49. package/lib/core/runtime-graph.js +89 -15
  50. package/lib/doc-assertions.js +297 -0
  51. package/lib/existing-tdd-gate.js +253 -0
  52. package/lib/forge-context.js +1 -4
  53. package/lib/forge-issues.js +64 -491
  54. package/lib/git-defaults.js +56 -0
  55. package/lib/harness-capability-matrix.js +5 -5
  56. package/lib/hook-renderer.js +147 -16
  57. package/lib/insights.js +96 -80
  58. package/lib/issue-backend.js +42 -3
  59. package/lib/kernel/backing-issue.js +14 -2
  60. package/lib/kernel/broker.js +44 -0
  61. package/lib/kernel/cli-broker-factory.js +12 -1
  62. package/lib/kernel/close-on-merge.js +154 -0
  63. package/lib/kernel/fs-class.js +42 -25
  64. package/lib/kernel/migrations.js +30 -2
  65. package/lib/kernel/schema.js +35 -0
  66. package/lib/kernel/sqlite-driver.js +292 -18
  67. package/lib/lefthook-wiring.js +21 -1
  68. package/lib/memory/router.js +16 -1
  69. package/lib/memory-digest.js +47 -15
  70. package/lib/memory-recall-events.js +145 -0
  71. package/lib/memory-recall.js +212 -0
  72. package/lib/merge-rules.js +8 -4
  73. package/lib/npm-publish-workflow.js +272 -0
  74. package/lib/orientation.js +371 -49
  75. package/lib/plugin-catalog.js +14 -4
  76. package/lib/pr-bundle.js +9 -6
  77. package/lib/pr-monitor/journal.js +18 -2
  78. package/lib/pr-monitor/reconcile-executor.js +842 -0
  79. package/lib/pr-monitor/reconcile-tick.js +138 -0
  80. package/lib/pr-monitor/reconcile.js +0 -0
  81. package/lib/pr-monitor/render-summary.js +196 -0
  82. package/lib/pr-monitor/shepherd-lease.js +252 -0
  83. package/lib/pr-monitor/watch-lifecycle.js +14 -2
  84. package/lib/pr-pull.js +98 -24
  85. package/lib/pr-shepherd.js +34 -8
  86. package/lib/preflight/gates.js +65 -18
  87. package/lib/preflight/runner.js +5 -0
  88. package/lib/project-memory.js +40 -0
  89. package/lib/protected-state-authority.js +305 -0
  90. package/lib/protected-state-surfaces.js +64 -44
  91. package/lib/release-readiness.js +51 -4
  92. package/lib/rules-sync.js +4 -0
  93. package/lib/runtime-health.js +15 -46
  94. package/lib/shell-utils.js +1 -1
  95. package/lib/skill-eval.js +750 -0
  96. package/lib/skills-sync.js +6 -3
  97. package/lib/smart-merge.js +28 -4
  98. package/lib/status/identity.js +46 -0
  99. package/lib/status/presenter.js +0 -35
  100. package/lib/status/snapshot.js +11 -16
  101. package/lib/symlink-utils.js +74 -26
  102. package/lib/upgrade-safety.js +47 -9
  103. package/lib/using-forge.js +328 -0
  104. package/lib/workflow/enforce-stage.js +5 -5
  105. package/lib/workflow/state-manager.js +23 -23
  106. package/package.json +6 -7
  107. package/rules/using-forge.md +24 -0
  108. package/scripts/doc-asserting-tests.js +158 -0
  109. package/scripts/forge-team/index.sh +0 -5
  110. package/scripts/forge-team/tests/dispatcher.test.sh +1 -1
  111. package/scripts/forge-team/tests/workflow-integration.test.sh +0 -1
  112. package/scripts/lib/behavioral-eval-runner.js +310 -0
  113. package/scripts/lib/behavioral-eval-runtime.js +456 -0
  114. package/scripts/lib/eval-evidence.js +328 -0
  115. package/scripts/lib/eval-runner.js +81 -41
  116. package/scripts/lib/immutable-eval-corpus.js +309 -0
  117. package/scripts/lib/promotion-evidence-loader.js +94 -0
  118. package/scripts/lib/promotion-scorecard.js +314 -0
  119. package/scripts/npm-release-receipt.js +134 -0
  120. package/scripts/process-tree.js +761 -0
  121. package/scripts/protected-state-check.js +47 -22
  122. package/scripts/run-command-eval.js +29 -1
  123. package/scripts/sync-d20-audit.js +172 -0
  124. package/scripts/test-full-suite.js +249 -37
  125. package/scripts/test.js +184 -44
  126. package/skills/claim-safety/SKILL.md +4 -0
  127. package/skills/claim-safety/evals/scorecard.json +41 -0
  128. package/skills/coverage.json +83 -0
  129. package/skills/dev/SKILL.md +4 -0
  130. package/skills/dev/evals/scorecard.json +41 -0
  131. package/skills/gates/SKILL.md +80 -0
  132. package/skills/gates/evals/evals.json +38 -0
  133. package/skills/gates/evals/scorecard.json +41 -0
  134. package/skills/hermes-forge/SKILL.md +1 -0
  135. package/skills/hermes-forge/evals/scorecard.json +41 -0
  136. package/skills/issue-basics/SKILL.md +1 -0
  137. package/skills/issue-basics/evals/scorecard.json +41 -0
  138. package/skills/kernel/SKILL.md +38 -0
  139. package/skills/kernel/evals/scorecard.json +41 -0
  140. package/skills/memory/SKILL.md +16 -1
  141. package/skills/memory/evals/scorecard.json +41 -0
  142. package/skills/parallel-deep-research/SKILL.md +1 -0
  143. package/skills/parallel-deep-research/evals/scorecard.json +41 -0
  144. package/skills/plan/SKILL.md +6 -0
  145. package/skills/plan/evals/scorecard.json +41 -0
  146. package/skills/portability/SKILL.md +47 -0
  147. package/skills/portability/evals/evals.json +34 -0
  148. package/skills/portability/evals/scorecard.json +41 -0
  149. package/skills/research/SKILL.md +1 -0
  150. package/skills/research/evals/scorecard.json +41 -0
  151. package/skills/review/SKILL.md +10 -11
  152. package/skills/review/evals/scorecard.json +41 -0
  153. package/skills/rollback/SKILL.md +5 -11
  154. package/skills/rollback/evals/scorecard.json +41 -0
  155. package/skills/setup/SKILL.md +91 -0
  156. package/skills/setup/evals/evals.json +42 -0
  157. package/skills/setup/evals/scorecard.json +41 -0
  158. package/skills/shepherd/SKILL.md +84 -38
  159. package/skills/shepherd/evals/evals.json +21 -9
  160. package/skills/shepherd/evals/scorecard.json +41 -0
  161. package/skills/ship/SKILL.md +10 -12
  162. package/skills/ship/evals/scorecard.json +41 -0
  163. package/skills/smith/SKILL.md +8 -0
  164. package/skills/smith/evals/scorecard.json +41 -0
  165. package/skills/sonarcloud/SKILL.md +1 -0
  166. package/skills/sonarcloud/evals/scorecard.json +41 -0
  167. package/skills/sonarcloud-analysis/SKILL.md +1 -0
  168. package/skills/sonarcloud-analysis/evals/scorecard.json +41 -0
  169. package/skills/status/SKILL.md +3 -0
  170. package/skills/status/evals/scorecard.json +41 -0
  171. package/skills/triage-ready/SKILL.md +2 -0
  172. package/skills/triage-ready/evals/scorecard.json +41 -0
  173. package/skills/using-forge/SKILL.md +104 -0
  174. package/skills/using-forge/evals/scorecard.json +41 -0
  175. package/skills/validate/SKILL.md +4 -0
  176. package/skills/validate/evals/scorecard.json +41 -0
  177. package/skills/verify/SKILL.md +4 -0
  178. package/skills/verify/evals/scorecard.json +41 -0
  179. package/skills/worktree/SKILL.md +92 -0
  180. package/skills/worktree/evals/evals.json +38 -0
  181. package/skills/worktree/evals/scorecard.json +41 -0
  182. package/lib/adapters/beads-issue-adapter.js +0 -127
  183. package/lib/beads-nudge.js +0 -91
  184. package/lib/beads-setup.js +0 -538
  185. package/lib/beads-sync-scaffold.js +0 -189
  186. package/lib/commands/board.js +0 -64
  187. package/lib/pat-setup.js +0 -207
  188. package/lib/pr-monitor/render-sticky.js +0 -192
  189. package/lib/pr-monitor/upsert-sticky.js +0 -169
  190. package/lib/status/beads-snapshot.js +0 -145
  191. package/scripts/beads-context.sh +0 -577
  192. package/scripts/beads-migrate-to-dolt.sh +0 -7
  193. package/scripts/beads-upgrade-smoke.sh +0 -284
  194. package/scripts/forge-team/lib/dashboard.sh +0 -316
  195. package/scripts/forge-team/tests/dashboard.test.sh +0 -155
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -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
+ }
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
+ }
115
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
 
@@ -406,12 +584,38 @@ async function handleList(projectRoot, opts) {
406
584
  const { buildMigratedKernelIssueDeps } = require('../kernel/cli-broker-factory');
407
585
  driver = (await buildMigratedKernelIssueDeps({ projectRoot })).kernelDriver;
408
586
  }
409
- return { success: true, worktrees: driver.listWorktrees({}) };
587
+ // `forge worktree remove` / `forge clean` delete the checkout but do not yet
588
+ // reconcile the kernel_worktrees registry (issue ceeef92f), so the raw registry
589
+ // can still list already-removed paths. Filter to worktrees that still exist on
590
+ // disk so `forge worktree list` never surfaces stale entries agents could act on.
591
+ const existsSync = (opts._fs && opts._fs.existsSync) || require('node:fs').existsSync;
592
+ const worktrees = driver.listWorktrees({}).filter((w) => w && w.path && existsSync(w.path));
593
+ return { success: true, worktrees, output: renderWorktreeList(worktrees) };
410
594
  } catch (error) {
411
595
  return { success: false, error: error.message, worktrees: [] };
412
596
  }
413
597
  }
414
598
 
599
+ /**
600
+ * Render the worktree registry as human-readable text for the CLI. The dispatcher
601
+ * prints only `result.output`, so without this `forge worktree list` is silent.
602
+ * @param {Array<object>} worktrees - Rows from driver.listWorktrees()
603
+ * @returns {string} One line per worktree (path, branch, linked issue, state).
604
+ */
605
+ function renderWorktreeList(worktrees) {
606
+ if (!Array.isArray(worktrees) || worktrees.length === 0) {
607
+ return 'No worktrees registered.';
608
+ }
609
+ const lines = worktrees.map((w) => {
610
+ const parts = [w.path || '(unknown path)'];
611
+ if (w.branch) parts.push(`branch: ${w.branch}`);
612
+ if (w.issue_id) parts.push(`issue: ${w.issue_id}`);
613
+ if (w.state) parts.push(w.state);
614
+ return ` ${parts.join(' | ')}`;
615
+ });
616
+ return `Registered worktrees (${worktrees.length}):\n${lines.join('\n')}`;
617
+ }
618
+
415
619
  /**
416
620
  * Handle the "remove" subcommand.
417
621
  * @param {string} slug - Worktree slug
@@ -161,6 +161,55 @@ const PLAN_SUBSKILL_DEFINITIONS = [
161
161
 
162
162
  const PLAN_SUBSKILL_IDS = new Set(PLAN_SUBSKILL_DEFINITIONS.map(definition => definition.id));
163
163
 
164
+ // The `smith` orchestrator composes the stage skills end-to-end. Its sub-skills
165
+ // are the stage skill NAMES (each a real `skills/<name>/` dir), unlike plan's
166
+ // fine-grained internal planning phases. Both resolve through the ONE generic
167
+ // registry below (keyed by owning skill), so composition is no longer hardcoded
168
+ // to plan. See docs/work kernel-native-skills composition epic (a0776e61) +
169
+ // smith-orchestrator (7da81cbd).
170
+ const SMITH_SUBSKILL_DEFINITIONS = ['plan', 'dev', 'validate', 'ship', 'review', 'verify']
171
+ .map(stage => ({ id: stage, label: `${stage} stage`, owner: 'smith' }));
172
+
173
+ // Skills that `plan` COMPOSES at the whole-skill level (its SKILL.md `subskills:`
174
+ // frontmatter), distinct from the fine-grained plan.* planning micro-phases in
175
+ // PLAN_SUBSKILL_DEFINITIONS. A composed whole-skill is an advertised sub-skill
176
+ // that must both VALIDATE and RESOLVE, but it is NOT a partialInvocation
177
+ // micro-phase (it maps to no runtime-graph action), so it lives OUTSIDE
178
+ // PLAN_SUBSKILL_IDS (which governs planning.template.only/skip).
179
+ const PLAN_COMPOSED_SUBSKILLS = ['research'];
180
+ const PLAN_COMPOSED_SUBSKILL_DEFINITIONS = PLAN_COMPOSED_SUBSKILLS.map(id => ({
181
+ id,
182
+ label: `${id.charAt(0).toUpperCase()}${id.slice(1)} (composed skill)`,
183
+ kind: 'composed-skill',
184
+ owner: 'plan',
185
+ }));
186
+
187
+ // Generic per-skill sub-skill registry, keyed by owning skill id. Generalizes
188
+ // plan's previously hardcoded PLAN_SUBSKILL_DEFINITIONS / validatePlanSubSkillList
189
+ // so any composing skill (plan, smith, ...) declares its sub-skill set through a
190
+ // single mechanism. This registry is the SINGLE source for BOTH validation (ids)
191
+ // and resolution (definitions), so getSubSkillDefinitions and validateSubSkillList
192
+ // can never disagree. plan's set = its planning micro-phases + composed whole-skills.
193
+ const SUBSKILL_REGISTRY = {
194
+ plan: [...PLAN_SUBSKILL_DEFINITIONS, ...PLAN_COMPOSED_SUBSKILL_DEFINITIONS],
195
+ smith: SMITH_SUBSKILL_DEFINITIONS,
196
+ };
197
+
198
+ // Validation id sets DERIVED from the registry definitions — guarantees
199
+ // validate-vs-resolve parity for every owner (a subskill that validates also
200
+ // resolves, and vice versa).
201
+ const SUBSKILL_IDS_BY_OWNER = Object.fromEntries(
202
+ Object.entries(SUBSKILL_REGISTRY).map(([owner, defs]) => [owner, new Set(defs.map(d => d.id))])
203
+ );
204
+
205
+ function getSubSkillDefinitions(owner) {
206
+ return SUBSKILL_REGISTRY[owner] ? [...SUBSKILL_REGISTRY[owner]] : [];
207
+ }
208
+
209
+ function getSubSkillIds(owner) {
210
+ return new Set(SUBSKILL_IDS_BY_OWNER[owner] || []);
211
+ }
212
+
164
213
  function planningSubSkillFromDefinition(definition) {
165
214
  return PlanningSubSkill({
166
215
  id: definition.id,
@@ -485,7 +534,7 @@ const RESOLVED_RUNTIME_GRAPH = {
485
534
  id: 'adapter.issue',
486
535
  kind: 'issue',
487
536
  label: 'Issue tracking adapter',
488
- config: { primary: 'beads', mirrors: ['github'] },
537
+ config: { primary: 'kernel', mirrors: ['github'] },
489
538
  }),
490
539
  Adapter({
491
540
  id: 'adapter.harness',
@@ -777,31 +826,51 @@ function assertStringList(value, errors, code, message) {
777
826
  return valid;
778
827
  }
779
828
 
780
- function validatePlanSubSkillList(value, errors, path) {
781
- if (!assertStringList(
782
- value,
783
- errors,
784
- 'INVALID_PLAN_SUBSKILL_LIST',
785
- `${path} must be a list of planning sub-skill IDs.`
786
- )) {
829
+ function subSkillCodesForOwner(owner) {
830
+ return owner === 'plan'
831
+ ? { listCode: 'INVALID_PLAN_SUBSKILL_LIST', unknownCode: 'UNKNOWN_PLAN_SUBSKILL', noun: 'planning sub-skill' }
832
+ : { listCode: 'INVALID_SUBSKILL_LIST', unknownCode: 'UNKNOWN_SUBSKILL', noun: `${owner} sub-skill` };
833
+ }
834
+
835
+ // Core list validator against an explicit known-id Set. The two callers below
836
+ // deliberately pass DIFFERENT id sets so the two contracts never leak into each
837
+ // other (see validatePlanSubSkillList).
838
+ function validateAgainstKnownIds(knownIds, codes, value, errors, path) {
839
+ const { listCode, unknownCode, noun } = codes;
840
+ if (!assertStringList(value, errors, listCode, `${path} must be a list of ${noun} IDs.`)) {
787
841
  return undefined;
788
842
  }
789
843
 
790
844
  const normalized = value.map(item => item.trim());
791
845
  let hasUnknown = false;
792
846
  for (const id of normalized) {
793
- if (!PLAN_SUBSKILL_IDS.has(id)) {
847
+ if (!knownIds.has(id)) {
794
848
  hasUnknown = true;
795
849
  errors.push({
796
- code: 'UNKNOWN_PLAN_SUBSKILL',
797
- message: `Unknown planning sub-skill '${id}' in ${CONFIG_SOURCE}.`,
850
+ code: unknownCode,
851
+ message: `Unknown ${noun} '${id}' in ${CONFIG_SOURCE}.`,
798
852
  });
799
853
  }
800
854
  }
801
- if (hasUnknown) {
802
- return undefined;
803
- }
804
- return normalized;
855
+ return hasUnknown ? undefined : normalized;
856
+ }
857
+
858
+ // Generic COMPOSITION validator, keyed by owning skill — validates a skill's
859
+ // ADVERTISED (SKILL.md frontmatter) subskills. For `plan` the known-id set is the
860
+ // composition union (plan.* micro-phases + composed whole-skills like research).
861
+ function validateSubSkillList(owner, value, errors, path) {
862
+ const knownIds = SUBSKILL_IDS_BY_OWNER[owner] || new Set();
863
+ return validateAgainstKnownIds(knownIds, subSkillCodesForOwner(owner), value, errors, path);
864
+ }
865
+
866
+ // partialInvocation validator (.forge/config.yaml planning.template.only/skip).
867
+ // SEPARATE contract from composition: it MUST accept ONLY the fine-grained plan.*
868
+ // MICRO-PHASE ids (PLAN_SUBSKILL_IDS), never composed whole-skill ids like
869
+ // `research` — a composed skill maps to no runtime-graph action, so `only:
870
+ // [research]` would be silent dead config. Validates against PLAN_SUBSKILL_IDS,
871
+ // NOT the composition union, so it fails closed on composed ids.
872
+ function validatePlanSubSkillList(value, errors, path) {
873
+ return validateAgainstKnownIds(PLAN_SUBSKILL_IDS, subSkillCodesForOwner('plan'), value, errors, path);
805
874
  }
806
875
 
807
876
  function applyPlanningMode(template, nextTemplate, errors) {
@@ -968,6 +1037,11 @@ module.exports = {
968
1037
  PlanningSubSkill,
969
1038
  Role,
970
1039
  ROLE_IDS,
1040
+ SUBSKILL_REGISTRY,
1041
+ getSubSkillDefinitions,
1042
+ getSubSkillIds,
1043
+ validateSubSkillList,
1044
+ validatePlanSubSkillList,
971
1045
  loadRuntimeGraphConfig,
972
1046
  lintRuntimeGraphConfig,
973
1047
  resolveRuntimeGraph,