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
@@ -34,6 +34,7 @@
34
34
 
35
35
  const fs = require('node:fs');
36
36
  const path = require('node:path');
37
+ const { parseFrontmatter } = require('./using-forge');
37
38
 
38
39
  /** Canonical skills live under this directory at the repo/package root. */
39
40
  const CANONICAL_SKILLS_DIR = 'skills';
@@ -71,7 +72,7 @@ function isValidSkillName(name) {
71
72
  * @param {string} sourceRoot - Directory containing the canonical `skills/` dir.
72
73
  * @param {object} [options]
73
74
  * @param {Set<string>|string[]} [options.only] - Restrict to these skill names.
74
- * @returns {{name: string, sourcePath: string}[]} Sorted list of skills.
75
+ * @returns {{name: string, sourcePath: string, invocation: string}[]} Sorted list of skills.
75
76
  */
76
77
  function listCanonicalSkills(sourceRoot, options = {}) {
77
78
  const skillsDir = path.join(sourceRoot, CANONICAL_SKILLS_DIR);
@@ -88,9 +89,11 @@ function listCanonicalSkills(sourceRoot, options = {}) {
88
89
  if (only && !only.has(entry.name)) continue;
89
90
 
90
91
  const sourcePath = path.join(skillsDir, entry.name);
91
- if (!fs.existsSync(path.join(sourcePath, 'SKILL.md'))) continue;
92
+ const skillFile = path.join(sourcePath, 'SKILL.md');
93
+ if (!fs.existsSync(skillFile)) continue;
92
94
 
93
- skills.push({ name: entry.name, sourcePath });
95
+ const { invocation } = parseFrontmatter(fs.readFileSync(skillFile, 'utf8'));
96
+ skills.push({ name: entry.name, sourcePath, invocation });
94
97
  }
95
98
 
96
99
  skills.sort((a, b) => a.name.localeCompare(b.name));
@@ -1,13 +1,32 @@
1
1
  'use strict';
2
2
 
3
+ const IMPROVEMENT_FOOTER = `---\n\n## Improving This Workflow\n\nEvery time you give the same instruction twice, add it to this file:\n1. User-specific rules: Add to USER:START section above\n2. Forge workflow improvements: Suggest to forge maintainers\n\n**Keep this file updated as you learn about the project.**\n\n---\n\nSee \`AGENTS.md\` for complete workflow guide.\nSee \`docs/TOOLCHAIN.md\` for comprehensive tool reference.\n`;
4
+
5
+ function extractUnmanagedContent(existingContent) {
6
+ let unmanagedContent = existingContent
7
+ .replace(/\r\n?/g, '\n')
8
+ .replace(/<!-- FORGE:SETUP-INSTRUCTIONS[\s\S]*?-->/g, '')
9
+ .replace(/<!-- FORGE:START.*?-->[\s\S]*?<!-- FORGE:END -->/g, '')
10
+ .trim();
11
+
12
+ unmanagedContent = unmanagedContent.replace(/^# AGENTS\.md\s*/, '').trim();
13
+
14
+ const footer = IMPROVEMENT_FOOTER.trim();
15
+ // The generated footer may precede user-appended text; remove every exact managed copy.
16
+ unmanagedContent = unmanagedContent.split(footer).join('').trim();
17
+
18
+ return unmanagedContent;
19
+ }
20
+
3
21
  /**
4
22
  * Smart merge for AGENTS.md - preserves USER sections, updates FORGE sections.
5
23
  *
6
- * Handles four cases:
24
+ * Handles five cases:
7
25
  * 1. No markers at all: Wrap existing content in USER markers, append FORGE section
8
26
  * 2. USER markers but no FORGE markers: Keep USER section, insert FORGE section
9
27
  * 3. Both markers present: Preserve USER section, update FORGE section (existing behavior)
10
- * 4. Empty existing content: Return only FORGE section (no empty USER block)
28
+ * 4. Forge markers without USER markers: Preserve surrounding unmanaged content as USER content
29
+ * 5. Empty existing content: Return only FORGE section (no empty USER block)
11
30
  *
12
31
  * @param {string} existingContent - The current AGENTS.md content
13
32
  * @param {string} newContent - The new template content containing FORGE section
@@ -20,6 +39,7 @@ function smartMergeAgentsMd(existingContent, newContent) {
20
39
 
21
40
  // Check if existing content has markers
22
41
  const hasUserMarkers = existingContent.includes('<!-- USER:START') && existingContent.includes('<!-- USER:END');
42
+ const hasForgeMarkers = existingContent.includes('<!-- FORGE:START') && existingContent.includes('<!-- FORGE:END');
23
43
 
24
44
  let userSection;
25
45
 
@@ -28,8 +48,12 @@ function smartMergeAgentsMd(existingContent, newContent) {
28
48
  const userMatch = (/(<!-- USER:START.*?-->[\s\S]*?<!-- USER:END -->)/).exec(existingContent);
29
49
  userSection = userMatch ? userMatch[0] : '';
30
50
  } else if (existingContent.trim() === '') {
31
- // Empty existing content: no USER block at all
32
51
  userSection = null;
52
+ } else if (hasForgeMarkers) {
53
+ const unmanagedContent = extractUnmanagedContent(existingContent);
54
+ userSection = unmanagedContent
55
+ ? `<!-- USER:START -->\n${unmanagedContent}\n<!-- USER:END -->`
56
+ : null;
33
57
  } else {
34
58
  // No markers: wrap entire existing content in USER markers
35
59
  userSection = `<!-- USER:START -->\n${existingContent.trim()}\n<!-- USER:END -->`;
@@ -56,7 +80,7 @@ function smartMergeAgentsMd(existingContent, newContent) {
56
80
  merged += forgeSection + '\n\n';
57
81
 
58
82
  // Add footer
59
- merged += `---\n\n## Improving This Workflow\n\nEvery time you give the same instruction twice, add it to this file:\n1. User-specific rules: Add to USER:START section above\n2. Forge workflow improvements: Suggest to forge maintainers\n\n**Keep this file updated as you learn about the project.**\n\n---\n\nSee \`AGENTS.md\` for complete workflow guide.\nSee \`docs/TOOLCHAIN.md\` for comprehensive tool reference.\n`;
83
+ merged += IMPROVEMENT_FOOTER;
60
84
 
61
85
  return merged;
62
86
  }
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const { secureExecFileSync } = require('../shell-utils.js');
4
+
5
+ // Developer identity for the status read model. Extracted from the retired
6
+ // lib/status/beads-snapshot.js: the identity lookup is backend-agnostic (it reads
7
+ // git config, never an issue store), so it outlived the Beads reader it shipped in.
8
+ // Kept in its own module so lib/status/snapshot.js stays read-model-only.
9
+
10
+ /**
11
+ * Read a single git config value, or '' when git is unavailable or the key is unset.
12
+ * Never throws — status must render even in a non-git directory.
13
+ *
14
+ * @param {string} projectRoot
15
+ * @param {string} key — git config key (e.g. 'user.email')
16
+ * @returns {string}
17
+ */
18
+ function getGitConfig(projectRoot, key) {
19
+ try {
20
+ return secureExecFileSync('git', ['config', key], {
21
+ encoding: 'utf8',
22
+ cwd: projectRoot,
23
+ stdio: ['pipe', 'pipe', 'pipe'],
24
+ }).trim();
25
+ } catch (_error) {
26
+ return '';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * The current developer's git identity, used to match claims to "my" work.
32
+ *
33
+ * @param {string} projectRoot
34
+ * @returns {{ email: string, name: string }}
35
+ */
36
+ function getDeveloperIdentity(projectRoot) {
37
+ return {
38
+ email: getGitConfig(projectRoot, 'user.email'),
39
+ name: getGitConfig(projectRoot, 'user.name'),
40
+ };
41
+ }
42
+
43
+ module.exports = {
44
+ getDeveloperIdentity,
45
+ getGitConfig,
46
+ };
@@ -173,43 +173,8 @@ function buildPersonalStatusJson({ context, snapshot, workflowResult = null }) {
173
173
  };
174
174
  }
175
175
 
176
- function formatBoard({ context, snapshot }) {
177
- return [
178
- '',
179
- 'Team Runtime Board',
180
- `Source: local Beads runtime state`,
181
- `Branch: ${context.branch}`,
182
- `Working tree: ${context.workingTree.summary}`,
183
- '',
184
- ...buildSection('Active', (snapshot.active || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
185
- ...buildSection('Ready', (snapshot.ready || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
186
- ...buildSection('Blocked', (snapshot.blocked || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
187
- ...buildSection('Stale', (snapshot.stale || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
188
- ...buildSection('Parked', (snapshot.parked || []).map(issue => formatIssue(issue, { includeStatus: true })), { limit: DEFAULT_SECTION_LIMIT }),
189
- ...buildSection('Recent Completions', (snapshot.recentCompleted || []).map(issue => formatIssue(issue)), { limit: DEFAULT_SECTION_LIMIT }),
190
- ...buildSection('Limits', snapshot.limits || []),
191
- ].join('\n');
192
- }
193
-
194
- function buildBoardJson({ context, snapshot }) {
195
- return {
196
- context,
197
- board: {
198
- active: (snapshot.active || []).map(toIssueSummary),
199
- ready: (snapshot.ready || []).map(toIssueSummary),
200
- blocked: (snapshot.blocked || []).map(toIssueSummary),
201
- stale: (snapshot.stale || []).map(toIssueSummary),
202
- parked: (snapshot.parked || []).map(toIssueSummary),
203
- recentCompleted: (snapshot.recentCompleted || []).map(toIssueSummary),
204
- },
205
- limits: snapshot.limits || [],
206
- };
207
- }
208
-
209
176
  module.exports = {
210
- buildBoardJson,
211
177
  buildPersonalStatusJson,
212
- formatBoard,
213
178
  formatRunNextLines,
214
179
  formatZeroArgStatus,
215
180
  toIssueSummary,
@@ -2,15 +2,14 @@
2
2
 
3
3
  const { resolveIssueBackend } = require('../issue-backend.js');
4
4
  const { runIssueOperation: defaultRunIssueOperation } = require('../forge-issues.js');
5
- const { readBeadsSnapshot, getDeveloperIdentity } = require('./beads-snapshot.js');
5
+ const { getDeveloperIdentity } = require('./identity.js');
6
6
 
7
7
  // Kernel status vocabulary (taxonomy-validator): 'open', 'in_progress', 'review',
8
8
  // the parked 'backlog', and the terminal 'done' / 'cancelled'. `ready` / `blocked`
9
9
  // are DERIVED read-model facts, never stored. An issue is treated as active here when
10
10
  // it is OPEN and carries a live claim (claimed_by); parked (`backlog`) work is its own
11
11
  // bucket so it stays visible instead of vanishing between ready and done. These buckets
12
- // mirror the shape readBeadsSnapshot produces so lib/status/presenter.js consumes
13
- // either backend's snapshot identically.
12
+ // are the contract lib/status/presenter.js renders against.
14
13
  const KERNEL_LIMITS = Object.freeze([
15
14
  'Reads Forge Kernel issue authority (ready/blocked/stale/active).',
16
15
  'Does not read GitHub review, CI, project, or sync freshness state.',
@@ -102,7 +101,7 @@ function emptyKernelSnapshot(developer) {
102
101
  * @param {object} [options]
103
102
  * @param {function} [options.runIssueOperation] — injectable kernel read (tests)
104
103
  * @param {object} [options.env]
105
- * @returns {Promise<object>} snapshot shaped like readBeadsSnapshot's output
104
+ * @returns {Promise<object>} snapshot in the presenter's bucket contract
106
105
  */
107
106
  async function readKernelSnapshot(projectRoot, options = {}) {
108
107
  const runIssueOperation = options.runIssueOperation || defaultRunIssueOperation;
@@ -153,29 +152,25 @@ async function readKernelSnapshot(projectRoot, options = {}) {
153
152
  }
154
153
 
155
154
  /**
156
- * Read the personal/board status snapshot from the active issue backend. Reads the
157
- * Kernel by default (the flagship `forge status` view); reads Beads only when Beads is
158
- * explicitly selected (--issue-backend beads / FORGE_ISSUE_BACKEND=beads /
159
- * issueBackend: beads in .forge/config.yaml). Resolution reuses lib/issue-backend.js
160
- * so the snapshot never drifts from the issue commands' backend authority.
155
+ * Read the personal/board status snapshot from the Kernel — the only issue backend.
156
+ *
157
+ * A repo that still carries the retired `issueBackend: beads` signal alongside an
158
+ * unmigrated `.beads/*.jsonl` store now renders the (empty) kernel board rather than
159
+ * the legacy Beads one; `forge upgrade`'s advisory is what points those users at
160
+ * `forge migrate --from beads`.
161
161
  *
162
162
  * @param {string} projectRoot
163
- * @param {object} [options] — forwarded to the backend reader; `issueBackend` selects
164
- * an explicit backend, `env` overrides process.env, `backend` short-circuits resolution.
163
+ * @param {object} [options] — forwarded to the backend reader; `env` overrides
164
+ * process.env, `backend` short-circuits resolution entirely.
165
165
  * @returns {Promise<object>} snapshot for lib/status/presenter.js
166
166
  */
167
167
  async function readStatusSnapshot(projectRoot, options = {}) {
168
168
  const backend = options.backend || resolveIssueBackend({
169
- deps: options.issueBackend ? { issueBackend: options.issueBackend } : {},
170
169
  env: options.env || process.env,
171
170
  projectRoot,
172
171
  warn: () => {},
173
172
  });
174
173
 
175
- if (backend === 'beads') {
176
- return readBeadsSnapshot(projectRoot, options);
177
- }
178
-
179
174
  return readKernelSnapshot(projectRoot, { ...options, backend });
180
175
  }
181
176
 
@@ -18,6 +18,40 @@ const path = require('node:path');
18
18
  const HEADER_COMMENT =
19
19
  '<!-- This file is a copy of AGENTS.md. Keep in sync manually or use: bunx forge setup --symlink -->';
20
20
 
21
+ const SYMLINK_FALLBACK_ERRORS = new Set(['EACCES', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM']);
22
+
23
+ function inspectExistingDestination(target, linkPath) {
24
+ let stat;
25
+ try {
26
+ stat = fs.lstatSync(linkPath);
27
+ } catch (err) {
28
+ if (err.code === 'ENOENT') return null;
29
+ throw err;
30
+ }
31
+
32
+ if (stat.isSymbolicLink()) {
33
+ let linkedTarget;
34
+ try {
35
+ linkedTarget = fs.readlinkSync(linkPath);
36
+ } catch (err) {
37
+ if (err.code === 'ENOENT') return null;
38
+ throw err;
39
+ }
40
+ if (path.resolve(path.dirname(linkPath), linkedTarget) === path.resolve(target)) {
41
+ return 'linked';
42
+ }
43
+ }
44
+ if (stat.isDirectory()) {
45
+ console.warn(` Warning: Skipped ${linkPath} because it is a directory. Remove it manually and re-run setup.`);
46
+ return '';
47
+ }
48
+ if (stat.isFile() && fs.readFileSync(linkPath, 'utf8').trim() === '@AGENTS.md') {
49
+ return 'existing-import';
50
+ }
51
+ console.warn(` Warning: Skipped ${linkPath} because an existing destination must be preserved.`);
52
+ return '';
53
+ }
54
+
21
55
  /**
22
56
  * Create a symlink from `linkPath` pointing to `target`.
23
57
  * If symlink creation fails (e.g., EPERM on Windows without admin),
@@ -28,7 +62,7 @@ const HEADER_COMMENT =
28
62
  * @param {string} linkPath - Absolute path for the symlink/copy (e.g., CLAUDE.md)
29
63
  * @param {Object} [options={}] - Options
30
64
  * @param {boolean} [options.symlinkOnly=false] - When true, skip copy fallback (--symlink flag)
31
- * @returns {'linked'|'copied'|''} Result indicator
65
+ * @returns {'linked'|'copied'|'existing-import'|''} Result indicator
32
66
  */
33
67
  function createSymlinkOrCopy(target, linkPath, options = {}) {
34
68
  try {
@@ -38,17 +72,9 @@ function createSymlinkOrCopy(target, linkPath, options = {}) {
38
72
  return '';
39
73
  }
40
74
 
41
- // Remove existing file/symlink at linkPath
42
- if (fs.existsSync(linkPath)) {
43
- const stat = fs.lstatSync(linkPath);
44
- if (stat.isDirectory()) {
45
- console.warn(
46
- ` ⚠ Skipped ${linkPath} (a directory exists at this path). Remove it manually and re-run setup.`
47
- );
48
- return '';
49
- }
50
- fs.unlinkSync(linkPath);
51
- }
75
+ // Existing agent files belong to the user. lstat also sees dangling links.
76
+ const existing = inspectExistingDestination(target, linkPath);
77
+ if (existing !== null) return existing;
52
78
 
53
79
  // Ensure parent directory exists
54
80
  const linkDir = path.dirname(linkPath);
@@ -56,22 +82,44 @@ function createSymlinkOrCopy(target, linkPath, options = {}) {
56
82
  fs.mkdirSync(linkDir, { recursive: true });
57
83
  }
58
84
 
59
- // Attempt symlink (relative path for portability)
60
- try {
61
- const relPath = path.relative(linkDir, target);
62
- fs.symlinkSync(relPath, linkPath);
63
- return 'linked';
64
- } catch (_symlinkErr) {
65
- // Expected: symlink creation fails with EPERM on Windows without admin privileges — fall back to copy
66
- if (options.symlinkOnly) {
67
- console.warn(` ⚠ Symlink failed for ${linkPath} (--symlink requires symlink support)`);
68
- return '';
85
+ for (let attempt = 0; attempt < 2; attempt += 1) {
86
+ // Attempt symlink (relative path for portability)
87
+ try {
88
+ const relPath = path.relative(linkDir, target);
89
+ fs.symlinkSync(relPath, linkPath);
90
+ return 'linked';
91
+ } catch (symlinkErr) {
92
+ if (symlinkErr.code === 'EEXIST') {
93
+ const racedExisting = inspectExistingDestination(target, linkPath);
94
+ if (racedExisting !== null) return racedExisting;
95
+ continue;
96
+ }
97
+ if (!SYMLINK_FALLBACK_ERRORS.has(symlinkErr.code)) {
98
+ throw symlinkErr;
99
+ }
100
+ if (options.symlinkOnly) {
101
+ console.warn(` ⚠ Symlink failed for ${linkPath} (--symlink requires symlink support)`);
102
+ return '';
103
+ }
104
+ const content = fs.readFileSync(target, 'utf-8');
105
+ try {
106
+ fs.writeFileSync(linkPath, HEADER_COMMENT + '\n' + content, {
107
+ encoding: 'utf8',
108
+ flag: 'wx',
109
+ });
110
+ return 'copied';
111
+ } catch (copyErr) {
112
+ if (copyErr.code === 'EEXIST') {
113
+ const racedExisting = inspectExistingDestination(target, linkPath);
114
+ if (racedExisting !== null) return racedExisting;
115
+ continue;
116
+ }
117
+ throw copyErr;
118
+ }
69
119
  }
70
- // Fall back to copy with header
71
- const content = fs.readFileSync(target, 'utf-8');
72
- fs.writeFileSync(linkPath, HEADER_COMMENT + '\n' + content, 'utf-8');
73
- return 'copied';
74
120
  }
121
+ console.warn(` ⚠ Could not create ${linkPath} after repeated conflicts; please re-run setup.`);
122
+ return '';
75
123
  } catch (err) {
76
124
  console.error(` ✗ Failed to link/copy ${target} -> ${linkPath}: ${err.message}`);
77
125
  return '';
@@ -8,6 +8,14 @@ const { resolvePatchIntentRecords } = require('./patch-intent');
8
8
  const { verifyForgeLock, readForgeLock } = require('./forge-lock');
9
9
  const { readConfigBackend, resolveIssueBackend } = require('./issue-backend');
10
10
  const { detectBeadsJsonlSource } = require('./beads-detect');
11
+ const {
12
+ FORGE_HOOK_CONTRACT,
13
+ hasForgeClaudeHooks,
14
+ hasMultipleHardLinks,
15
+ HookConfigParseError,
16
+ renderHookConfig,
17
+ } = require('./hook-renderer');
18
+ const { assertNoAncestorSymlinkEscape, assertNoSymlinkEscape } = require('./protected-state-surfaces');
11
19
 
12
20
  function checkStatus(ok) {
13
21
  return ok ? 'pass' : 'fail';
@@ -55,6 +63,31 @@ function buildSelfHealCandidates(projectRoot) {
55
63
  description: 'Create missing Forge audit log file',
56
64
  });
57
65
  }
66
+ const claudeDir = path.join(projectRoot, '.claude');
67
+ if (fs.existsSync(claudeDir)) {
68
+ const claudeSettings = path.join(claudeDir, 'settings.json');
69
+ const unsafePath =
70
+ assertNoAncestorSymlinkEscape(projectRoot, claudeSettings) ||
71
+ assertNoSymlinkEscape(projectRoot, claudeSettings);
72
+ if (unsafePath || hasMultipleHardLinks(claudeSettings)) return candidates;
73
+ const existing = fs.existsSync(claudeSettings) ? fs.readFileSync(claudeSettings, 'utf8') : '';
74
+ try {
75
+ if (!hasForgeClaudeHooks(existing, FORGE_HOOK_CONTRACT)) {
76
+ candidates.push({
77
+ id: 'claude-hooks',
78
+ path: '.claude/settings.json',
79
+ description: 'Merge missing Forge-owned Claude lifecycle hooks',
80
+ });
81
+ }
82
+ } catch (error) {
83
+ if (!(error instanceof HookConfigParseError)) throw error;
84
+ candidates.push({
85
+ id: 'claude-hooks',
86
+ path: '.claude/settings.json',
87
+ description: 'Back up malformed Claude settings before hook repair',
88
+ });
89
+ }
90
+ }
58
91
  return candidates;
59
92
  }
60
93
 
@@ -67,12 +100,13 @@ function safeConfigBackend(projectRoot) {
67
100
  }
68
101
 
69
102
  // Detect the 0.0.10 -> current breaking boundary that hides a returning user's
70
- // issues (kernel issue a5399f3d): a `.beads/*.jsonl` store still present while the
71
- // default backend has flipped to the Kernel. `needsMigration` is true only when
72
- // the user has NOT explicitly opted back into Beads — via `.forge/config.yaml`
73
- // OR `FORGE_ISSUE_BACKEND` — so the advisory respects the SAME env+config opt-in
74
- // the issue-path nudge does (both resolve through resolveIssueBackend). Uses the
75
- // single shared detector so the two surfaces cannot drift.
103
+ // issues (kernel issue a5399f3d): a `.beads/*.jsonl` store still present now that
104
+ // the Kernel is the only backend. There is no longer any way to opt back into
105
+ // Beads, so a leftover `issueBackend: beads` in `.forge/config.yaml` (or
106
+ // FORGE_ISSUE_BACKEND) can no longer suppress this advisory — resolveIssueBackend
107
+ // answers 'kernel' regardless, which is precisely when the user needs to migrate.
108
+ // `configBackend` is still reported so the surface can name the stale setting.
109
+ // Uses the single shared detector so the two surfaces cannot drift.
76
110
  function buildBeadsMigrationSummary(projectRoot, env = process.env) {
77
111
  const jsonlPresent = detectBeadsJsonlSource(projectRoot) !== null;
78
112
  const configBackend = safeConfigBackend(projectRoot);
@@ -179,13 +213,11 @@ function appendBeadsMigration(lines, beadsMigration) {
179
213
  lines.push(
180
214
  '',
181
215
  'Breaking change since 0.0.10 — action required',
182
- 'Detected a Beads issue store (.beads/*.jsonl). Forge now defaults to the Kernel',
216
+ 'Detected a Beads issue store (.beads/*.jsonl). The Kernel is now the only',
183
217
  'issue backend, so these issues will NOT appear until migrated (your data is safe',
184
218
  'on disk in the meantime). To migrate:',
185
219
  ' forge migrate --from beads # import your Beads issues into the Kernel',
186
220
  ' forge setup # (re)wire hooks + provision the Kernel store',
187
- 'Prefer to stay on Beads? Set `issueBackend: beads` in .forge/config.yaml '
188
- + '(or FORGE_ISSUE_BACKEND=beads).',
189
221
  );
190
222
  }
191
223
 
@@ -236,6 +268,12 @@ function applySelfHeal(projectRoot, report) {
236
268
  applied.push({ path: '.forge/log.jsonl' });
237
269
  }
238
270
 
271
+ if (report.selfHealCandidates.some(candidate => candidate.id === 'claude-hooks')) {
272
+ const result = renderHookConfig({ harness: 'claude', targetRoot: projectRoot });
273
+ if (result.wrote) applied.push({ path: '.claude/settings.json' });
274
+ else if (result.backup) applied.push({ path: path.relative(projectRoot, result.backup) });
275
+ }
276
+
239
277
  return {
240
278
  refused: false,
241
279
  applied,