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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/AGENTS.md +18 -7
  2. package/CHANGELOG.md +79 -1
  3. package/CLAUDE.md +0 -12
  4. package/CODING_STANDARDS.md +72 -0
  5. package/README.md +6 -2
  6. package/bin/forge-cmd.js +20 -0
  7. package/bin/forge.js +28 -375
  8. package/docs/INDEX.md +1 -1
  9. package/docs/guides/BEADS_GITHUB_SYNC.md +2 -31
  10. package/docs/guides/MIGRATION.md +4 -4
  11. package/docs/guides/SETUP.md +16 -16
  12. package/docs/reference/COMMANDS.md +8 -5
  13. package/docs/reference/FORGE_KERNEL_STORAGE_MODEL.md +4 -0
  14. package/docs/reference/INSIGHTS_RECAP.md +9 -20
  15. package/docs/reference/INSTALL.md +4 -0
  16. package/docs/reference/LEGACY_CLAIM_REPAIR.md +112 -0
  17. package/docs/reference/RELEASE.md +5 -3
  18. package/docs/reference/TOOLCHAIN.md +8 -0
  19. package/docs/reference/github-accounts.md +134 -0
  20. package/docs/reference/protected-state-surfaces.md +4 -4
  21. package/docs/reference/shepherd.md +114 -35
  22. package/lefthook.yml +12 -0
  23. package/lib/activation/ensure-forge-home.js +33 -15
  24. package/lib/adapters/pr-state-adapter.js +359 -144
  25. package/lib/audit-evidence.js +71 -110
  26. package/lib/base-remote.js +138 -0
  27. package/lib/beta5-compatibility-evidence.js +1093 -0
  28. package/lib/bun-lockfile-proof.js +413 -0
  29. package/lib/bun-workflow-pins.js +461 -0
  30. package/lib/capabilities/index.js +9 -0
  31. package/lib/capabilities/model.js +141 -0
  32. package/lib/capabilities/probes.js +347 -0
  33. package/lib/capped-jsonl-log.js +236 -0
  34. package/lib/codex-skills.js +2 -2
  35. package/lib/commands/_manifest.js +1 -0
  36. package/lib/commands/_registry.js +50 -20
  37. package/lib/commands/clean.js +252 -32
  38. package/lib/commands/dev.js +4 -33
  39. package/lib/commands/doctor.js +37 -6
  40. package/lib/commands/gate.js +197 -27
  41. package/lib/commands/github.js +215 -0
  42. package/lib/commands/hooks.js +276 -30
  43. package/lib/commands/insights.js +8 -3
  44. package/lib/commands/memory.js +66 -2
  45. package/lib/commands/merge.js +1265 -58
  46. package/lib/commands/plan.js +33 -2
  47. package/lib/commands/pr.js +3 -1
  48. package/lib/commands/preflight.js +21 -4
  49. package/lib/commands/prime.js +21 -8
  50. package/lib/commands/push.js +146 -54
  51. package/lib/commands/recall.js +127 -49
  52. package/lib/commands/recap.js +6 -1
  53. package/lib/commands/release.js +39 -3
  54. package/lib/commands/remember.js +28 -4
  55. package/lib/commands/serve.js +26 -9
  56. package/lib/commands/setup.js +323 -98
  57. package/lib/commands/shepherd.js +591 -73
  58. package/lib/commands/ship.js +36 -91
  59. package/lib/commands/skill.js +127 -11
  60. package/lib/commands/status.js +17 -1
  61. package/lib/commands/team.js +47 -8
  62. package/lib/commands/test.js +187 -38
  63. package/lib/commands/validate.js +65 -21
  64. package/lib/commands/worktree.js +359 -45
  65. package/lib/core/runtime-graph.js +1 -1
  66. package/lib/doc-assertions.js +297 -0
  67. package/lib/existing-tdd-gate.js +253 -0
  68. package/lib/fixtures/beta5-corpus/v1/README.md +9 -0
  69. package/lib/fixtures/beta5-corpus/v1/contract/command-contract.json +26 -0
  70. package/lib/fixtures/beta5-corpus/v1/contract/package-contract.json +13 -0
  71. package/lib/fixtures/beta5-corpus/v1/contract/workflow-stage-matrix.json +8 -0
  72. package/lib/fixtures/beta5-corpus/v1/manifest.json +25 -0
  73. package/lib/fixtures/beta5-corpus/v1/state/comments.jsonl +1 -0
  74. package/lib/fixtures/beta5-corpus/v1/state/config.yaml +6 -0
  75. package/lib/fixtures/beta5-corpus/v1/state/dependencies.jsonl +1 -0
  76. package/lib/fixtures/beta5-corpus/v1/state/issues.jsonl +2 -0
  77. package/lib/fixtures/beta5-corpus/v1/state/kernel.sql +20 -0
  78. package/lib/forge-context.js +1 -4
  79. package/lib/forge-issues.js +134 -32
  80. package/lib/gate-events.js +98 -10
  81. package/lib/git-defaults.js +56 -0
  82. package/lib/github-context.js +308 -0
  83. package/lib/global-flags.js +1 -0
  84. package/lib/harness-capability-matrix.js +3 -3
  85. package/lib/hook-renderer.js +122 -5
  86. package/lib/insights.js +96 -80
  87. package/lib/issue-render.js +19 -0
  88. package/lib/kernel/backing-issue.js +14 -2
  89. package/lib/kernel/broker.js +739 -31
  90. package/lib/kernel/claim-reconciler.js +238 -0
  91. package/lib/kernel/cli-broker-factory.js +12 -1
  92. package/lib/kernel/close-on-merge.js +154 -0
  93. package/lib/kernel/fs-class.js +42 -25
  94. package/lib/kernel/lease-enforcer.js +9 -4
  95. package/lib/kernel/legacy-claim-repair.js +442 -0
  96. package/lib/kernel/live-claim-projection.js +26 -0
  97. package/lib/kernel/migrations.js +118 -3
  98. package/lib/kernel/readiness-model.js +184 -12
  99. package/lib/kernel/schema.js +49 -1
  100. package/lib/kernel/sqlite-driver.js +3435 -172
  101. package/lib/kernel/taxonomy-validator.js +4 -1
  102. package/lib/kernel/windows-private-acl.js +239 -0
  103. package/lib/lefthook-wiring.js +21 -1
  104. package/lib/memory/hygiene.js +191 -0
  105. package/lib/memory/router.js +110 -28
  106. package/lib/memory/usage-evidence.js +4 -0
  107. package/lib/memory-digest.js +106 -15
  108. package/lib/memory-recall-events.js +145 -0
  109. package/lib/memory-recall.js +71 -10
  110. package/lib/merge-rules.js +143 -21
  111. package/lib/npm-publish-workflow.js +465 -0
  112. package/lib/orientation.js +68 -43
  113. package/lib/package-root.js +2 -0
  114. package/lib/plugin-catalog.js +14 -4
  115. package/lib/pr-bundle.js +5 -6
  116. package/lib/pr-monitor/auto-actions.js +169 -28
  117. package/lib/pr-monitor/differ.js +110 -4
  118. package/lib/pr-monitor/events.js +0 -0
  119. package/lib/pr-monitor/flow-monitor.js +1424 -0
  120. package/lib/pr-monitor/gather.js +251 -44
  121. package/lib/pr-monitor/journal.js +18 -39
  122. package/lib/pr-monitor/monitor.js +117 -10
  123. package/lib/pr-monitor/process-identity.js +117 -0
  124. package/lib/pr-monitor/reconcile-executor.js +1129 -470
  125. package/lib/pr-monitor/reconcile.js +0 -0
  126. package/lib/pr-monitor/render-summary.js +293 -0
  127. package/lib/pr-monitor/review-preflight.js +269 -0
  128. package/lib/pr-monitor/shepherd-lease.js +38 -20
  129. package/lib/pr-monitor/verdict.js +438 -0
  130. package/lib/pr-monitor/watch-lifecycle.js +145 -27
  131. package/lib/pr-monitor/watch-owner.js +1414 -0
  132. package/lib/pr-monitor/watch.js +129 -58
  133. package/lib/pr-pull.js +33 -14
  134. package/lib/pr-shepherd.js +51 -11
  135. package/lib/preflight/gates.js +65 -18
  136. package/lib/preflight/runner.js +5 -0
  137. package/lib/project-memory.js +178 -4
  138. package/lib/protected-state-authority.js +1100 -0
  139. package/lib/protected-state-surfaces.js +243 -45
  140. package/lib/release-readiness.js +53 -7
  141. package/lib/review-adapter.js +65 -0
  142. package/lib/shell-utils.js +1 -1
  143. package/lib/skills-sync.js +71 -35
  144. package/lib/smart-merge.js +28 -4
  145. package/lib/symlink-utils.js +74 -26
  146. package/lib/upgrade-safety.js +39 -0
  147. package/lib/using-forge.js +19 -6
  148. package/lib/validation/risk-manifest.js +339 -0
  149. package/lib/workflow/enforce-stage.js +44 -0
  150. package/lib/workflow/plan-authority.js +225 -0
  151. package/package.json +12 -9
  152. package/scripts/commitlint.js +13 -15
  153. package/scripts/doc-asserting-tests.js +158 -0
  154. package/scripts/generate-risk-manifest.js +91 -0
  155. package/scripts/github-context-bridge.sh +10 -0
  156. package/scripts/legacy-claim-repair.js +145 -0
  157. package/scripts/lib/behavioral-eval-runner.js +310 -0
  158. package/scripts/lib/behavioral-eval-runtime.js +457 -0
  159. package/scripts/lib/eval-evidence.js +328 -0
  160. package/scripts/lib/eval-runner.js +81 -41
  161. package/scripts/lib/immutable-eval-corpus.js +309 -0
  162. package/scripts/lib/promotion-evidence-loader.js +94 -0
  163. package/scripts/lib/promotion-scorecard.js +314 -0
  164. package/scripts/npm-release-receipt.js +134 -0
  165. package/scripts/process-tree.js +773 -0
  166. package/scripts/protected-state-check.js +479 -31
  167. package/scripts/run-command-eval.js +29 -1
  168. package/scripts/sync-agent-skills.js +333 -34
  169. package/scripts/sync-d20-audit.js +172 -0
  170. package/scripts/test-full-suite.js +935 -37
  171. package/scripts/test-profile.js +13 -3
  172. package/scripts/test.js +271 -57
  173. package/skills/coverage.json +1 -0
  174. package/skills/review/SKILL.md +6 -11
  175. package/skills/review/evals/scorecard.json +4 -4
  176. package/skills/rollback/SKILL.md +4 -11
  177. package/skills/rollback/evals/scorecard.json +3 -3
  178. package/skills/setup/SKILL.md +18 -0
  179. package/skills/setup/evals/scorecard.json +3 -3
  180. package/skills/shepherd/SKILL.md +39 -16
  181. package/skills/shepherd/evals/scorecard.json +4 -4
  182. package/skills/ship/SKILL.md +4 -12
  183. package/skills/ship/evals/scorecard.json +3 -3
  184. package/skills/validate/SKILL.md +3 -0
  185. package/skills/validate/evals/scorecard.json +1 -1
  186. package/skills/worktree/SKILL.md +6 -1
  187. package/skills/worktree/evals/scorecard.json +2 -2
  188. package/lib/beads-setup.js +0 -538
  189. package/lib/beads-sync-scaffold.js +0 -189
  190. package/lib/pat-setup.js +0 -207
  191. package/lib/pr-monitor/render-sticky.js +0 -206
  192. package/lib/pr-monitor/upsert-sticky.js +0 -169
  193. package/scripts/beads-context.sh +0 -577
  194. package/scripts/beads-migrate-to-dolt.sh +0 -7
  195. package/scripts/beads-upgrade-smoke.sh +0 -284
  196. package/scripts/lib/beads-migrate-to-dolt.mjs +0 -503
@@ -34,6 +34,8 @@
34
34
 
35
35
  const fs = require('node:fs');
36
36
  const path = require('node:path');
37
+ const { execFileSync } = require('node:child_process');
38
+ const { parseFrontmatter } = require('./using-forge');
37
39
 
38
40
  /** Canonical skills live under this directory at the repo/package root. */
39
41
  const CANONICAL_SKILLS_DIR = 'skills';
@@ -71,7 +73,7 @@ function isValidSkillName(name) {
71
73
  * @param {string} sourceRoot - Directory containing the canonical `skills/` dir.
72
74
  * @param {object} [options]
73
75
  * @param {Set<string>|string[]} [options.only] - Restrict to these skill names.
74
- * @returns {{name: string, sourcePath: string}[]} Sorted list of skills.
76
+ * @returns {{name: string, sourcePath: string, invocation: string}[]} Sorted list of skills.
75
77
  */
76
78
  function listCanonicalSkills(sourceRoot, options = {}) {
77
79
  const skillsDir = path.join(sourceRoot, CANONICAL_SKILLS_DIR);
@@ -88,9 +90,11 @@ function listCanonicalSkills(sourceRoot, options = {}) {
88
90
  if (only && !only.has(entry.name)) continue;
89
91
 
90
92
  const sourcePath = path.join(skillsDir, entry.name);
91
- if (!fs.existsSync(path.join(sourcePath, 'SKILL.md'))) continue;
93
+ const skillFile = path.join(sourcePath, 'SKILL.md');
94
+ if (!fs.existsSync(skillFile)) continue;
92
95
 
93
- skills.push({ name: entry.name, sourcePath });
96
+ const { invocation } = parseFrontmatter(fs.readFileSync(skillFile, 'utf8'));
97
+ skills.push({ name: entry.name, sourcePath, invocation });
94
98
  }
95
99
 
96
100
  skills.sort((a, b) => a.name.localeCompare(b.name));
@@ -131,6 +135,23 @@ function normalizeContent(buffer) {
131
135
  return buffer.toString('utf8').replace(/\r\n/g, '\n');
132
136
  }
133
137
 
138
+ function gitIgnoredCanonicalPaths(repoRoot, runGit = execFileSync, { strict = false } = {}) {
139
+ if (!fs.existsSync(path.join(repoRoot, '.git'))) return new Set();
140
+ try {
141
+ const output = runGit('git', [
142
+ 'ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', 'skills',
143
+ ], {
144
+ cwd: repoRoot,
145
+ encoding: 'utf8',
146
+ stdio: ['ignore', 'pipe', 'pipe'],
147
+ });
148
+ return new Set(output.split('\0').filter(Boolean).map(repoPath => repoPath.normalize('NFC')));
149
+ } catch (error) {
150
+ if (strict) throw error;
151
+ return new Set();
152
+ }
153
+ }
154
+
134
155
  /**
135
156
  * Remove a symlink sitting at a target path so a real directory can be written
136
157
  * in its place. Only ever removes the link entry itself — never a real
@@ -174,13 +195,18 @@ function clearSymlinkAtPath(targetPath) {
174
195
  * @param {string} sourcePath - Source skill directory.
175
196
  * @param {string} targetPath - Target skill directory.
176
197
  */
177
- function copySkillDir(sourcePath, targetPath) {
198
+ function copySkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
178
199
  fs.mkdirSync(targetPath, { recursive: true });
179
200
  for (const entry of fs.readdirSync(sourcePath, { withFileTypes: true })) {
201
+ const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
180
202
  const src = path.join(sourcePath, entry.name);
181
203
  const dest = path.join(targetPath, entry.name);
204
+ if (excludeRelativePaths.has(relative.normalize('NFC'))) {
205
+ fs.rmSync(dest, { recursive: true, force: true });
206
+ continue;
207
+ }
182
208
  if (entry.isDirectory()) {
183
- copySkillDir(src, dest);
209
+ copySkillDir(src, dest, excludeRelativePaths, relative);
184
210
  } else if (entry.isFile()) {
185
211
  fs.copyFileSync(src, dest);
186
212
  }
@@ -201,7 +227,12 @@ function copySkillDir(sourcePath, targetPath) {
201
227
  * @param {boolean} [params.clean=false] - Remove canonical-managed stale dirs first.
202
228
  * @returns {{written: string[]}} Names of skills written.
203
229
  */
204
- function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false }) {
230
+ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false, excludeRelativePaths }) {
231
+ if (excludeRelativePaths === undefined) {
232
+ excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(sourceRoot)]
233
+ .map(repoPath => repoPath.slice('skills/'.length))
234
+ .filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
235
+ }
205
236
  const skills = listCanonicalSkills(sourceRoot, { only });
206
237
  fs.mkdirSync(targetSkillsDir, { recursive: true });
207
238
 
@@ -222,7 +253,7 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
222
253
  // Defensive: clear a pre-existing (possibly dangling) symlink so the copy
223
254
  // doesn't fail. Only symlinks are removed — real dirs are overwritten in place.
224
255
  clearSymlinkAtPath(targetPath);
225
- copySkillDir(skill.sourcePath, targetPath);
256
+ copySkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name);
226
257
  written.push(skill.name);
227
258
  }
228
259
  return { written };
@@ -235,9 +266,12 @@ function populateAgentSkills({ sourceRoot, targetSkillsDir, only, clean = false
235
266
  * @param {string} targetPath - Target (generated) skill dir.
236
267
  * @returns {{file: string, status: 'missing'|'changed'|'extra'}[]} Drift entries.
237
268
  */
238
- function diffSkillDir(sourcePath, targetPath) {
269
+ function diffSkillDir(sourcePath, targetPath, excludeRelativePaths = new Set(), prefix = '') {
239
270
  const drift = [];
240
- const sourceFiles = new Set(listFilesRecursive(sourcePath));
271
+ const sourceFiles = new Set(listFilesRecursive(sourcePath).filter(rel => {
272
+ const relative = prefix ? `${prefix}/${rel}` : rel;
273
+ return !excludeRelativePaths.has(relative.normalize('NFC'));
274
+ }));
241
275
  const targetFiles = new Set(listFilesRecursive(targetPath));
242
276
 
243
277
  for (const rel of sourceFiles) {
@@ -261,6 +295,28 @@ function diffSkillDir(sourcePath, targetPath) {
261
295
  return drift;
262
296
  }
263
297
 
298
+ function collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }) {
299
+ const agentDir = path.join(repoRoot, rel);
300
+ const drift = [];
301
+ const targetNames = fs.readdirSync(agentDir, { withFileTypes: true })
302
+ .filter((entry) => entry.isDirectory())
303
+ .map((entry) => entry.name);
304
+ for (const skill of skills) {
305
+ const targetPath = path.join(agentDir, skill.name);
306
+ if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
307
+ drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
308
+ continue;
309
+ }
310
+ for (const entry of diffSkillDir(skill.sourcePath, targetPath, excludeRelativePaths, skill.name)) {
311
+ drift.push({ agent: rel, skill: skill.name, ...entry });
312
+ }
313
+ }
314
+ for (const name of targetNames) {
315
+ if (!canonicalByName.has(name)) drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
316
+ }
317
+ return drift;
318
+ }
319
+
264
320
  /**
265
321
  * Check that existing agent skill mirrors match the canonical source.
266
322
  *
@@ -276,9 +332,12 @@ function diffSkillDir(sourcePath, targetPath) {
276
332
  * @param {string[]} [params.agentSkillDirs] - Override the agent dirs to scan.
277
333
  * @returns {{inSync: boolean, checkedAgents: string[], drift: object[]}}
278
334
  */
279
- function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS }) {
335
+ function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS, runGit = execFileSync }) {
280
336
  const skills = listCanonicalSkills(repoRoot, { only });
281
337
  const canonicalByName = new Map(skills.map((s) => [s.name, s]));
338
+ const excludeRelativePaths = new Set([...gitIgnoredCanonicalPaths(repoRoot, runGit)]
339
+ .map(repoPath => repoPath.slice('skills/'.length))
340
+ .filter(relative => !/^[^/]+\/SKILL\.md$/.test(relative)));
282
341
  const drift = [];
283
342
  const checkedAgents = [];
284
343
 
@@ -287,31 +346,7 @@ function checkSkillsSync({ repoRoot, only, agentSkillDirs = AGENT_SKILL_DIRS })
287
346
  if (!fs.existsSync(agentDir)) continue; // populated at setup time; absence ≠ drift
288
347
  checkedAgents.push(rel);
289
348
 
290
- const targetNames = fs
291
- .readdirSync(agentDir, { withFileTypes: true })
292
- .filter((e) => e.isDirectory())
293
- .map((e) => e.name);
294
-
295
- // Skills present canonically but missing/changed in the mirror.
296
- for (const skill of skills) {
297
- const targetPath = path.join(agentDir, skill.name);
298
- // A regular file at the skill path is corruption, not a valid mirror:
299
- // treat it as drift instead of letting diffSkillDir() throw ENOTDIR.
300
- if (!fs.existsSync(targetPath) || !fs.statSync(targetPath).isDirectory()) {
301
- drift.push({ agent: rel, skill: skill.name, file: 'SKILL.md', status: 'missing' });
302
- continue;
303
- }
304
- for (const entry of diffSkillDir(skill.sourcePath, targetPath)) {
305
- drift.push({ agent: rel, skill: skill.name, ...entry });
306
- }
307
- }
308
-
309
- // Skill dirs in the mirror with no canonical source = stale.
310
- for (const name of targetNames) {
311
- if (!canonicalByName.has(name)) {
312
- drift.push({ agent: rel, skill: name, file: '*', status: 'stale' });
313
- }
314
- }
349
+ drift.push(...collectAgentSkillDrift({ repoRoot, rel, skills, canonicalByName, excludeRelativePaths }));
315
350
  }
316
351
 
317
352
  return { inSync: drift.length === 0, checkedAgents, drift };
@@ -324,6 +359,7 @@ module.exports = {
324
359
  listCanonicalSkills,
325
360
  listFilesRecursive,
326
361
  clearSymlinkAtPath,
362
+ gitIgnoredCanonicalPaths,
327
363
  populateAgentSkills,
328
364
  diffSkillDir,
329
365
  checkSkillsSync,
@@ -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
  }
@@ -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
 
@@ -235,6 +268,12 @@ function applySelfHeal(projectRoot, report) {
235
268
  applied.push({ path: '.forge/log.jsonl' });
236
269
  }
237
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
+
238
277
  return {
239
278
  refused: false,
240
279
  applied,
@@ -100,10 +100,10 @@ function applyFrontmatterLine(state, line) {
100
100
  if (state.inDescription) {
101
101
  if (/^\s+\S/.test(line)) {
102
102
  state.descParts.push(line.trim());
103
- } else if (line.trim() !== '') {
104
- state.inDescription = false;
103
+ return;
105
104
  }
106
- return;
105
+ if (line.trim() === '') return;
106
+ state.inDescription = false;
107
107
  }
108
108
  // Detect the `name:` key without a regex: SonarCloud flags every /^name:.../ variant for
109
109
  // super-linear backtracking. startsWith + slice is behavior-identical — the remainder is
@@ -113,6 +113,15 @@ function applyFrontmatterLine(state, line) {
113
113
  if (value) state.name = unquote(value);
114
114
  return;
115
115
  }
116
+ if (line.startsWith('invocation:')) {
117
+ const value = line.slice('invocation:'.length).trim();
118
+ const quote = value[0];
119
+ const hasMatchingQuotes = value.length >= 2
120
+ && (quote === '"' || quote === "'")
121
+ && value.endsWith(quote);
122
+ state.invocation = hasMatchingQuotes ? value.slice(1, -1) : value;
123
+ return;
124
+ }
116
125
  const descMatch = /^description:\s*(.*)$/.exec(line);
117
126
  if (descMatch) {
118
127
  state.inDescription = true;
@@ -124,10 +133,14 @@ function applyFrontmatterLine(state, line) {
124
133
  /** Parse the name and (flattened) description from a SKILL.md frontmatter block. */
125
134
  function parseFrontmatter(raw) {
126
135
  const block = frontmatterBlock(raw);
127
- if (block === null) return { name: null, description: '' };
128
- const state = { name: null, descParts: [], inDescription: false };
136
+ if (block === null) return { name: null, description: '', invocation: 'model' };
137
+ const state = { name: null, descParts: [], inDescription: false, invocation: 'model' };
129
138
  for (const line of block.split(/\r?\n/)) applyFrontmatterLine(state, line);
130
- return { name: state.name, description: state.descParts.join(' ').replace(/\s+/g, ' ').trim() };
139
+ return {
140
+ name: state.name,
141
+ description: state.descParts.join(' ').replace(/\s+/g, ' ').trim(),
142
+ invocation: state.invocation,
143
+ };
131
144
  }
132
145
 
133
146
  /** Read one skill dir into a `{ name, description }` catalog entry, or null when unreadable. */