forgedock 1.0.16 → 1.0.18

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 (190) hide show
  1. package/README.md +54 -3
  2. package/bin/cross-publish.mjs +344 -0
  3. package/bin/forgedock.mjs +2205 -541
  4. package/bin/github-app-manifest.json.license +2 -0
  5. package/bin/hooks/session-start.mjs +4 -5
  6. package/bin/registry.mjs +20 -0
  7. package/bin/tui.mjs +172 -96
  8. package/commands/adopt.md +433 -0
  9. package/commands/analytics.md +2 -0
  10. package/commands/audit-agents.md +2 -0
  11. package/commands/audit.md +2 -0
  12. package/commands/autopilot.md +77 -0
  13. package/commands/changelog.md +354 -0
  14. package/commands/cleanup.md +2 -0
  15. package/commands/compat-audit.md +330 -0
  16. package/commands/deploy-info.md +6 -5
  17. package/commands/design-bench.md +176 -0
  18. package/commands/design-render-critique-loop.md +103 -0
  19. package/commands/design.md +259 -0
  20. package/commands/diagnose.md +483 -0
  21. package/commands/explain.md +349 -0
  22. package/commands/forge-stats.md +2 -0
  23. package/commands/forgedock-init.md +2 -0
  24. package/commands/geo-audit.md +2 -0
  25. package/commands/incident-response.md +2 -0
  26. package/commands/issue.md +9 -3
  27. package/commands/milestone.md +2 -0
  28. package/commands/optimize.md +411 -0
  29. package/commands/orchestrate.md +239 -0
  30. package/commands/pipeline-health.md +60 -1
  31. package/commands/qa-sweep.md +39 -2
  32. package/commands/quality-gate.md +67 -0
  33. package/commands/replay.md +263 -0
  34. package/commands/resume.md +264 -0
  35. package/commands/review-pr-agents.md +5 -1
  36. package/commands/review-pr-staging.md +160 -2
  37. package/commands/review-pr.md +85 -3
  38. package/commands/rollback.md +2 -0
  39. package/commands/scope.md +280 -0
  40. package/commands/security-audit.md +2 -0
  41. package/commands/status.md +262 -0
  42. package/commands/sync-ecosystem.md +2 -0
  43. package/commands/test-gate.md +929 -0
  44. package/commands/validate.md +2 -0
  45. package/commands/work-on/build/architect.md +128 -18
  46. package/commands/work-on/build/context.md +130 -17
  47. package/commands/work-on/build/implement.md +7 -3
  48. package/commands/work-on/build/validate.md +2 -0
  49. package/commands/work-on/build.md +32 -7
  50. package/commands/work-on/close.md +85 -0
  51. package/commands/work-on/decompose.md +2 -0
  52. package/commands/work-on/investigate.md +30 -3
  53. package/commands/work-on/review.md +13 -1
  54. package/commands/work-on-monolithic.md +2 -0
  55. package/commands/work-on.md +676 -38
  56. package/docs/BRAND.md +51 -0
  57. package/docs/CI.md +177 -0
  58. package/docs/CONFIG.md +94 -0
  59. package/docs/FORGE-PROTOCOL.md +324 -0
  60. package/docs/articles/b-vs-c-baseline.md +210 -0
  61. package/docs/articles/full-abc-benchmark.md +205 -0
  62. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +174 -0
  63. package/docs/articles/selective-spec-loading-token-savings.md +140 -0
  64. package/docs/claude-breakpoints.json +47 -0
  65. package/docs/design/abc-benchmark.md +195 -0
  66. package/docs/design/design-architect-rationale.md +123 -0
  67. package/docs/design/design-memory.md +61 -0
  68. package/docs/design/design-spec-schema.md +248 -0
  69. package/docs/design/design-system-lint.md +150 -0
  70. package/docs/design/divergent-generation.md +106 -0
  71. package/docs/design/effects-appropriateness.md +133 -0
  72. package/docs/design/fixtures/briefs/README.md +47 -0
  73. package/docs/design/fixtures/briefs/cadence.md +39 -0
  74. package/docs/design/fixtures/briefs/plume.md +39 -0
  75. package/docs/design/fixtures/briefs/slipstream.md +39 -0
  76. package/docs/design/fixtures/briefs/tender.md +39 -0
  77. package/docs/design/fixtures/briefs/voltage.md +39 -0
  78. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-1.html +364 -0
  79. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-2.html +420 -0
  80. package/docs/design/fixtures/runs/b-vs-c-baseline/cadence/arm-b/run-3.html +506 -0
  81. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-1.html +611 -0
  82. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-2.html +594 -0
  83. package/docs/design/fixtures/runs/b-vs-c-baseline/plume/arm-b/run-3.html +817 -0
  84. package/docs/design/fixtures/runs/b-vs-c-baseline/runs.json +175 -0
  85. package/docs/design/fixtures/runs/b-vs-c-baseline/scorecard.json +138 -0
  86. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-1.html +373 -0
  87. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-2.html +398 -0
  88. package/docs/design/fixtures/runs/b-vs-c-baseline/slipstream/arm-b/run-3.html +595 -0
  89. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-1.html +531 -0
  90. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-2.html +565 -0
  91. package/docs/design/fixtures/runs/b-vs-c-baseline/tender/arm-b/run-3.html +674 -0
  92. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-1.html +388 -0
  93. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-2.html +463 -0
  94. package/docs/design/fixtures/runs/b-vs-c-baseline/voltage/arm-b/run-3.html +568 -0
  95. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-1.html +562 -0
  96. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-2.html +618 -0
  97. package/docs/design/fixtures/runs/full-abc/cadence/arm-a/run-3.html +618 -0
  98. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-1.html +364 -0
  99. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-2.html +420 -0
  100. package/docs/design/fixtures/runs/full-abc/cadence/arm-b/run-3.html +506 -0
  101. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-1.html +527 -0
  102. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-2.html +445 -0
  103. package/docs/design/fixtures/runs/full-abc/plume/arm-a/run-3.html +483 -0
  104. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-1.html +611 -0
  105. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-2.html +594 -0
  106. package/docs/design/fixtures/runs/full-abc/plume/arm-b/run-3.html +817 -0
  107. package/docs/design/fixtures/runs/full-abc/runs.json +190 -0
  108. package/docs/design/fixtures/runs/full-abc/scorecard.json +144 -0
  109. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-1.html +496 -0
  110. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-2.html +447 -0
  111. package/docs/design/fixtures/runs/full-abc/slipstream/arm-a/run-3.html +564 -0
  112. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-1.html +373 -0
  113. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-2.html +398 -0
  114. package/docs/design/fixtures/runs/full-abc/slipstream/arm-b/run-3.html +595 -0
  115. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-1.html +622 -0
  116. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-2.html +475 -0
  117. package/docs/design/fixtures/runs/full-abc/tender/arm-a/run-3.html +660 -0
  118. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-1.html +531 -0
  119. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-2.html +565 -0
  120. package/docs/design/fixtures/runs/full-abc/tender/arm-b/run-3.html +674 -0
  121. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-1.html +525 -0
  122. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-2.html +446 -0
  123. package/docs/design/fixtures/runs/full-abc/voltage/arm-a/run-3.html +463 -0
  124. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-1.html +388 -0
  125. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-2.html +463 -0
  126. package/docs/design/fixtures/runs/full-abc/voltage/arm-b/run-3.html +568 -0
  127. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-1.html +1068 -0
  128. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-2.html +1423 -0
  129. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-a/run-3.html +1245 -0
  130. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-1.html +305 -0
  131. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-2.html +327 -0
  132. package/docs/design/fixtures/runs/full-abc-opus/cadence/arm-b/run-3.html +428 -0
  133. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-1.html +1270 -0
  134. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-2.html +1172 -0
  135. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-a/run-3.html +1532 -0
  136. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-1.html +315 -0
  137. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-2.html +342 -0
  138. package/docs/design/fixtures/runs/full-abc-opus/plume/arm-b/run-3.html +353 -0
  139. package/docs/design/fixtures/runs/full-abc-opus/runs.json +190 -0
  140. package/docs/design/fixtures/runs/full-abc-opus/scorecard.json +144 -0
  141. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-1.html +345 -0
  142. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-2.html +396 -0
  143. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-a/run-3.html +502 -0
  144. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-1.html +408 -0
  145. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-2.html +446 -0
  146. package/docs/design/fixtures/runs/full-abc-opus/slipstream/arm-b/run-3.html +496 -0
  147. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-1.html +813 -0
  148. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-2.html +711 -0
  149. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-a/run-3.html +721 -0
  150. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-1.html +280 -0
  151. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-2.html +291 -0
  152. package/docs/design/fixtures/runs/full-abc-opus/tender/arm-b/run-3.html +366 -0
  153. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-1.html +1010 -0
  154. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-2.html +836 -0
  155. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-a/run-3.html +930 -0
  156. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-1.html +407 -0
  157. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-2.html +396 -0
  158. package/docs/design/fixtures/runs/full-abc-opus/voltage/arm-b/run-3.html +529 -0
  159. package/docs/design/foundations/brutalist.css +294 -0
  160. package/docs/design/foundations/editorial.css +300 -0
  161. package/docs/design/foundations/minimal-luxury.css +295 -0
  162. package/docs/design/foundations/technical.css +293 -0
  163. package/docs/design/foundations/warm-photo.css +299 -0
  164. package/docs/design/reference-corpus.md +1726 -0
  165. package/docs/design/render-critique-loop.md +57 -0
  166. package/docs/site/getting-started.md +21 -0
  167. package/docs/spec-graph-schema.md +397 -0
  168. package/package.json +3 -3
  169. package/scripts/bench-scorecard.mjs +239 -0
  170. package/scripts/build-spec-graph.mjs +509 -0
  171. package/scripts/classify-lane.sh +138 -0
  172. package/scripts/design-system-lint.mjs +993 -0
  173. package/scripts/graph-query.sh +486 -0
  174. package/scripts/transition-label.sh +169 -0
  175. package/scripts/validate-pr-target.sh +30 -0
  176. package/scripts/validate-spec-graph.sh +423 -0
  177. package/templates/devdocs/README.md +72 -0
  178. package/templates/devdocs/agent/using-forgedock.md +3 -0
  179. package/templates/devdocs/agent/using-github.md +3 -0
  180. package/templates/devdocs/index.yaml +162 -0
  181. package/templates/devdocs/project/architecture.md +3 -0
  182. package/templates/devdocs/project/conventions.md +3 -0
  183. package/templates/devdocs/project/custom-instructions.md +3 -0
  184. package/templates/devdocs/project/glossary.md +3 -0
  185. package/templates/devdocs/project/stack.md +3 -0
  186. package/templates/devdocs/templates/commit-convention.md +3 -0
  187. package/templates/devdocs/templates/issue.md +3 -0
  188. package/templates/devdocs/templates/pull-request.md +3 -0
  189. package/templates/devdocs/templates/review-finding.md +3 -0
  190. package/templates/workflows/forgedock-review.yml +96 -0
package/bin/forgedock.mjs CHANGED
@@ -1,18 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
4
 
3
- import { fileURLToPath } from "url";
4
- import { dirname, join, relative } from "path";
5
- import { mkdir, symlink, readlink, lstat, readdir } from "fs/promises";
5
+ import { fileURLToPath, pathToFileURL } from "url";
6
+ import { dirname, join, relative, resolve } from "path";
7
+ import { mkdir, symlink, readlink, lstat, readdir, stat, unlink } from "fs/promises";
6
8
  import {
7
9
  existsSync,
8
10
  appendFileSync,
9
11
  readFileSync,
10
12
  writeFileSync,
11
13
  renameSync,
12
- copyFileSync,
14
+ unlinkSync,
13
15
  } from "fs";
14
- import { execSync } from "child_process";
15
- import readline from "readline";
16
+ import { execSync, execFileSync } from "child_process";
17
+ import { homedir } from "os";
16
18
  import {
17
19
  renderLogo,
18
20
  runSteps,
@@ -37,20 +39,461 @@ const __dirname = dirname(__filename);
37
39
  const FORGE_HOME = dirname(__dirname);
38
40
  const COMMANDS_DIR = join(FORGE_HOME, "commands");
39
41
 
40
- if (!process.env.HOME) {
41
- process.stderr.write(
42
- "Error: HOME environment variable is not set. Cannot determine install location.\n",
43
- );
44
- process.exit(1);
45
- }
42
+ // Resolve home directory cross-platform: HOME on Unix, USERPROFILE on Windows.
43
+ // os.homedir() handles all supported platforms without a hard exit — see #744.
44
+ const HOME = homedir();
45
+
46
+ const TARGET_DIR = join(HOME, ".claude", "commands");
47
+ const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
48
+ const SCRIPTS_TARGET_DIR = join(HOME, ".claude", "scripts");
46
49
 
47
- const TARGET_DIR = join(process.env.HOME, ".claude", "commands");
50
+ /**
51
+ * Allowlist of pipeline-agent scripts that get installed to ~/.claude/scripts/.
52
+ * Only these files are symlinked (or copied on Windows) during install/update.
53
+ *
54
+ * Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
55
+ * installed — those scripts are invoked directly via $FORGE_HOME/scripts/ by
56
+ * review-pr.md and quality-gate.md and should not pollute the user's Claude
57
+ * scripts namespace.
58
+ *
59
+ * When adding a new pipeline-agent script, add its filename here.
60
+ */
61
+ const PIPELINE_SCRIPTS = new Set([
62
+ "classify-lane.sh",
63
+ "transition-label.sh",
64
+ "validate-pr-target.sh",
65
+ ]);
48
66
 
49
67
  const args = process.argv.slice(2);
50
68
  const command = args[0] || "install";
51
69
 
52
70
  // ---------------------------------------------------------------------------
53
- // Splash — the ForgeDock F-monogram logo shown at startup
71
+ // SessionStart hook — settings.json helpers
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Path to the user-level Claude Code settings file.
76
+ * This is where the SessionStart hook entry is written/removed.
77
+ */
78
+ const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
79
+
80
+ /**
81
+ * Timeout (in seconds) for the SessionStart hook entry written into
82
+ * ~/.claude/settings.json. Claude Code hook timeouts are in seconds —
83
+ * not milliseconds like the Node.js child_process timeouts elsewhere in
84
+ * this file.
85
+ */
86
+ const SESSION_START_HOOK_TIMEOUT_SECONDS = 10;
87
+
88
+ /**
89
+ * Escape all shell metacharacters that receive special treatment inside
90
+ * POSIX double-quoted strings. Apply this to any filesystem path before
91
+ * embedding it in a double-quoted shell assignment or command argument.
92
+ *
93
+ * Characters escaped and why:
94
+ * " → \" (closes the double-quoted context — ref: forge#451)
95
+ * $ → \$ (variable expansion and $(...) command substitution — ref: forge#792)
96
+ * ` → \` (legacy backtick command substitution — ref: forge#792)
97
+ * ! → \! (bash history expansion — harmless in sh but risky in bash — ref: forge#792)
98
+ *
99
+ * Backslash (\) is intentionally NOT escaped here: path.join() backslashes
100
+ * must be converted to forward slashes by the caller before this function
101
+ * is applied (see sessionStartHookCommand below). On POSIX systems,
102
+ * paths never contain backslashes, so this is a no-op for shell profiles.
103
+ *
104
+ * <!-- Added: forge#808 -->
105
+ *
106
+ * @param {string} path - Filesystem path to escape for shell double-quote context.
107
+ * @returns {string} - Path safe for embedding inside a double-quoted shell string.
108
+ */
109
+ function shellEscapeDoubleQuotedPath(path) {
110
+ return path
111
+ .replace(/\\/g, '\\\\') // backslash — must be first to avoid double-escaping
112
+ .replace(/"/g, '\\"') // double quote — breaks out of the argument
113
+ .replace(/\$/g, '\\$') // dollar sign — variable/command substitution
114
+ .replace(/`/g, '\\`') // backtick — legacy command substitution
115
+ .replace(/!/g, '\\!'); // exclamation mark — bash history expansion
116
+ }
117
+
118
+ /**
119
+ * The command value written into the SessionStart hook entry.
120
+ * Uses forward slashes even on Windows — Node accepts them natively and
121
+ * they avoid any backslash-related issues in the hook command string.
122
+ * Backslashes emitted by path.join() on Windows are normalised to forward
123
+ * slashes on line 101 before any escaping is applied.
124
+ *
125
+ * When Claude Code's hook runner passes the command string through a POSIX
126
+ * shell (sh -c), the following characters receive special treatment inside
127
+ * double-quoted strings and must be escaped — see shellEscapeDoubleQuotedPath().
128
+ *
129
+ * `node --` is used to terminate Node.js option parsing so that a path
130
+ * component beginning with `-` cannot be misinterpreted as a CLI flag.
131
+ *
132
+ * @returns {string}
133
+ */
134
+ function sessionStartHookCommand() {
135
+ const hookPath = join(
136
+ FORGE_HOME,
137
+ "bin",
138
+ "hooks",
139
+ "session-start.mjs",
140
+ ).replace(/\\/g, "/");
141
+ const safePath = shellEscapeDoubleQuotedPath(hookPath);
142
+ return `node -- "${safePath}"`;
143
+ }
144
+
145
+ /**
146
+ * Check whether a hook command string refers to the ForgeDock SessionStart hook.
147
+ *
148
+ * Uses a platform-safe RegExp that matches both POSIX forward-slash paths
149
+ * (Linux/macOS) and Windows backslash paths, so uninstall can locate and
150
+ * remove the hook even when FORGE_HOME was different at install time.
151
+ *
152
+ * @param {string} command - The command string from a hook entry.
153
+ * @returns {boolean}
154
+ */
155
+ function isForgeSessionStartHook(command) {
156
+ // Match: ...bin[/\]hooks[/\]session-start.mjs (quote-terminated or EOL)
157
+ return /[/\\]bin[/\\]hooks[/\\]session-start\.mjs["']?\s*$/.test(command);
158
+ }
159
+
160
+ /**
161
+ * Strip JSONC syntax (single-line comments, block comments, trailing commas)
162
+ * from a raw JSON string, returning a plain JSON string that JSON.parse() accepts.
163
+ *
164
+ * Handles settings.json files that Claude Code's own editor may annotate
165
+ * with comments and trailing commas.
166
+ *
167
+ * @param {string} raw - Raw text of a JSONC file.
168
+ * @returns {string} - Valid JSON string.
169
+ */
170
+ function stripJsonc(raw) {
171
+ let result = "";
172
+ let i = 0;
173
+ const len = raw.length;
174
+
175
+ while (i < len) {
176
+ const ch = raw[i];
177
+
178
+ // Inside a string literal — copy until unescaped closing quote
179
+ if (ch === '"') {
180
+ result += ch;
181
+ i++;
182
+ while (i < len) {
183
+ const sc = raw[i];
184
+ result += sc;
185
+ if (sc === "\\" && i + 1 < len) {
186
+ i++;
187
+ result += raw[i];
188
+ } else if (sc === '"') {
189
+ break;
190
+ }
191
+ i++;
192
+ }
193
+ i++;
194
+ continue;
195
+ }
196
+
197
+ // Single-line comment — skip until newline
198
+ if (ch === "/" && i + 1 < len && raw[i + 1] === "/") {
199
+ while (i < len && raw[i] !== "\n") i++;
200
+ continue;
201
+ }
202
+
203
+ // Block comment — skip until */
204
+ if (ch === "/" && i + 1 < len && raw[i + 1] === "*") {
205
+ i += 2;
206
+ while (i + 1 < len && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
207
+ if (i + 1 < len) i += 2; // only advance past */ if terminator was found
208
+ continue;
209
+ }
210
+
211
+ // Trailing comma — skip if next structural char (past whitespace and
212
+ // comments) is } or ].
213
+ // This must run OUTSIDE the string-literal branch above so string content
214
+ // containing ",}" or ",]" is never touched.
215
+ // The lookahead skips whitespace AND inline comments so that patterns like
216
+ // `value, /* note */ }` or `value, // note\n}` are handled correctly.
217
+ if (ch === ",") {
218
+ let j = i + 1;
219
+ // Advance j past whitespace and comments
220
+ let advanced = true;
221
+ while (advanced && j < len) {
222
+ advanced = false;
223
+ // Skip whitespace
224
+ while (
225
+ j < len &&
226
+ (raw[j] === " " ||
227
+ raw[j] === "\t" ||
228
+ raw[j] === "\r" ||
229
+ raw[j] === "\n")
230
+ ) {
231
+ j++;
232
+ advanced = true;
233
+ }
234
+ // Skip single-line comment
235
+ if (j + 1 < len && raw[j] === "/" && raw[j + 1] === "/") {
236
+ while (j < len && raw[j] !== "\n") j++;
237
+ advanced = true;
238
+ }
239
+ // Skip block comment
240
+ if (j + 1 < len && raw[j] === "/" && raw[j + 1] === "*") {
241
+ j += 2;
242
+ while (j + 1 < len && !(raw[j] === "*" && raw[j + 1] === "/")) j++;
243
+ if (j + 1 < len) j += 2; // only advance past */ if terminator was found
244
+ advanced = true;
245
+ }
246
+ }
247
+ if (j < len && (raw[j] === "}" || raw[j] === "]")) {
248
+ i++;
249
+ continue; // skip trailing comma
250
+ }
251
+ }
252
+
253
+ result += ch;
254
+ i++;
255
+ }
256
+
257
+ return result;
258
+ }
259
+
260
+ /**
261
+ * Read ~/.claude/settings.json, returning a parsed object.
262
+ * Returns an empty object if the file does not exist.
263
+ * Tolerates JSONC syntax (comments, trailing commas) that Claude Code's
264
+ * own settings editor may insert.
265
+ * Throws if the file exists but cannot be parsed even after JSONC stripping.
266
+ *
267
+ * @returns {object}
268
+ */
269
+ function readClaudeSettings() {
270
+ try {
271
+ const raw = readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
272
+ return JSON.parse(stripJsonc(raw));
273
+ } catch (err) {
274
+ if (err.code === "ENOENT") return {};
275
+ throw err;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Atomically write an object to ~/.claude/settings.json.
281
+ * Uses a .tmp sibling + renameSync to avoid partial writes.
282
+ * Cleans up the tmp file on failure. (ref: forge#444)
283
+ *
284
+ * @param {object} settings
285
+ */
286
+ function writeClaudeSettings(settings) {
287
+ const tmpPath = CLAUDE_SETTINGS_PATH + ".forgedock.tmp";
288
+ try {
289
+ writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
290
+ renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
291
+ } catch (err) {
292
+ try {
293
+ unlinkSync(tmpPath);
294
+ } catch {
295
+ /* already gone or never created */
296
+ }
297
+ throw err;
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Atomically write text content to a file.
303
+ * Uses a .forgedock.tmp sibling + renameSync to avoid partial writes on
304
+ * crash or SIGINT. Cleans up the tmp file on failure. (ref: forge#813)
305
+ *
306
+ * @param {string} filePath - Absolute path to write
307
+ * @param {string} content - UTF-8 string content
308
+ */
309
+ function atomicWriteFile(filePath, content) {
310
+ const tmpPath = filePath + ".forgedock.tmp";
311
+ try {
312
+ writeFileSync(tmpPath, content, "utf-8");
313
+ renameSync(tmpPath, filePath);
314
+ } catch (err) {
315
+ try {
316
+ unlinkSync(tmpPath);
317
+ } catch {
318
+ /* already gone or never created */
319
+ }
320
+ throw err;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Idempotently write a `.symlink-source` sentinel file to TARGET_DIR
326
+ * (~/.claude/commands/) so other installers can detect that ForgeDock
327
+ * owns this namespace.
328
+ *
329
+ * The sentinel is a plain UTF-8 text file (not a symlink) containing
330
+ * the source path, a reinstall hint, and the install timestamp. It is
331
+ * updated on every install so the timestamp stays fresh. Write failures
332
+ * are non-fatal — the function returns 'failed' and the caller logs a
333
+ * warning; install continues regardless.
334
+ *
335
+ * Calling convention matches the other state-write helpers:
336
+ * 'written' — sentinel was newly created or refreshed
337
+ * 'failed' — an error occurred (non-fatal; install continues)
338
+ *
339
+ * <!-- Added: forge#1038 -->
340
+ *
341
+ * @returns {'written' | 'failed'}
342
+ */
343
+ function writeSymlinkSentinel() {
344
+ const sentinelPath = join(TARGET_DIR, ".symlink-source");
345
+ const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
346
+ const content =
347
+ `# ForgeDock command symlinks — DO NOT REPOINT\n` +
348
+ `# Source: ${COMMANDS_DIR}\n` +
349
+ `#\n` +
350
+ `# These symlinks are managed by ForgeDock (https://forgedock.com).\n` +
351
+ `# ForgeDock owns the global ~/.claude/commands/ namespace.\n` +
352
+ `# Project-specific commands should install to the project's\n` +
353
+ `# .claude/commands/ directory instead (Claude Code merges both;\n` +
354
+ `# project-local commands win on name collisions).\n` +
355
+ `#\n` +
356
+ `# Running another installer here will silently repoint these\n` +
357
+ `# symlinks, breaking all ForgeDock commands globally.\n` +
358
+ `#\n` +
359
+ `# To reinstall ForgeDock: npx forgedock install\n` +
360
+ `# Last installed: ${timestamp}\n`;
361
+ try {
362
+ atomicWriteFile(sentinelPath, content);
363
+ return "written";
364
+ } catch {
365
+ return "failed";
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Install the ForgeDock SessionStart hook into ~/.claude/settings.json
371
+ * idempotently. Does not modify any existing hooks unrelated to ForgeDock.
372
+ *
373
+ * Returns:
374
+ * 'installed' — hook was newly added
375
+ * 'already-present' — hook was already there (idempotent)
376
+ * 'failed' — an error occurred (non-fatal; install continues)
377
+ *
378
+ * @returns {Promise<'installed' | 'already-present' | 'failed'>}
379
+ */
380
+ async function installSessionStartHook() {
381
+ try {
382
+ const settings = readClaudeSettings();
383
+
384
+ // Ensure the hooks section exists
385
+ if (!settings.hooks || typeof settings.hooks !== "object") {
386
+ settings.hooks = {};
387
+ }
388
+
389
+ // Ensure SessionStart array exists
390
+ if (!Array.isArray(settings.hooks.SessionStart)) {
391
+ settings.hooks.SessionStart = [];
392
+ }
393
+
394
+ const cmd = sessionStartHookCommand();
395
+
396
+ // Check if already present — match by path suffix so uninstall/reinstall
397
+ // across different FORGE_HOME paths still works correctly. (ref: forge#537)
398
+ const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
399
+ if (!entry || typeof entry !== "object") return false;
400
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
401
+ return hooks.some(
402
+ (h) =>
403
+ h &&
404
+ typeof h.command === "string" &&
405
+ isForgeSessionStartHook(h.command),
406
+ );
407
+ });
408
+
409
+ if (alreadyPresent) return "already-present";
410
+
411
+ // Append the new hook entry (matcher omitted — fires on every session start)
412
+ settings.hooks.SessionStart.push({
413
+ hooks: [
414
+ {
415
+ type: "command",
416
+ command: cmd,
417
+ timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
418
+ },
419
+ ],
420
+ });
421
+
422
+ writeClaudeSettings(settings);
423
+ return "installed";
424
+ } catch {
425
+ return "failed";
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
431
+ * idempotently. Only removes the entry written by installSessionStartHook().
432
+ * Unrelated hooks are preserved.
433
+ *
434
+ * Returns:
435
+ * 'removed' — hook was found and removed
436
+ * 'not-present' — hook was not in settings.json (already clean)
437
+ * 'failed' — an error occurred (non-fatal; uninstall continues)
438
+ *
439
+ * @returns {Promise<'removed' | 'not-present' | 'failed'>}
440
+ */
441
+ async function removeSessionStartHook() {
442
+ try {
443
+ const settings = readClaudeSettings();
444
+
445
+ if (
446
+ !settings.hooks ||
447
+ !Array.isArray(settings.hooks.SessionStart) ||
448
+ settings.hooks.SessionStart.length === 0
449
+ ) {
450
+ return "not-present";
451
+ }
452
+
453
+ const originalLength = settings.hooks.SessionStart.length;
454
+
455
+ // Filter out the ForgeDock-managed entry, keep everything else
456
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
457
+ (entry) => {
458
+ if (!entry || typeof entry !== "object") return true;
459
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
460
+ const isForgeEntry = hooks.some(
461
+ (h) =>
462
+ h &&
463
+ typeof h.command === "string" &&
464
+ isForgeSessionStartHook(h.command),
465
+ );
466
+ return !isForgeEntry;
467
+ },
468
+ );
469
+
470
+ if (settings.hooks.SessionStart.length === originalLength) {
471
+ return "not-present";
472
+ }
473
+
474
+ // Clean up empty SessionStart array to leave settings.json tidy
475
+ if (settings.hooks.SessionStart.length === 0) {
476
+ delete settings.hooks.SessionStart;
477
+ }
478
+
479
+ // Clean up empty hooks object
480
+ if (
481
+ settings.hooks &&
482
+ typeof settings.hooks === "object" &&
483
+ Object.keys(settings.hooks).length === 0
484
+ ) {
485
+ delete settings.hooks;
486
+ }
487
+
488
+ writeClaudeSettings(settings);
489
+ return "removed";
490
+ } catch {
491
+ return "failed";
492
+ }
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Version + splash — read package.json version and render branded logo
54
497
  // ---------------------------------------------------------------------------
55
498
 
56
499
  function getVersion() {
@@ -69,38 +512,42 @@ function splash() {
69
512
  process.stderr.write(renderLogo({ version }) + "\n");
70
513
  }
71
514
 
515
+ // ---------------------------------------------------------------------------
516
+ // CLAUDE.md / AGENTS.md managed-block injection
517
+ // ---------------------------------------------------------------------------
518
+
72
519
  /**
73
- * Prompt the user for a yes/no confirmation.
74
- * Returns false (safe default) when stdin is not a TTY (e.g. CI or piped input).
520
+ * Marker strings that delimit the ForgeDock-managed block in CLAUDE.md / AGENTS.md.
521
+ * Must be exact — used for both detection and regex anchoring.
75
522
  */
76
- async function confirmOverwrite(question) {
77
- if (!process.stdin.isTTY) {
78
- process.stderr.write(
79
- ` ${YELLOW}Non-interactive environment detected — aborting to protect existing config.${RESET}\n`,
80
- );
81
- return false;
82
- }
83
- return new Promise((resolve) => {
84
- const rl = readline.createInterface({
85
- input: process.stdin,
86
- output: process.stderr,
87
- });
88
- rl.question(question, (answer) => {
89
- rl.close();
90
- resolve(
91
- answer.trim().toLowerCase() === "y" ||
92
- answer.trim().toLowerCase() === "yes",
93
- );
94
- });
95
- });
96
- }
523
+ const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
524
+ const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
525
+
526
+ /**
527
+ * The behavioral rules block injected into the project's CLAUDE.md.
528
+ * Kept ≤30 lines (per issue #607 requirement).
529
+ * Content is fixed — this is NOT documentation, it is behavioral guidance.
530
+ */
531
+ const FORGEDOCK_MANAGED_BLOCK = `${CLAUDE_BLOCK_BEGIN}
532
+ ## ForgeDock Pipeline Rules
533
+
534
+ This project uses ForgeDock for structured development. Follow these rules:
535
+
536
+ 1. **Issue-first**: When the user describes a bug, feature, or task conversationally — create a GitHub issue with \`/issue\` before writing any code. Never inline-fix without an issue number.
537
+ 2. **Pipeline flow**: All implementation goes through \`/work-on <issue#>\`. This runs: investigate → architect → build → review → merge.
538
+ 3. **Traceability**: Every code change must link to a GitHub issue. Use FORGE annotations to pass context between pipeline phases.
539
+ 4. **Findings become issues**: When you discover bugs, inconsistencies, or improvements during other work — create a GitHub issue (\`gh issue create\`), don't fix inline.
540
+ 5. **Available commands**: \`/work-on\`, \`/issue\`, \`/review-pr\`, \`/orchestrate\`, \`/quality-gate\`, \`/milestone\`, \`/autopilot\`
541
+
542
+ For full documentation, see: [ForgeDock docs](https://forgedock.com/docs)
543
+ ${CLAUDE_BLOCK_END}`;
97
544
 
98
545
  /**
99
- * Returns true if `dir` is inside a git work tree. Used to avoid generating
100
- * forge.yaml in non-project directories — e.g. when npx defaults cwd to a
101
- * system folder (C:\Windows) because the launching shell sat on an
102
- * unsupported UNC/WSL path. The command is a fixed string with no
103
- * interpolated input, so there is no shell-injection surface (ref #151).
546
+ * Returns true if `dir` is inside a git work tree.
547
+ * Used to guard CLAUDE.md injection against non-project directories.
548
+ *
549
+ * @param {string} dir
550
+ * @returns {boolean}
104
551
  */
105
552
  function isGitWorkTree(dir) {
106
553
  try {
@@ -115,6 +562,187 @@ function isGitWorkTree(dir) {
115
562
  }
116
563
  }
117
564
 
565
+ /**
566
+ * Idempotently inject the ForgeDock managed block into a single file.
567
+ *
568
+ * Handles all corrupt marker states (ref: forge#269, forge#291):
569
+ * - File missing → create file containing block only
570
+ * - No markers → append block to existing content
571
+ * - BEGIN + END (correct) → replace content between markers
572
+ * - BEGIN only → strip orphaned BEGIN, re-append full block
573
+ * - END only → strip orphaned END, re-append full block
574
+ * - END before BEGIN → strip both + content between, re-append full block
575
+ * - Block already current → return 'unchanged'
576
+ *
577
+ * Returns:
578
+ * 'created' — file was created with block
579
+ * 'updated' — existing block was replaced with new content
580
+ * 'unchanged' — block was already present and current
581
+ * 'appended' — block was appended (no prior markers)
582
+ *
583
+ * @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
584
+ * @returns {'created' | 'updated' | 'unchanged' | 'appended'}
585
+ */
586
+ function injectManagedBlock(filePath) {
587
+ const managed = FORGEDOCK_MANAGED_BLOCK;
588
+
589
+ // File does not exist — create it with the block
590
+ if (!existsSync(filePath)) {
591
+ atomicWriteFile(filePath, managed + "\n");
592
+ return "created";
593
+ }
594
+
595
+ let existing = readFileSync(filePath, "utf-8");
596
+
597
+ const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
598
+ const hasEnd = existing.includes(CLAUDE_BLOCK_END);
599
+
600
+ if (hasBegin && hasEnd) {
601
+ // Determine marker order
602
+ const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
603
+ const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
604
+
605
+ if (beginIdx < endIdx) {
606
+ // Normal order — replace content between markers (inclusive)
607
+ // Non-greedy match so we don't over-consume if someone manually
608
+ // duplicated the markers.
609
+ const blockRegex = new RegExp(
610
+ escapeRegExp(CLAUDE_BLOCK_BEGIN) +
611
+ "[\\s\\S]*?" +
612
+ escapeRegExp(CLAUDE_BLOCK_END),
613
+ );
614
+ if (!blockRegex.test(existing)) {
615
+ // Should not happen: both markers were confirmed above via includes() and
616
+ // indexOf() ordering. Guard against silent misclassification — if the regex
617
+ // somehow fails to match, replaced === existing would incorrectly return
618
+ // 'unchanged' instead of surfacing the broken state.
619
+ throw new Error(
620
+ `injectManagedBlock: regex failed to match markers in ${filePath}`,
621
+ );
622
+ }
623
+ const replaced = existing.replace(blockRegex, managed);
624
+ if (replaced === existing) {
625
+ // Content between markers was already identical to the managed block
626
+ return "unchanged";
627
+ }
628
+ atomicWriteFile(filePath, replaced);
629
+ return "updated";
630
+ } else {
631
+ // Reversed markers (END before BEGIN) — strip both markers + everything
632
+ // between them, then fall through to append. (ref: forge#291 BUG-2)
633
+ const strippedReversed = existing
634
+ .replace(
635
+ new RegExp(
636
+ escapeRegExp(CLAUDE_BLOCK_END) +
637
+ "[\\s\\S]*?" +
638
+ escapeRegExp(CLAUDE_BLOCK_BEGIN),
639
+ ),
640
+ "",
641
+ )
642
+ // Strip any leftover isolated markers
643
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
644
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "");
645
+ existing = strippedReversed.trimEnd();
646
+ // Fall through to append
647
+ }
648
+ } else if (hasBegin) {
649
+ // Orphaned BEGIN — strip it, then fall through to append. (ref: forge#269)
650
+ existing = existing
651
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
652
+ .trimEnd();
653
+ // Fall through to append
654
+ } else if (hasEnd) {
655
+ // Orphaned END — strip it, then fall through to append. (ref: forge#291 BUG-1)
656
+ existing = existing
657
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
658
+ .trimEnd();
659
+ // Fall through to append
660
+ }
661
+
662
+ // No valid block present — append
663
+ const separator = existing.length > 0 ? "\n\n" : "";
664
+ atomicWriteFile(filePath, existing + separator + managed + "\n");
665
+ return hasBegin || hasEnd ? "updated" : "appended";
666
+ }
667
+
668
+ /**
669
+ * Remove the ForgeDock managed block from a single file.
670
+ * Strips only the marker-bounded section; all surrounding content is preserved.
671
+ * No-ops if the file doesn't exist or contains no markers.
672
+ *
673
+ * Returns:
674
+ * 'removed' — block was found and removed
675
+ * 'not-present' — no block in file (or file doesn't exist)
676
+ *
677
+ * @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
678
+ * @returns {'removed' | 'not-present'}
679
+ */
680
+ function removeManagedBlock(filePath) {
681
+ if (!existsSync(filePath)) return "not-present";
682
+
683
+ const existing = readFileSync(filePath, "utf-8");
684
+ const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
685
+ const hasEnd = existing.includes(CLAUDE_BLOCK_END);
686
+
687
+ if (!hasBegin && !hasEnd) return "not-present";
688
+
689
+ let cleaned = existing;
690
+
691
+ if (hasBegin && hasEnd) {
692
+ const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
693
+ const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
694
+
695
+ if (beginIdx < endIdx) {
696
+ // Normal block — remove it
697
+ cleaned = existing
698
+ .replace(
699
+ new RegExp(
700
+ escapeRegExp(CLAUDE_BLOCK_BEGIN) +
701
+ "[\\s\\S]*?" +
702
+ escapeRegExp(CLAUDE_BLOCK_END),
703
+ ),
704
+ "",
705
+ )
706
+ .trimEnd();
707
+ } else {
708
+ // Reversed — strip both and content between
709
+ cleaned = existing
710
+ .replace(
711
+ new RegExp(
712
+ escapeRegExp(CLAUDE_BLOCK_END) +
713
+ "[\\s\\S]*?" +
714
+ escapeRegExp(CLAUDE_BLOCK_BEGIN),
715
+ ),
716
+ "",
717
+ )
718
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
719
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
720
+ .trimEnd();
721
+ }
722
+ } else {
723
+ // Orphaned markers — strip them
724
+ cleaned = cleaned
725
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
726
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
727
+ .trimEnd();
728
+ }
729
+
730
+ if (cleaned === existing.trimEnd()) return "not-present";
731
+
732
+ atomicWriteFile(filePath, cleaned.length > 0 ? cleaned + "\n" : "");
733
+ return "removed";
734
+ }
735
+
736
+ /**
737
+ * Escape a string for use inside a RegExp constructor.
738
+ *
739
+ * @param {string} str
740
+ * @returns {string}
741
+ */
742
+ function escapeRegExp(str) {
743
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
744
+ }
745
+
118
746
  async function findMarkdownFiles(dir) {
119
747
  const results = [];
120
748
  const entries = await readdir(dir, { withFileTypes: true });
@@ -130,13 +758,101 @@ async function findMarkdownFiles(dir) {
130
758
  }
131
759
 
132
760
  // ---------------------------------------------------------------------------
133
- // Shared file-linking logic — used by both install() and update()
761
+ // Shell profile FORGE_HOME removal
134
762
  // ---------------------------------------------------------------------------
135
763
 
136
764
  /**
137
- * Link or copy all commands from COMMANDS_DIR into TARGET_DIR.
138
- * Calls step.progress(idx+1, total) on each file.
139
- * Returns { installed, updated, skipped }.
765
+ * The sentinel comment line written by install() immediately before the
766
+ * `export FORGE_HOME=` line. This is the reliable anchor for removal —
767
+ * it is never present in organic shell profiles.
768
+ */
769
+ const FORGE_HOME_COMMENT = "# ForgeDock — autonomous development pipeline";
770
+
771
+ /**
772
+ * Remove the ForgeDock FORGE_HOME block from a single shell profile file.
773
+ *
774
+ * install() appends exactly this 2-line block (preceded by a blank line):
775
+ *
776
+ * # ForgeDock — autonomous development pipeline
777
+ * export FORGE_HOME="<path>"
778
+ *
779
+ * This function strips that block (and any orphaned `export FORGE_HOME=...`
780
+ * lines immediately following the comment, or any standalone
781
+ * `export FORGE_HOME=...` lines that have no paired comment). All other
782
+ * content is preserved.
783
+ *
784
+ * Uses an atomic write (tmp file + rename) to avoid partial writes.
785
+ *
786
+ * Returns:
787
+ * 'removed' — block was found and removed
788
+ * 'not-present' — block not found in file (or file does not exist)
789
+ * 'failed' — write error (non-fatal; callers warn and continue)
790
+ *
791
+ * @param {string} profilePath - Absolute path to .bashrc or .zshrc
792
+ * @returns {'removed' | 'not-present' | 'failed'}
793
+ */
794
+ function removeForgeHomeFromProfile(profilePath) {
795
+ if (!existsSync(profilePath)) return "not-present";
796
+
797
+ let content;
798
+ try {
799
+ content = readFileSync(profilePath, "utf-8");
800
+ } catch {
801
+ return "failed";
802
+ }
803
+
804
+ if (!content.includes(FORGE_HOME_COMMENT) && !content.includes("export FORGE_HOME=")) {
805
+ return "not-present";
806
+ }
807
+
808
+ // Step 1: Remove the 2-line ForgeDock block (comment + export), preceded
809
+ // by an optional leading blank line that install() inserts.
810
+ // The \r? handles profiles with CRLF line endings.
811
+ // The value pattern (?:[^"\\]|\\.)* matches a double-quoted shell string
812
+ // that may contain backslash-escape sequences (e.g. \" or \\) written by
813
+ // shellEscapeDoubleQuotedPath(). The simpler [^"]* would stop prematurely
814
+ // at the escaped-quote character inside \", failing to match the full line.
815
+ let cleaned = content.replace(
816
+ /\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="(?:[^"\\]|\\.)*"\r?\n/g,
817
+ "\n",
818
+ );
819
+
820
+ // Step 2: Remove any orphaned `export FORGE_HOME=...` lines that may
821
+ // remain if the comment line was already manually deleted.
822
+ cleaned = cleaned.replace(/^export FORGE_HOME=.*\r?\n/gm, "");
823
+
824
+ // Step 3: Trim trailing blank lines introduced by the removal, but
825
+ // preserve a single trailing newline if the original file had one.
826
+ const hadTrailingNewline = content.endsWith("\n");
827
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trimEnd();
828
+ if (cleaned.length > 0 && hadTrailingNewline) {
829
+ cleaned += "\n";
830
+ }
831
+
832
+ if (cleaned === content) return "not-present";
833
+
834
+ const tmpPath = profilePath + ".forgedock.tmp";
835
+ try {
836
+ writeFileSync(tmpPath, cleaned, "utf-8");
837
+ renameSync(tmpPath, profilePath);
838
+ } catch {
839
+ try {
840
+ unlinkSync(tmpPath);
841
+ } catch {
842
+ /* already gone or never created */
843
+ }
844
+ return "failed";
845
+ }
846
+
847
+ return "removed";
848
+ }
849
+
850
+ /**
851
+ * Link (symlink) all commands from COMMANDS_DIR to TARGET_DIR.
852
+ * Called inside a runSteps() step — uses step.progress() and step.note().
853
+ *
854
+ * @param {object} step - StepAPI from runSteps
855
+ * @returns {Promise<{installed: number, updated: number, skipped: number}>}
140
856
  */
141
857
  async function linkCommands(step) {
142
858
  const files = await findMarkdownFiles(COMMANDS_DIR);
@@ -145,13 +861,14 @@ async function linkCommands(step) {
145
861
  let updated = 0;
146
862
  let skipped = 0;
147
863
 
148
- for (let idx = 0; idx < files.length; idx++) {
149
- const file = files[idx];
864
+ for (let i = 0; i < files.length; i++) {
865
+ const file = files[i];
150
866
  const rel = relative(COMMANDS_DIR, file);
151
867
  const target = join(TARGET_DIR, rel);
152
868
  const targetDir = dirname(target);
153
869
 
154
870
  await mkdir(targetDir, { recursive: true });
871
+ step.progress(i + 1, total);
155
872
 
156
873
  try {
157
874
  const stats = await lstat(target);
@@ -161,304 +878,205 @@ async function linkCommands(step) {
161
878
  if (current === file) {
162
879
  skipped++;
163
880
  } else {
164
- await symlink(file, target + ".tmp");
165
- const { rename } = await import("fs/promises");
166
- await rename(target + ".tmp", target);
167
- updated++;
881
+ // Symlink points to a different source — warn if it appears to belong
882
+ // to another Forge/ForgeDock installation (i.e. not a broken link that
883
+ // already pointed here under a different absolute path).
884
+ // Only emit the warning once per install to avoid log spam.
885
+ // <!-- Added: forge#1038 -->
886
+ if (!current.startsWith(COMMANDS_DIR)) {
887
+ step.note(
888
+ yellow(`collision: ${rel} was → ${current} — repointing to ForgeDock`),
889
+ );
890
+ }
891
+ try {
892
+ await symlink(file, target + ".tmp");
893
+ const { rename } = await import("fs/promises");
894
+ await rename(target + ".tmp", target);
895
+ updated++;
896
+ } catch (renameErr) {
897
+ try {
898
+ await unlink(target + ".tmp");
899
+ } catch {
900
+ /* .tmp already gone or was never created */
901
+ }
902
+ if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
903
+ // Windows without Developer Mode: symlink() throws EPERM/EACCES.
904
+ // Fall back to a direct copy (matches new-install fallback behaviour).
905
+ const { copyFile } = await import("fs/promises");
906
+ await copyFile(file, target);
907
+ updated++;
908
+ } else {
909
+ throw renameErr;
910
+ }
911
+ }
168
912
  }
169
913
  } else {
170
- // Existing regular file — a ForgeDock-shipped command copied by a prior
171
- // install (common on Windows, where symlinks aren't permitted). It is
172
- // ForgeDock-managed (we only iterate shipped command names), so refresh
173
- // it: skip if already current, otherwise overwrite with a fresh copy.
174
- if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
175
- skipped++;
176
- } else {
177
- copyFileSync(file, target);
178
- updated++;
179
- }
914
+ // Regular file — skip; user must remove manually
915
+ skipped++;
180
916
  }
181
917
  } catch (err) {
182
- if (err.code !== "ENOENT") {
183
- throw err;
184
- }
185
- // Doesn't exist — create it. Prefer a symlink; fall back to a copy on
186
- // systems where symlink creation is not permitted (e.g. Windows without
187
- // Developer Mode / admin), which would otherwise throw EPERM. See #587.
918
+ if (err.code !== "ENOENT") throw err;
919
+ // Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
188
920
  try {
189
921
  await symlink(file, target);
190
922
  } catch (linkErr) {
191
923
  if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
192
- copyFileSync(file, target);
924
+ const { copyFile } = await import("fs/promises");
925
+ await copyFile(file, target);
193
926
  } else {
194
927
  throw linkErr;
195
928
  }
196
929
  }
197
930
  installed++;
198
931
  }
199
-
200
- step.progress(idx + 1, total);
201
932
  }
202
933
 
203
934
  return { installed, updated, skipped };
204
935
  }
205
936
 
206
- // ---------------------------------------------------------------------------
207
- // Core forge.yaml detection + write — used by both install() and init()
208
- // ---------------------------------------------------------------------------
209
-
210
937
  /**
211
- * Detect project info from the current working directory and write forge.yaml.
212
- * Returns the detected config object.
213
- * This is pure logic — no TUI output. Called from inside step callbacks.
214
- *
215
- * @param {string} cwd - Current working directory
216
- * @param {object} [opts]
217
- * @param {(text: string) => void} [opts.onNote] - Called with incremental progress notes
218
- * @returns {Promise<{owner, repo, remoteDetected, defaultBranch, stagingBranch, description, outputPath, wrote: boolean, skipped: boolean, skipReason: string}>}
938
+ * Enumerate pipeline-agent scripts in SCRIPTS_DIR that should be installed
939
+ * to ~/.claude/scripts/. Only files present in PIPELINE_SCRIPTS are returned.
940
+ * Subdirectories are not traversed — scripts/ is a flat directory.
941
+ *
942
+ * @param {string} dir - Absolute path to the scripts source directory.
943
+ * @returns {Promise<string[]>} Sorted list of absolute file paths.
219
944
  */
220
- async function detectAndBuildForgeYaml(cwd, opts = {}) {
221
- const { onNote = () => {} } = opts;
222
- const worktreeBase = join(cwd, ".claude", "worktrees");
223
- const outputPath = join(cwd, "forge.yaml");
224
-
225
- const detected = {
226
- owner: "your-github-org",
227
- repo: "your-repo-name",
228
- remoteDetected: false,
229
- defaultBranch: "main",
230
- stagingBranch: "staging",
231
- description: "",
232
- };
233
-
234
- // --- Detect git remote URL and parse owner/repo ---
945
+ async function findScriptFiles(dir) {
946
+ const results = [];
947
+ let entries;
235
948
  try {
236
- const remoteUrl = execSync("git remote get-url origin", {
237
- cwd,
238
- encoding: "utf-8",
239
- stdio: ["pipe", "pipe", "pipe"],
240
- }).trim();
241
-
242
- // SSH: git@github.com:owner/repo.git
243
- const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
244
- // HTTPS: https://github.com/owner/repo.git
245
- const httpsMatch = remoteUrl.match(
246
- /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
247
- );
248
-
249
- if (sshMatch) {
250
- detected.owner = sshMatch[1];
251
- detected.repo = sshMatch[2];
252
- detected.remoteDetected = true;
253
- } else if (httpsMatch) {
254
- detected.owner = httpsMatch[1];
255
- detected.repo = httpsMatch[2];
256
- detected.remoteDetected = true;
257
- }
258
- } catch {
259
- // No git remote — use placeholders
949
+ entries = await readdir(dir, { withFileTypes: true });
950
+ } catch (err) {
951
+ if (err.code === "ENOENT") return results; // scripts/ dir absent — skip silently
952
+ throw err;
260
953
  }
261
-
262
- onNote(
263
- detected.remoteDetected
264
- ? `${detected.owner}/${detected.repo}`
265
- : "no git remote — using placeholders",
266
- );
267
-
268
- // --- Detect default branch ---
269
- try {
270
- const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
271
- cwd,
272
- encoding: "utf-8",
273
- stdio: ["pipe", "pipe", "pipe"],
274
- }).trim();
275
- detected.defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
276
- } catch {
277
- try {
278
- detected.defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
279
- cwd,
280
- encoding: "utf-8",
281
- stdio: ["pipe", "pipe", "pipe"],
282
- }).trim();
283
- if (detected.defaultBranch === "HEAD") detected.defaultBranch = "main";
284
- } catch {
285
- // Use default "main"
954
+ for (const entry of entries) {
955
+ if (entry.isFile() && PIPELINE_SCRIPTS.has(entry.name)) {
956
+ results.push(join(dir, entry.name));
286
957
  }
287
958
  }
959
+ return results.sort();
960
+ }
288
961
 
289
- // --- Detect staging branch ---
290
- try {
291
- const remoteBranches = execSync("git branch -r", {
292
- cwd,
293
- encoding: "utf-8",
294
- stdio: ["pipe", "pipe", "pipe"],
295
- });
296
- detected.stagingBranch = remoteBranches.includes("origin/staging")
297
- ? "staging"
298
- : detected.defaultBranch;
299
- } catch {
300
- detected.stagingBranch = detected.defaultBranch;
301
- }
302
-
303
- // --- Auto-detect project description from README.md / CLAUDE.md ---
304
- function extractFirstParagraph(text) {
305
- const lines = text.split("\n");
306
- let inFirstParagraph = false;
307
- const paragraphLines = [];
308
- for (const line of lines) {
309
- if (!inFirstParagraph && line.match(/^#/)) continue;
310
- if (!inFirstParagraph && line.trim() === "") continue;
311
- if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
312
- if (!inFirstParagraph && line.match(/^---/)) continue;
313
- if (!inFirstParagraph) {
314
- inFirstParagraph = true;
315
- }
316
- if (line.trim() === "") break;
317
- paragraphLines.push(line.trim());
318
- }
319
- if (paragraphLines.length === 0) return "";
320
- return paragraphLines
321
- .join(" ")
322
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
323
- .replace(/\*\*([^*]+)\*\*/g, "$1")
324
- .replace(/`([^`]+)`/g, "$1")
325
- .slice(0, 200)
326
- .trim();
327
- }
962
+ /**
963
+ * Link or copy all scripts from SCRIPTS_DIR into SCRIPTS_TARGET_DIR (~/.claude/scripts/).
964
+ * Mirrors linkCommands() behaviour: symlink where permitted, copy on EPERM/EACCES (Windows).
965
+ * Called inside a runSteps() step — uses step.progress() and step.note().
966
+ *
967
+ * @param {object} step - StepAPI from runSteps
968
+ * @returns {Promise<{installed: number, updated: number, skipped: number}>}
969
+ */
970
+ async function linkScripts(step) {
971
+ const files = await findScriptFiles(SCRIPTS_DIR);
972
+ const total = files.length;
328
973
 
329
- for (const fname of ["README.md", "CLAUDE.md"]) {
330
- if (detected.description) break;
331
- try {
332
- const fpath = join(cwd, fname);
333
- if (existsSync(fpath)) {
334
- detected.description = extractFirstParagraph(
335
- readFileSync(fpath, "utf-8").slice(0, 2048),
336
- );
337
- }
338
- } catch {
339
- // Best-effort only
340
- }
974
+ if (total === 0) {
975
+ step.skip("no scripts to link");
976
+ return { installed: 0, updated: 0, skipped: 0 };
341
977
  }
342
978
 
343
- // --- Build forge.yaml content ---
344
- const { owner, repo, defaultBranch, stagingBranch, description } = detected;
345
-
346
- const projectName = repo
347
- .split(/[-_]/)
348
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
349
- .join(" ");
350
-
351
- const content = `# forge.yaml — ForgeDock Configuration
352
- #
353
- # Auto-generated by: npx forgedock init
354
- # Edit this file with your project details.
355
- #
356
- # Required sections: project, paths, branches
357
- # Optional sections: repos, project_board, services, review, verification
358
- #
359
- # See docs/CONFIG.md for full reference.
360
-
361
- # =============================================================================
362
- # PROJECT (REQUIRED)
363
- # =============================================================================
364
-
365
- project:
366
- name: "${projectName}"
367
- owner: "${owner}"
368
- repo: "${repo}"
369
- description: "${description.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"
370
-
371
- # =============================================================================
372
- # PATHS (REQUIRED)
373
- # =============================================================================
374
-
375
- paths:
376
- root: "${cwd.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"
377
- worktree_base: "${worktreeBase.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"
378
-
379
- # =============================================================================
380
- # BRANCHES (REQUIRED)
381
- # =============================================================================
382
-
383
- branches:
384
- default: "${defaultBranch}"
385
- staging: "${stagingBranch}"
386
- feature_pattern: "milestone/{slug}"
387
-
388
- # =============================================================================
389
- # REPOS (OPTIONAL)
390
- # Multi-repo configuration. Remove the # to enable.
391
- # =============================================================================
392
-
393
- # repos:
394
- # default:
395
- # repo: "${owner}/${repo}"
396
- # staging_branch: "${stagingBranch}"
397
- # satellites:
398
- # - prefix: "mcp"
399
- # repo: "${owner}/your-satellite-repo"
400
- # staging_branch: "main"
401
- # local_path: "${join(cwd, "..", "your-satellite-repo")}"
402
-
403
- # =============================================================================
404
- # PROJECT BOARD (OPTIONAL)
405
- # GitHub Projects v2 integration.
406
- # To find IDs: gh project list --owner ${owner}
407
- # =============================================================================
408
-
409
- # project_board:
410
- # owner: "${owner}"
411
- # project_number: 1
412
- # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
413
- # field_ids:
414
- # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
415
- # lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
416
- # component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
417
- # priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
418
- # workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
979
+ await mkdir(SCRIPTS_TARGET_DIR, { recursive: true });
419
980
 
420
- # =============================================================================
421
- # REVIEW (OPTIONAL)
422
- # Context injected into review agent prompts.
423
- # =============================================================================
981
+ let installed = 0;
982
+ let updated = 0;
983
+ let skipped = 0;
424
984
 
425
- # review:
426
- # tech_stack: "Node.js, TypeScript, PostgreSQL"
427
- # context: |
428
- # Describe your repo structure and any unusual conventions here.
985
+ for (let idx = 0; idx < files.length; idx++) {
986
+ const file = files[idx];
987
+ const rel = relative(SCRIPTS_DIR, file);
988
+ const target = join(SCRIPTS_TARGET_DIR, rel);
429
989
 
430
- # =============================================================================
431
- # VERIFICATION (OPTIONAL)
432
- # Health-check patterns for quality gate and validate commands.
433
- # =============================================================================
990
+ step.progress(idx + 1, total);
434
991
 
435
- # verification:
436
- # health_endpoint: "https://api.${repo}.io/health"
437
- # health_patterns:
438
- # - '"status": "ok"'
439
- `;
992
+ try {
993
+ const stats = await lstat(target);
440
994
 
441
- try {
442
- writeFileSync(outputPath, content, "utf-8");
443
- } catch (err) {
444
- throw new Error(
445
- `Could not write forge.yaml to ${outputPath} — ${err.code ?? err.message}. ` +
446
- `Run npx forgedock init from a writable project directory.`,
447
- );
995
+ if (stats.isSymbolicLink()) {
996
+ const current = await readlink(target);
997
+ if (current === file) {
998
+ skipped++;
999
+ } else {
1000
+ try {
1001
+ await symlink(file, target + ".tmp");
1002
+ const { rename } = await import("fs/promises");
1003
+ await rename(target + ".tmp", target);
1004
+ updated++;
1005
+ } catch (renameErr) {
1006
+ try {
1007
+ await unlink(target + ".tmp");
1008
+ } catch {
1009
+ /* .tmp already gone or was never created */
1010
+ }
1011
+ if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
1012
+ // Windows without Developer Mode: symlink() throws EPERM/EACCES.
1013
+ // Fall back to a direct copy (matches new-install fallback behaviour).
1014
+ const { copyFile } = await import("fs/promises");
1015
+ await copyFile(file, target);
1016
+ updated++;
1017
+ } else {
1018
+ throw renameErr;
1019
+ }
1020
+ }
1021
+ }
1022
+ } else {
1023
+ // Existing regular file — refresh if contents differ.
1024
+ if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
1025
+ skipped++;
1026
+ } else {
1027
+ const { copyFile } = await import("fs/promises");
1028
+ await copyFile(file, target);
1029
+ updated++;
1030
+ }
1031
+ }
1032
+ } catch (err) {
1033
+ if (err.code !== "ENOENT") throw err;
1034
+ // Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
1035
+ try {
1036
+ await symlink(file, target);
1037
+ } catch (linkErr) {
1038
+ if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
1039
+ const { copyFile } = await import("fs/promises");
1040
+ await copyFile(file, target);
1041
+ } else {
1042
+ throw linkErr;
1043
+ }
1044
+ }
1045
+ installed++;
1046
+ }
448
1047
  }
449
1048
 
450
- return { ...detected, outputPath, wrote: true };
1049
+ return { installed, updated, skipped };
451
1050
  }
452
1051
 
453
- // ---------------------------------------------------------------------------
454
- // Subcommands
455
- // ---------------------------------------------------------------------------
456
-
457
1052
  async function install() {
458
1053
  const result = await runSteps([
459
1054
  {
460
1055
  label: "Checking environment",
461
1056
  async run(step) {
1057
+ // Worktree guard — refuse to install when FORGE_HOME is a git worktree.
1058
+ // In a worktree, FORGE_HOME/.git is a regular file (not a directory).
1059
+ // Installing from a worktree would bake an ephemeral path into the
1060
+ // symlinks and SessionStart hook; the path breaks when the worktree
1061
+ // is cleaned up, taking all Forge commands offline globally.
1062
+ // <!-- Added: forge#1037 -->
1063
+ const gitPath = join(FORGE_HOME, ".git");
1064
+ let gitStat = null;
1065
+ try {
1066
+ gitStat = await lstat(gitPath);
1067
+ } catch (err) {
1068
+ if (err.code !== "ENOENT") throw err;
1069
+ // .git absent — npm install or detached checkout; not a worktree.
1070
+ }
1071
+ if (gitStat !== null && gitStat.isFile()) {
1072
+ throw new Error(
1073
+ `install() is running from a git worktree (${FORGE_HOME}).\n` +
1074
+ ` Installing from a worktree would repoint ~/.claude/commands/ symlinks\n` +
1075
+ ` to an ephemeral path that breaks when the worktree is deleted.\n` +
1076
+ ` Run \`npx forgedock install\` from the main repository clone instead.`,
1077
+ );
1078
+ }
1079
+
462
1080
  await mkdir(TARGET_DIR, { recursive: true });
463
1081
  step.note(cyan(TARGET_DIR));
464
1082
  },
@@ -467,25 +1085,72 @@ async function install() {
467
1085
  label: "Linking commands",
468
1086
  async run(step) {
469
1087
  const { installed, updated, skipped } = await linkCommands(step);
1088
+ const total = installed + updated + skipped;
470
1089
  step.note(
471
- `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
1090
+ `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped (${total} commands total)`,
472
1091
  );
473
1092
  },
474
1093
  },
475
1094
  {
476
- label: "Configuring session hook",
1095
+ label: "Writing namespace sentinel",
1096
+ async run(step) {
1097
+ // Write .symlink-source to ~/.claude/commands/ so other installers
1098
+ // can detect that ForgeDock owns this namespace. Non-fatal on failure.
1099
+ // <!-- Added: forge#1038 -->
1100
+ const result = writeSymlinkSentinel();
1101
+ if (result === "failed") {
1102
+ step.note("could not write .symlink-source — run npx forgedock install again to retry");
1103
+ } else {
1104
+ step.note(cyan(join(TARGET_DIR, ".symlink-source")));
1105
+ }
1106
+ },
1107
+ },
1108
+ {
1109
+ label: "Linking scripts",
1110
+ async run(step) {
1111
+ const { installed, updated, skipped } = await linkScripts(step);
1112
+ if (installed + updated + skipped > 0) {
1113
+ step.note(
1114
+ `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
1115
+ );
1116
+ }
1117
+ },
1118
+ },
1119
+ {
1120
+ label: "Configuring shell profile",
477
1121
  async run(step) {
1122
+ // Set FORGE_HOME in shell profiles
478
1123
  let profileUpdated = false;
479
1124
  for (const profile of [
480
- join(process.env.HOME, ".bashrc"),
481
- join(process.env.HOME, ".zshrc"),
1125
+ join(HOME, ".bashrc"),
1126
+ join(HOME, ".zshrc"),
482
1127
  ]) {
483
1128
  if (existsSync(profile)) {
484
1129
  const content = readFileSync(profile, "utf-8");
485
- if (!content.includes("FORGE_HOME")) {
1130
+ // Escape shell metacharacters in FORGE_HOME before embedding it in
1131
+ // a double-quoted shell assignment. A crafted install path
1132
+ // containing $(...), backticks, or " characters would otherwise
1133
+ // inject arbitrary shell commands executed on the user's next login.
1134
+ // shellEscapeDoubleQuotedPath() applies the same escaping used for
1135
+ // the SessionStart hook command (ref: forge#808, forge#792, forge#451).
1136
+ const safeForgeHome = shellEscapeDoubleQuotedPath(FORGE_HOME);
1137
+ const exactExport = `export FORGE_HOME="${safeForgeHome}"`;
1138
+ if (content.includes(exactExport)) {
1139
+ // Already set to the current path — no update needed.
1140
+ } else {
1141
+ // Either not present, or set to a stale path — refresh it.
1142
+ if (content.includes("FORGE_HOME")) {
1143
+ // Strip the old block before appending the updated one.
1144
+ const removeResult = removeForgeHomeFromProfile(profile);
1145
+ if (removeResult === "failed") {
1146
+ // Removal failed — old export still present. Skip append to
1147
+ // avoid writing a duplicate FORGE_HOME export. (ref: forge#846)
1148
+ continue;
1149
+ }
1150
+ }
486
1151
  appendFileSync(
487
1152
  profile,
488
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
1153
+ `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${safeForgeHome}"\n`,
489
1154
  );
490
1155
  profileUpdated = true;
491
1156
  }
@@ -496,13 +1161,23 @@ async function install() {
496
1161
  }
497
1162
  },
498
1163
  },
1164
+ {
1165
+ label: "Registering SessionStart hook",
1166
+ async run(step) {
1167
+ const hookResult = await installSessionStartHook();
1168
+ if (hookResult === "already-present") {
1169
+ step.skip("already present");
1170
+ } else if (hookResult === "failed") {
1171
+ // Non-fatal — warn but don't throw
1172
+ step.note("could not write — run npx forgedock install again to retry");
1173
+ }
1174
+ },
1175
+ },
499
1176
  {
500
1177
  label: "Generating forge.yaml",
501
1178
  async run(step) {
502
1179
  // Auto-generate forge.yaml if missing — no second command needed.
503
- // Only do this inside a real git project; otherwise cwd may be a system or
504
- // read-only directory (e.g. C:\Windows when npx is launched from a WSL path),
505
- // and writing forge.yaml there would crash. See #585.
1180
+ // Only do this inside a real git project (ref: forge#585).
506
1181
  const cwd = process.cwd();
507
1182
  const forgeYamlPath = join(cwd, "forge.yaml");
508
1183
  if (existsSync(forgeYamlPath)) {
@@ -510,19 +1185,47 @@ async function install() {
510
1185
  return;
511
1186
  }
512
1187
  if (!isGitWorkTree(cwd)) {
513
- step.skip(
514
- "not a git project — run npx forgedock init inside your project",
515
- );
1188
+ step.skip("not a git project — run npx forgedock init inside your project");
1189
+ return;
1190
+ }
1191
+ await init(true);
1192
+ },
1193
+ },
1194
+ {
1195
+ label: "Updating CLAUDE.md",
1196
+ async run(step) {
1197
+ // Inject behavioral rules into CLAUDE.md (and AGENTS.md if present).
1198
+ // Only inside a git project — guards against non-project cwd (ref: forge#585).
1199
+ const installCwd = process.cwd();
1200
+ if (!isGitWorkTree(installCwd)) {
1201
+ step.skip("not a git project");
516
1202
  return;
517
1203
  }
518
- const detected = await detectAndBuildForgeYaml(cwd, {
519
- onNote: () => {},
520
- });
521
- if (!detected.remoteDetected) {
522
- step.note("created — fill in project.owner and project.repo");
1204
+
1205
+ const claudeMdPath = join(installCwd, "CLAUDE.md");
1206
+ const agentsMdPath = join(installCwd, "AGENTS.md");
1207
+
1208
+ const claudeResult = injectManagedBlock(claudeMdPath);
1209
+ let note = "";
1210
+ if (claudeResult === "created") {
1211
+ note = "CLAUDE.md created";
1212
+ } else if (claudeResult === "updated") {
1213
+ note = "CLAUDE.md block refreshed";
1214
+ } else if (claudeResult === "appended") {
1215
+ note = "CLAUDE.md rules appended";
523
1216
  } else {
524
- step.note(`created for ${detected.owner}/${detected.repo}`);
1217
+ note = "CLAUDE.md already current";
525
1218
  }
1219
+
1220
+ // Mirror to AGENTS.md only if it already exists (never create it)
1221
+ if (existsSync(agentsMdPath)) {
1222
+ const agentsResult = injectManagedBlock(agentsMdPath);
1223
+ if (agentsResult === "updated" || agentsResult === "appended") {
1224
+ note += " · AGENTS.md mirrored";
1225
+ }
1226
+ }
1227
+
1228
+ step.note(note);
526
1229
  },
527
1230
  },
528
1231
  ]);
@@ -534,6 +1237,7 @@ async function install() {
534
1237
  `${green("✔")} ${bold("ForgeDock is ready")}`,
535
1238
  "",
536
1239
  `Commands installed to ${cyan(TARGET_DIR)}`,
1240
+ `Scripts installed to ${cyan(SCRIPTS_TARGET_DIR)}`,
537
1241
  `Next: ${cyan("cd <your-project>")} then ${cyan("npx forgedock init")}`,
538
1242
  ],
539
1243
  { title: "ForgeDock Installed" },
@@ -543,204 +1247,440 @@ async function install() {
543
1247
  }
544
1248
 
545
1249
  async function uninstall() {
546
- const result = await runSteps([
547
- {
548
- label: "Removing commands",
549
- async run(step) {
550
- const files = await findMarkdownFiles(COMMANDS_DIR);
551
- const total = files.length;
552
- let removed = 0;
1250
+ console.log("");
1251
+ console.log(`${BOLD}ForgeDock${RESET} — Removing pipeline commands`);
1252
+ console.log("");
553
1253
 
554
- for (let idx = 0; idx < files.length; idx++) {
555
- const file = files[idx];
556
- const rel = relative(COMMANDS_DIR, file);
557
- const target = join(TARGET_DIR, rel);
1254
+ const files = await findMarkdownFiles(COMMANDS_DIR);
1255
+ let removed = 0;
558
1256
 
559
- try {
560
- const stats = await lstat(target);
561
- const { unlink } = await import("fs/promises");
562
- if (stats.isSymbolicLink()) {
563
- const current = await readlink(target);
564
- if (current === file) {
565
- await unlink(target);
566
- removed++;
567
- }
568
- } else if (
569
- readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
570
- ) {
571
- // Regular file installed by ForgeDock in copy mode (content matches the
572
- // shipped command). Safe to remove. Content-differing files are left
573
- // in place in case the user edited them.
574
- await unlink(target);
575
- removed++;
576
- }
577
- } catch (err) {
578
- if (err.code !== "ENOENT") {
579
- throw err;
580
- }
581
- // Doesn't exist — nothing to do
582
- }
1257
+ for (const file of files) {
1258
+ const rel = relative(COMMANDS_DIR, file);
1259
+ const target = join(TARGET_DIR, rel);
583
1260
 
584
- step.progress(idx + 1, total);
1261
+ try {
1262
+ const stats = await lstat(target);
1263
+ if (stats.isSymbolicLink()) {
1264
+ const current = await readlink(target);
1265
+ if (current === file) {
1266
+ const { unlink } = await import("fs/promises");
1267
+ await unlink(target);
1268
+ console.log(` ${RED}Removed${RESET}: ${rel}`);
1269
+ removed++;
585
1270
  }
1271
+ } else if (
1272
+ readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
1273
+ ) {
1274
+ // Regular file installed by ForgeDock in copy mode — content matches.
1275
+ const { unlink } = await import("fs/promises");
1276
+ await unlink(target);
1277
+ console.log(` ${RED}Removed${RESET}: ${rel}`);
1278
+ removed++;
1279
+ }
1280
+ } catch (err) {
1281
+ if (err.code !== "ENOENT") throw err;
1282
+ // Doesn't exist — nothing to do
1283
+ }
1284
+ }
586
1285
 
587
- step.note(`${red(String(removed))} removed`);
588
- },
589
- },
590
- ]);
1286
+ console.log("");
1287
+ console.log(`Done. Removed: ${removed} commands.`);
1288
+ console.log("");
591
1289
 
592
- if (result.ok) {
593
- process.stderr.write(
594
- box(
595
- [
596
- `${green("✔")} ForgeDock commands removed`,
597
- "",
598
- `Re-install anytime: ${cyan("npx forgedock")}`,
599
- ],
600
- { title: "ForgeDock Uninstalled" },
601
- ) + "\n",
602
- );
603
- }
604
- }
1290
+ // Remove scripts installed by ForgeDock from ~/.claude/scripts/
1291
+ const scriptFiles = await findScriptFiles(SCRIPTS_DIR);
1292
+ let scriptsRemoved = 0;
605
1293
 
606
- async function update() {
607
- // Check if installed via npm (no .git directory) or via git clone
608
- const gitDir = join(FORGE_HOME, ".git");
609
- if (!existsSync(gitDir)) {
610
- await runSteps([
611
- {
612
- label: "Checking update path",
613
- async run(step) {
614
- step.skip(`installed via npm — run ${cyan("npm update -g forgedock")}`);
615
- },
616
- },
617
- ]);
618
- return;
619
- }
1294
+ for (const file of scriptFiles) {
1295
+ const rel = relative(SCRIPTS_DIR, file);
1296
+ const target = join(SCRIPTS_TARGET_DIR, rel);
620
1297
 
621
- const result = await runSteps([
622
- {
623
- label: "Checking current branch",
624
- async run(step) {
625
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
626
- cwd: FORGE_HOME,
627
- encoding: "utf-8",
628
- }).trim();
629
- if (branch !== "main") {
630
- step.skip(`not on main (${branch})`);
631
- } else {
632
- step.note("on main");
633
- }
634
- },
635
- },
636
- {
637
- label: "Fetching latest",
638
- async run(step) {
639
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
640
- cwd: FORGE_HOME,
641
- encoding: "utf-8",
642
- }).trim();
643
- if (branch !== "main") {
644
- step.skip("skipped — not on main");
645
- return;
646
- }
647
- const before = execSync("git rev-parse HEAD", {
648
- cwd: FORGE_HOME,
649
- encoding: "utf-8",
650
- }).trim();
651
- try {
652
- execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
653
- execSync("git merge --ff-only origin/main --quiet", {
654
- cwd: FORGE_HOME,
655
- });
656
- } catch {
657
- step.skip("cannot fast-forward — local changes exist");
658
- return;
659
- }
660
- const after = execSync("git rev-parse HEAD", {
661
- cwd: FORGE_HOME,
662
- encoding: "utf-8",
663
- }).trim();
664
- if (before === after) {
665
- step.skip("already up to date");
666
- } else {
667
- step.note(`${before.slice(0, 7)} → ${after.slice(0, 7)}`);
668
- }
669
- },
670
- },
671
- {
672
- label: "Reinstalling commands",
673
- async run(step) {
674
- const branch = execSync("git rev-parse --abbrev-ref HEAD", {
675
- cwd: FORGE_HOME,
676
- encoding: "utf-8",
677
- }).trim();
678
- if (branch !== "main") {
679
- step.skip("skipped — not on main");
680
- return;
1298
+ try {
1299
+ const stats = await lstat(target);
1300
+ const { unlink } = await import("fs/promises");
1301
+ if (stats.isSymbolicLink()) {
1302
+ const current = await readlink(target);
1303
+ if (current === file) {
1304
+ await unlink(target);
1305
+ console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
1306
+ scriptsRemoved++;
681
1307
  }
682
- const { installed, updated, skipped } = await linkCommands(step);
683
- step.note(
684
- `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
685
- );
686
- },
687
- },
688
- ]);
1308
+ } else if (
1309
+ readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
1310
+ ) {
1311
+ // Regular file installed by ForgeDock in copy mode — content matches.
1312
+ await unlink(target);
1313
+ console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
1314
+ scriptsRemoved++;
1315
+ }
1316
+ } catch (err) {
1317
+ if (err.code !== "ENOENT") throw err;
1318
+ // Doesn't exist — nothing to do
1319
+ }
1320
+ }
689
1321
 
690
- if (result.ok) {
691
- process.stderr.write(
692
- box([`${green("✔")} ForgeDock is up to date`], {
693
- title: "ForgeDock Updated",
694
- }) + "\n",
1322
+ if (scriptFiles.length > 0) {
1323
+ console.log("");
1324
+ console.log(`Done. Removed: ${scriptsRemoved} scripts.`);
1325
+ console.log("");
1326
+ }
1327
+
1328
+ // Remove the SessionStart hook from ~/.claude/settings.json
1329
+ const hookRemoveResult = await removeSessionStartHook();
1330
+ if (hookRemoveResult === "removed") {
1331
+ console.log(
1332
+ ` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}~/.claude/settings.json${RESET}`,
1333
+ );
1334
+ } else if (hookRemoveResult === "not-present") {
1335
+ console.log(
1336
+ ` ✔ SessionStart hook already absent from ~/.claude/settings.json`,
695
1337
  );
1338
+ } else {
1339
+ console.log(
1340
+ ` ${YELLOW}⚠${RESET} Could not update ~/.claude/settings.json — remove the hook manually.`,
1341
+ );
1342
+ }
1343
+ console.log("");
1344
+
1345
+ // Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present)
1346
+ const uninstallCwd = process.cwd();
1347
+ const claudeMdPath = join(uninstallCwd, "CLAUDE.md");
1348
+ const agentsMdPath = join(uninstallCwd, "AGENTS.md");
1349
+
1350
+ const claudeRemoveResult = removeManagedBlock(claudeMdPath);
1351
+ if (claudeRemoveResult === "removed") {
1352
+ console.log(
1353
+ ` ${GREEN}✔${RESET} Removed ForgeDock pipeline rules from ${CYAN}CLAUDE.md${RESET}`,
1354
+ );
1355
+ } else {
1356
+ console.log(` ✔ No ForgeDock block in CLAUDE.md — nothing to remove`);
1357
+ }
1358
+
1359
+ if (existsSync(agentsMdPath)) {
1360
+ const agentsRemoveResult = removeManagedBlock(agentsMdPath);
1361
+ if (agentsRemoveResult === "removed") {
1362
+ console.log(
1363
+ ` ${GREEN}✔${RESET} Removed ForgeDock pipeline rules from ${CYAN}AGENTS.md${RESET}`,
1364
+ );
1365
+ } else {
1366
+ console.log(` ✔ No ForgeDock block in AGENTS.md — nothing to remove`);
1367
+ }
1368
+ }
1369
+ console.log("");
1370
+
1371
+ // Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc)
1372
+ for (const profileName of [".bashrc", ".zshrc"]) {
1373
+ const profilePath = join(HOME, profileName);
1374
+ const profileResult = removeForgeHomeFromProfile(profilePath);
1375
+ if (profileResult === "removed") {
1376
+ console.log(
1377
+ ` ${GREEN}✔${RESET} Removed FORGE_HOME export from ${CYAN}~/${profileName}${RESET}`,
1378
+ );
1379
+ } else if (profileResult === "not-present") {
1380
+ console.log(` ✔ No FORGE_HOME entry in ~/${profileName} — nothing to remove`);
1381
+ } else {
1382
+ console.log(
1383
+ ` ${YELLOW}⚠${RESET} Could not update ~/${profileName} — remove the FORGE_HOME export manually.`,
1384
+ );
1385
+ }
696
1386
  }
1387
+ console.log("");
1388
+
1389
+ // Remove .forgedock marker file from cwd (created by `npx forgedock enable`)
1390
+ const markerPath = join(uninstallCwd, ".forgedock");
1391
+ if (existsSync(markerPath)) {
1392
+ try {
1393
+ unlinkSync(markerPath);
1394
+ console.log(
1395
+ ` ${GREEN}✔${RESET} Removed ${CYAN}.forgedock${RESET} marker from ${CYAN}${uninstallCwd}${RESET}`,
1396
+ );
1397
+ } catch {
1398
+ console.log(
1399
+ ` ${YELLOW}⚠${RESET} Could not remove .forgedock marker — remove it manually: ${markerPath}`,
1400
+ );
1401
+ }
1402
+ } else {
1403
+ console.log(` ✔ No .forgedock marker in current directory — nothing to remove`);
1404
+ }
1405
+ console.log("");
1406
+
1407
+ // Clean up registry nudgeSeen entry for cwd (written by the session-start hook)
1408
+ try {
1409
+ const { clearNudgeSeen } = await import(
1410
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1411
+ );
1412
+ await clearNudgeSeen(uninstallCwd);
1413
+ console.log(
1414
+ ` ${GREEN}✔${RESET} Cleaned registry entries for ${CYAN}${uninstallCwd}${RESET}`,
1415
+ );
1416
+ } catch {
1417
+ // Non-fatal — registry is best-effort
1418
+ console.log(
1419
+ ` ${YELLOW}⚠${RESET} Could not clean registry entries — safe to ignore.`,
1420
+ );
1421
+ }
1422
+ console.log("");
1423
+
1424
+ console.log(`${GREEN}ForgeDock uninstalled.${RESET} No residue remains from this directory.`);
1425
+ console.log(` Re-install at any time with: ${CYAN}npx forgedock${RESET}`);
1426
+ console.log("");
1427
+ }
1428
+
1429
+ async function update() {
1430
+ console.log("");
1431
+ console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
1432
+ console.log("");
1433
+
1434
+ // Check if installed via npm (no .git directory) or via git clone
1435
+ const gitDir = join(FORGE_HOME, ".git");
1436
+ if (existsSync(gitDir)) {
1437
+ try {
1438
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1439
+ cwd: FORGE_HOME,
1440
+ encoding: "utf-8",
1441
+ }).trim();
1442
+ if (branch !== "main") {
1443
+ console.log(` Not on main branch (${branch}) — skipping`);
1444
+ return;
1445
+ }
1446
+
1447
+ const before = execSync("git rev-parse HEAD", {
1448
+ cwd: FORGE_HOME,
1449
+ encoding: "utf-8",
1450
+ }).trim();
1451
+ execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
1452
+ execSync("git merge --ff-only origin/main --quiet", {
1453
+ cwd: FORGE_HOME,
1454
+ });
1455
+ const after = execSync("git rev-parse HEAD", {
1456
+ cwd: FORGE_HOME,
1457
+ encoding: "utf-8",
1458
+ }).trim();
1459
+
1460
+ if (before === after) {
1461
+ console.log(` Already up to date.`);
1462
+ } else {
1463
+ console.log(` ${GREEN}Updated to latest.${RESET}`);
1464
+ await install();
1465
+ }
1466
+ } catch (err) {
1467
+ console.log(
1468
+ ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
1469
+ );
1470
+ }
1471
+ } else {
1472
+ console.log(
1473
+ ` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
1474
+ );
1475
+ }
1476
+ console.log("");
697
1477
  }
698
1478
 
699
1479
  async function init(fromInstall = false) {
1480
+ // When called from install() via runSteps(), suppress stdout to prevent
1481
+ // interleaving with the TUI spinner on stderr. runSteps() provides the
1482
+ // "Generating forge.yaml" step label as visual feedback. (#812)
1483
+ const log = fromInstall ? () => {} : console.log;
1484
+ log("");
1485
+ log(`${BOLD}ForgeDock${RESET} — Generating forge.yaml`);
1486
+ log("");
1487
+
700
1488
  const cwd = process.cwd();
1489
+ const worktreeBase = join(cwd, ".claude", "worktrees");
701
1490
  const outputPath = join(cwd, "forge.yaml");
702
1491
 
703
- // When called from install(), we already know forge.yaml doesn't exist
704
- // and cwd is a git work tree — go straight to detection and write.
705
- if (fromInstall) {
706
- const detected = await detectAndBuildForgeYaml(cwd, { onNote: () => {} });
707
- if (!detected.remoteDetected) {
708
- process.stderr.write(
709
- box(
710
- [
711
- `${yellow("Action required:")}`,
712
- `Edit ${cyan("forge.yaml")} — fill in ${cyan("project.owner")} and ${cyan("project.repo")}`,
713
- "(git remote not detected)",
714
- ],
715
- { title: "forge.yaml Generated" },
716
- ) + "\n",
1492
+ // --- Detect git remote URL and parse owner/repo ---
1493
+ let owner = "your-github-org";
1494
+ let repo = "your-repo-name";
1495
+ let remoteDetected = false;
1496
+
1497
+ try {
1498
+ const remoteUrl = execSync("git remote get-url origin", {
1499
+ cwd,
1500
+ encoding: "utf-8",
1501
+ stdio: ["pipe", "pipe", "pipe"],
1502
+ }).trim();
1503
+
1504
+ // SSH: git@github.com:owner/repo.git
1505
+ const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
1506
+ // HTTPS: https://github.com/owner/repo.git
1507
+ const httpsMatch = remoteUrl.match(
1508
+ /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
1509
+ );
1510
+
1511
+ if (sshMatch) {
1512
+ owner = sshMatch[1];
1513
+ repo = sshMatch[2];
1514
+ remoteDetected = true;
1515
+ } else if (httpsMatch) {
1516
+ owner = httpsMatch[1];
1517
+ repo = httpsMatch[2];
1518
+ remoteDetected = true;
1519
+ } else {
1520
+ log(
1521
+ ` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
717
1522
  );
718
1523
  }
719
- return;
1524
+ } catch {
1525
+ log(
1526
+ ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1527
+ );
720
1528
  }
721
1529
 
722
- // Interactive path — run as a step-checklist with a full summary box.
723
- let detected = null;
724
- let aborted = false;
1530
+ if (remoteDetected) {
1531
+ log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
1532
+ }
725
1533
 
726
- // Handle existing forge.yaml before entering runSteps (needs interactive prompt)
727
- if (existsSync(outputPath)) {
728
- process.stderr.write(
729
- `\n ${YELLOW}Warning:${RESET} forge.yaml already exists at ${outputPath}\n`,
1534
+ // --- Detect default branch ---
1535
+ let defaultBranch = "main";
1536
+ try {
1537
+ const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1538
+ cwd,
1539
+ encoding: "utf-8",
1540
+ stdio: ["pipe", "pipe", "pipe"],
1541
+ }).trim();
1542
+ // refs/remotes/origin/main → main
1543
+ defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
1544
+ log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
1545
+ } catch {
1546
+ // Fallback: try git rev-parse
1547
+ try {
1548
+ defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
1549
+ cwd,
1550
+ encoding: "utf-8",
1551
+ stdio: ["pipe", "pipe", "pipe"],
1552
+ }).trim();
1553
+ if (defaultBranch === "HEAD") defaultBranch = "main";
1554
+ log(
1555
+ ` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
1556
+ );
1557
+ } catch {
1558
+ log(
1559
+ ` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
1560
+ );
1561
+ }
1562
+ }
1563
+
1564
+ // --- Detect staging branch ---
1565
+ let stagingBranch = "staging";
1566
+ try {
1567
+ const remoteBranches = execSync("git branch -r", {
1568
+ cwd,
1569
+ encoding: "utf-8",
1570
+ stdio: ["pipe", "pipe", "pipe"],
1571
+ });
1572
+ if (remoteBranches.includes("origin/staging")) {
1573
+ stagingBranch = "staging";
1574
+ log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
1575
+ } else {
1576
+ stagingBranch = defaultBranch;
1577
+ log(
1578
+ ` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
1579
+ );
1580
+ }
1581
+ } catch {
1582
+ log(
1583
+ ` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
730
1584
  );
731
- process.stderr.write(
732
- ` Continuing will back up your existing config and replace it.\n\n`,
1585
+ stagingBranch = defaultBranch;
1586
+ }
1587
+
1588
+ // --- Auto-detect project description from README.md ---
1589
+ let description = "";
1590
+ try {
1591
+ const readmePath = join(cwd, "README.md");
1592
+ if (existsSync(readmePath)) {
1593
+ const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
1594
+ // Skip the first heading line (# Title), grab the first non-empty paragraph
1595
+ const lines = readmeContent.split("\n");
1596
+ let inFirstParagraph = false;
1597
+ const paragraphLines = [];
1598
+ for (const line of lines) {
1599
+ // Skip heading lines at the top
1600
+ if (!inFirstParagraph && line.match(/^#/)) continue;
1601
+ // Skip blank lines before paragraph starts
1602
+ if (!inFirstParagraph && line.trim() === "") continue;
1603
+ // Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
1604
+ if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
1605
+ if (!inFirstParagraph && line.match(/^---/)) continue;
1606
+ // Start collecting
1607
+ if (!inFirstParagraph) {
1608
+ inFirstParagraph = true;
1609
+ }
1610
+ // Stop at blank line (end of paragraph)
1611
+ if (line.trim() === "") break;
1612
+ paragraphLines.push(line.trim());
1613
+ }
1614
+ if (paragraphLines.length > 0) {
1615
+ // Flatten to single line, strip markdown links/bold/inline code
1616
+ description = paragraphLines
1617
+ .join(" ")
1618
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1619
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1620
+ .replace(/`([^`]+)`/g, "$1")
1621
+ .slice(0, 200)
1622
+ .trim();
1623
+ }
1624
+ }
1625
+ } catch {
1626
+ // Best-effort only — silently fall back to empty description
1627
+ }
1628
+
1629
+ if (description) {
1630
+ log(
1631
+ ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from README.md)`,
733
1632
  );
734
- const confirmed = await confirmOverwrite(` Overwrite? [y/N] `);
735
- if (!confirmed) {
736
- process.stderr.write(
737
- `\n ${YELLOW}Aborted.${RESET} Your forge.yaml was not modified.\n\n`,
1633
+ }
1634
+
1635
+ // --- Auto-detect project description from CLAUDE.md (fallback) ---
1636
+ if (!description) {
1637
+ try {
1638
+ const claudePath = join(cwd, "CLAUDE.md");
1639
+ if (existsSync(claudePath)) {
1640
+ const claudeContent = readFileSync(claudePath, "utf-8").slice(0, 2048);
1641
+ const lines = claudeContent.split("\n");
1642
+ let inFirstParagraph = false;
1643
+ const paragraphLines = [];
1644
+ for (const line of lines) {
1645
+ // Skip heading lines at the top
1646
+ if (!inFirstParagraph && line.match(/^#/)) continue;
1647
+ // Skip blank lines before paragraph starts
1648
+ if (!inFirstParagraph && line.trim() === "") continue;
1649
+ // Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
1650
+ if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
1651
+ if (!inFirstParagraph && line.match(/^---/)) continue;
1652
+ // Start collecting
1653
+ if (!inFirstParagraph) {
1654
+ inFirstParagraph = true;
1655
+ }
1656
+ // Stop at blank line (end of paragraph)
1657
+ if (line.trim() === "") break;
1658
+ paragraphLines.push(line.trim());
1659
+ }
1660
+ if (paragraphLines.length > 0) {
1661
+ // Flatten to single line, strip markdown links/bold/inline code
1662
+ description = paragraphLines
1663
+ .join(" ")
1664
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1665
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1666
+ .replace(/`([^`]+)`/g, "$1")
1667
+ .slice(0, 200)
1668
+ .trim();
1669
+ }
1670
+ }
1671
+ } catch {
1672
+ // Best-effort only — silently fall back to empty description
1673
+ }
1674
+
1675
+ if (description) {
1676
+ log(
1677
+ ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from CLAUDE.md)`,
738
1678
  );
739
- return;
740
1679
  }
741
- process.stderr.write("\n");
1680
+ }
742
1681
 
743
- // Back up existing file before proceeding
1682
+ // --- Handle existing forge.yaml ---
1683
+ if (existsSync(outputPath)) {
744
1684
  const baseBak = join(cwd, "forge.yaml.bak");
745
1685
  const backupPath = existsSync(baseBak)
746
1686
  ? join(
@@ -750,65 +1690,773 @@ async function init(fromInstall = false) {
750
1690
  : baseBak;
751
1691
  const backupName = backupPath.split("/").pop();
752
1692
  renameSync(outputPath, backupPath);
753
- process.stderr.write(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}\n\n`);
1693
+ log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
754
1694
  }
755
1695
 
756
- const result = await runSteps([
757
- {
758
- label: "Detecting project",
759
- async run(step) {
760
- detected = await detectAndBuildForgeYaml(cwd, {
761
- onNote: (msg) => step.note(msg),
762
- });
763
- },
764
- },
765
- ]);
1696
+ // --- Generate forge.yaml content ---
1697
+ const projectName = repo
1698
+ .split(/[-_]/)
1699
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1700
+ .join(" ");
766
1701
 
767
- if (!result.ok || !detected) return;
768
-
769
- const {
770
- owner,
771
- repo,
772
- defaultBranch,
773
- stagingBranch,
774
- remoteDetected,
775
- description,
776
- } = detected;
777
-
778
- const summaryLines = [
779
- `${green("✔")} forge.yaml generated`,
780
- "",
781
- ` repo: ${cyan(owner + "/" + repo)}`,
782
- ` default: ${cyan(defaultBranch)}`,
783
- ` staging: ${cyan(stagingBranch)}`,
784
- ];
785
- if (description) {
786
- summaryLines.push(
787
- ` desc: ${dim(
788
- description.slice(0, 60) + (description.length > 60 ? "…" : ""),
789
- )}`,
1702
+ // Pre-escape values for embedding in YAML double-quoted strings.
1703
+ // Backslash must be escaped first to avoid double-escaping.
1704
+ const safeOwner = owner.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1705
+ const safeRepo = repo.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1706
+ const safeProjectName = projectName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1707
+ const safeDefaultBranch = defaultBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1708
+ const safeStagingBranch = stagingBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1709
+
1710
+ const content = `# forge.yaml — ForgeDock Configuration
1711
+ #
1712
+ # Auto-generated by: npx forgedock init
1713
+ # Edit this file with your project details.
1714
+ #
1715
+ # Required sections: project, paths, branches
1716
+ # Optional sections: repos, project_board, services, review, verification
1717
+ #
1718
+ # See docs/CONFIG.md for full reference.
1719
+
1720
+ # =============================================================================
1721
+ # PROJECT (REQUIRED)
1722
+ # =============================================================================
1723
+
1724
+ project:
1725
+ name: "${safeProjectName}"
1726
+ owner: "${safeOwner}"
1727
+ repo: "${safeRepo}"
1728
+ description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1729
+
1730
+ # =============================================================================
1731
+ # PATHS (REQUIRED)
1732
+ # =============================================================================
1733
+
1734
+ paths:
1735
+ root: "${cwd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1736
+ worktree_base: "${worktreeBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1737
+
1738
+ # =============================================================================
1739
+ # BRANCHES (REQUIRED)
1740
+ # =============================================================================
1741
+
1742
+ branches:
1743
+ default: "${safeDefaultBranch}"
1744
+ staging: "${safeStagingBranch}"
1745
+ feature_pattern: "milestone/{slug}"
1746
+
1747
+ # =============================================================================
1748
+ # REPOS (OPTIONAL)
1749
+ # Multi-repo configuration. Remove the # to enable.
1750
+ # =============================================================================
1751
+
1752
+ # repos:
1753
+ # default:
1754
+ # repo: "${safeOwner}/${safeRepo}"
1755
+ # staging_branch: "${safeStagingBranch}"
1756
+ # satellites:
1757
+ # - prefix: "mcp"
1758
+ # repo: "${safeOwner}/your-satellite-repo"
1759
+ # staging_branch: "main"
1760
+ # local_path: "${join(cwd, "..", "your-satellite-repo").replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1761
+
1762
+ # =============================================================================
1763
+ # PROJECT BOARD (OPTIONAL)
1764
+ # GitHub Projects v2 integration.
1765
+ # To find IDs: gh project list --owner ${safeOwner}
1766
+ # =============================================================================
1767
+
1768
+ # project_board:
1769
+ # owner: "${safeOwner}"
1770
+ # project_number: 1
1771
+ # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
1772
+ # field_ids:
1773
+ # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1774
+ # lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1775
+ # component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1776
+ # priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1777
+ # workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1778
+
1779
+ # =============================================================================
1780
+ # REVIEW (OPTIONAL)
1781
+ # Context injected into review agent prompts.
1782
+ # =============================================================================
1783
+
1784
+ # review:
1785
+ # tech_stack: "Node.js, TypeScript, PostgreSQL"
1786
+ # context: |
1787
+ # Describe your repo structure and any unusual conventions here.
1788
+
1789
+ # =============================================================================
1790
+ # VERIFICATION (OPTIONAL)
1791
+ # Health-check patterns for quality gate and validate commands.
1792
+ # =============================================================================
1793
+
1794
+ # verification:
1795
+ # health_endpoint: "https://api.${safeRepo}.io/health"
1796
+ # health_patterns:
1797
+ # - '"status": "ok"'
1798
+ #
1799
+ # # Integration test suites run by /test-gate against the provisioned cluster.
1800
+ # # Each entry specifies a logical cluster (matched to test_services), the shell
1801
+ # # command to execute, and the working directory (relative to project root).
1802
+ # # Used by: test-gate (Phase 3 Provision + Phase 4 Fan out)
1803
+ # #
1804
+ # # integration_tests:
1805
+ # # - cluster: "api"
1806
+ # # command: "pytest tests/integration/ -q --tb=short"
1807
+ # # working_dir: "."
1808
+ #
1809
+ # # Maps logical cluster names (from integration_tests) to running container
1810
+ # # names. /test-gate checks these containers are live before running tests.
1811
+ # # Omit entries for clusters that do not use Docker.
1812
+ # # Used by: test-gate (Phase 3 Provision)
1813
+ # #
1814
+ # # test_services:
1815
+ # # api: "${safeRepo}-api-blue"
1816
+ # # worker: "${safeRepo}-worker-blue"
1817
+ #
1818
+ # # Controls /test-gate posture at the staging-to-main deploy boundary.
1819
+ # # posture: blocking (default) or advisory.
1820
+ # # override_phrase: exact comment text an operator posts to bypass a BLOCK.
1821
+ # # Used by: test-gate (Phase 7 Verdict), review-pr-staging (Phase 6.5)
1822
+ # #
1823
+ # # test_gate:
1824
+ # # posture: "blocking"
1825
+ # # override_phrase: "OVERRIDE: shipping with test failures \u2014"
1826
+ `;
1827
+
1828
+ atomicWriteFile(outputPath, content);
1829
+
1830
+ log(` ${GREEN}Created${RESET}: forge.yaml`);
1831
+ log("");
1832
+
1833
+ if (fromInstall) {
1834
+ // Called automatically from install() — only print what still needs attention.
1835
+ // NOTE: log() is a no-op when fromInstall=true (stdout suppressed to avoid
1836
+ // interleaving with TUI spinner on stderr). If git remote was not detected,
1837
+ // the user will see the incomplete placeholder values in forge.yaml directly. (#812)
1838
+ if (!remoteDetected) {
1839
+ log(`${YELLOW}Action required:${RESET}`);
1840
+ log(
1841
+ ` Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET} (git remote not detected)`,
1842
+ );
1843
+ log("");
1844
+ }
1845
+ } else {
1846
+ // Called explicitly via `npx forgedock init` — print full next steps
1847
+ log(`${BOLD}Next steps:${RESET}`);
1848
+ if (!remoteDetected) {
1849
+ log(
1850
+ ` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
1851
+ );
1852
+ } else {
1853
+ log(
1854
+ ` 1. Review ${CYAN}forge.yaml${RESET} — all required fields were auto-detected`,
1855
+ );
1856
+ }
1857
+ log(
1858
+ ` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
790
1859
  );
791
- }
792
- summaryLines.push("");
793
- if (!remoteDetected) {
794
- summaryLines.push(
795
- `${yellow("→")} Edit ${cyan("forge.yaml")} — fill in ${cyan("project.owner")} and ${cyan("project.repo")}`,
1860
+ log(
1861
+ ` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
796
1862
  );
1863
+ log("");
1864
+ }
1865
+ }
1866
+
1867
+ /**
1868
+ * Mark a directory as ForgeDock-managed by removing it from the opt-out
1869
+ * registry and creating a .forgedock marker file.
1870
+ *
1871
+ * @param {string} [dir] - Directory to enable (default: process.cwd()).
1872
+ */
1873
+ async function enable(dir) {
1874
+ const targetDir = resolve(dir || process.cwd());
1875
+
1876
+ let setOptOut;
1877
+ try {
1878
+ ({ setOptOut } = await import(
1879
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1880
+ ));
1881
+ } catch (err) {
1882
+ console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
1883
+ process.exit(1);
1884
+ }
1885
+
1886
+ // Remove from opt-out registry
1887
+ try {
1888
+ await setOptOut(targetDir, false);
1889
+ } catch (err) {
1890
+ console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
1891
+ process.exit(1);
1892
+ }
1893
+
1894
+ // Create .forgedock marker file so the directory is treated as managed
1895
+ // even without a forge.yaml (user can run `npx forgedock init` next)
1896
+ const markerPath = join(targetDir, ".forgedock");
1897
+ if (!existsSync(markerPath)) {
1898
+ try {
1899
+ writeFileSync(markerPath, "", "utf-8");
1900
+ console.log(` ${GREEN}Created${RESET}: .forgedock marker in ${targetDir}`);
1901
+ } catch (err) {
1902
+ console.log(` ${YELLOW}Warning${RESET}: could not create .forgedock marker: ${err.message}`);
1903
+ }
797
1904
  } else {
798
- summaryLines.push(
799
- `${green("→")} Review ${cyan("forge.yaml")} — all required fields were auto-detected`,
1905
+ console.log(` ✔ .forgedock marker already present in ${targetDir}`);
1906
+ }
1907
+
1908
+ console.log(` ${GREEN}✔${RESET} ForgeDock ${GREEN}enabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
1909
+ console.log(` Removed from opt-out registry. Run ${CYAN}npx forgedock init${RESET} to generate forge.yaml.`);
1910
+ console.log("");
1911
+ }
1912
+
1913
+ /**
1914
+ * Opt a directory out of ForgeDock by adding it to the opt-out registry.
1915
+ * The session-start hook will be silent for this directory in future sessions.
1916
+ *
1917
+ * @param {string} [dir] - Directory to disable (default: process.cwd()).
1918
+ */
1919
+ async function disable(dir) {
1920
+ const targetDir = resolve(dir || process.cwd());
1921
+
1922
+ let setOptOut;
1923
+ try {
1924
+ ({ setOptOut } = await import(
1925
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1926
+ ));
1927
+ } catch (err) {
1928
+ console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
1929
+ process.exit(1);
1930
+ }
1931
+
1932
+ try {
1933
+ await setOptOut(targetDir, true);
1934
+ } catch (err) {
1935
+ console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
1936
+ process.exit(1);
1937
+ }
1938
+
1939
+ console.log(` ${GREEN}✔${RESET} ForgeDock ${YELLOW}disabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
1940
+ console.log(` Added to opt-out registry. The session-start hook will be silent here.`);
1941
+ console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
1942
+ console.log("");
1943
+ }
1944
+
1945
+ /**
1946
+ * Show the resolved ForgeDock state for a directory.
1947
+ * Prints human-readable state (managed-active, managed-optedout, unmanaged)
1948
+ * with relevant detail about what markers or registry entries were found.
1949
+ *
1950
+ * @param {string} [dir] - Directory to inspect (default: process.cwd()).
1951
+ */
1952
+ async function status(dir) {
1953
+ const targetDir = resolve(dir || process.cwd());
1954
+
1955
+ let resolveState;
1956
+ try {
1957
+ ({ resolveState } = await import(
1958
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1959
+ ));
1960
+ } catch (err) {
1961
+ console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
1962
+ process.exit(1);
1963
+ }
1964
+
1965
+ let state;
1966
+ try {
1967
+ state = resolveState(targetDir);
1968
+ } catch (err) {
1969
+ console.error(`${RED}Error resolving state: ${err.message}${RESET}`);
1970
+ process.exit(1);
1971
+ }
1972
+
1973
+ const hasForgeYaml = existsSync(join(targetDir, "forge.yaml"));
1974
+ const hasMarker = existsSync(join(targetDir, ".forgedock"));
1975
+
1976
+ console.log("");
1977
+ console.log(`${BOLD}ForgeDock Status${RESET}`);
1978
+ console.log(` Directory: ${CYAN}${targetDir}${RESET}`);
1979
+ console.log("");
1980
+
1981
+ switch (state) {
1982
+ case "managed-active":
1983
+ console.log(` State: ${GREEN}managed-active${RESET}`);
1984
+ console.log(` ForgeDock is active in this directory.`);
1985
+ if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
1986
+ if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
1987
+ console.log(` The session-start hook will inject context on session start.`);
1988
+ console.log(` To disable: ${CYAN}npx forgedock disable${RESET}`);
1989
+ break;
1990
+
1991
+ case "managed-optedout":
1992
+ console.log(` State: ${YELLOW}managed-optedout${RESET}`);
1993
+ console.log(` ForgeDock is installed but opted out for this directory.`);
1994
+ if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
1995
+ if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
1996
+ console.log(` The session-start hook is silent here (opt-out registry entry present).`);
1997
+ console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
1998
+ break;
1999
+
2000
+ case "unmanaged":
2001
+ default:
2002
+ console.log(` State: ${RED}unmanaged${RESET}`);
2003
+ console.log(` ForgeDock is not active in this directory.`);
2004
+ console.log(` No forge.yaml or .forgedock marker found.`);
2005
+ console.log(` To enable: ${CYAN}npx forgedock enable${RESET}`);
2006
+ console.log(` To set up: ${CYAN}npx forgedock init${RESET}`);
2007
+ break;
2008
+ }
2009
+ console.log("");
2010
+ }
2011
+
2012
+ /**
2013
+ * Run installation health checks and report pass/fail for each.
2014
+ *
2015
+ * Checks (in order):
2016
+ * 1. Command symlinks — TARGET_DIR entries point to correct COMMANDS_DIR files
2017
+ * 2. gh CLI installed and authenticated
2018
+ * 3. git configured (user.name + user.email)
2019
+ * 4. forge.yaml exists and has required keys
2020
+ * 5. SessionStart hook registered in ~/.claude/settings.json
2021
+ * 6. CLAUDE.md has the ForgeDock behavioral block (cwd)
2022
+ * 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
2023
+ * 8. FORGE_HOME environment variable is set
2024
+ * 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
2025
+ *
2026
+ * Exits with code 0 if all checks pass, code 1 if any fail.
2027
+ */
2028
+ async function doctor() {
2029
+ console.log("");
2030
+ console.log(`${BOLD}ForgeDock Doctor${RESET} — Installation Health Check`);
2031
+ console.log("");
2032
+
2033
+ let failures = 0;
2034
+ let warnings = 0;
2035
+
2036
+ /**
2037
+ * Print a pass line.
2038
+ * @param {string} label
2039
+ * @param {string} [detail]
2040
+ */
2041
+ function pass(label, detail) {
2042
+ const suffix = detail ? ` ${detail}` : "";
2043
+ console.log(` ${GREEN}✔${RESET} ${label}${suffix}`);
2044
+ }
2045
+
2046
+ /**
2047
+ * Print a fail line with a remediation hint.
2048
+ * @param {string} label
2049
+ * @param {string} hint
2050
+ */
2051
+ function fail(label, hint) {
2052
+ failures++;
2053
+ console.log(` ${RED}✗${RESET} ${BOLD}${label}${RESET}`);
2054
+ console.log(` Fix: ${hint}`);
2055
+ }
2056
+
2057
+ /**
2058
+ * Print a warning line (informational, does not count as failure).
2059
+ * @param {string} label
2060
+ * @param {string} hint
2061
+ */
2062
+ function warn(label, hint) {
2063
+ warnings++;
2064
+ console.log(` ${YELLOW}⚠${RESET} ${label}`);
2065
+ console.log(` Note: ${hint}`);
2066
+ }
2067
+
2068
+ // ── Check 1: Command symlinks ──────────────────────────────────────────────
2069
+ {
2070
+ let symlinkOk = true;
2071
+ let checked = 0;
2072
+ let broken = 0;
2073
+ const brokenLinks = [];
2074
+
2075
+ try {
2076
+ // Collect all .md files from COMMANDS_DIR recursively
2077
+ const collectMd = async (dir) => {
2078
+ const results = [];
2079
+ let entries;
2080
+ try {
2081
+ entries = await readdir(dir, { withFileTypes: true });
2082
+ } catch {
2083
+ return results;
2084
+ }
2085
+ for (const entry of entries) {
2086
+ const full = join(dir, entry.name);
2087
+ if (entry.isDirectory()) {
2088
+ results.push(...(await collectMd(full)));
2089
+ } else if (entry.name.endsWith(".md")) {
2090
+ results.push(full);
2091
+ }
2092
+ }
2093
+ return results;
2094
+ };
2095
+
2096
+ const sourceFiles = await collectMd(COMMANDS_DIR);
2097
+
2098
+ for (const src of sourceFiles) {
2099
+ const rel = relative(COMMANDS_DIR, src);
2100
+ const tgt = join(TARGET_DIR, rel);
2101
+ checked++;
2102
+ try {
2103
+ const lstats = await lstat(tgt);
2104
+ if (lstats.isSymbolicLink()) {
2105
+ const dest = await readlink(tgt);
2106
+ if (dest !== src) {
2107
+ broken++;
2108
+ brokenLinks.push(rel);
2109
+ symlinkOk = false;
2110
+ }
2111
+ } else {
2112
+ // Regular file — not a symlink (install left a copy)
2113
+ broken++;
2114
+ brokenLinks.push(`${rel} (not a symlink)`);
2115
+ symlinkOk = false;
2116
+ }
2117
+ } catch {
2118
+ // Symlink missing
2119
+ broken++;
2120
+ brokenLinks.push(`${rel} (missing)`);
2121
+ symlinkOk = false;
2122
+ }
2123
+ }
2124
+
2125
+ if (symlinkOk) {
2126
+ pass("Command symlinks", `${checked} symlinks valid`);
2127
+ } else {
2128
+ fail(
2129
+ "Command symlinks",
2130
+ `Run: npx forgedock install (${broken}/${checked} broken: ${brokenLinks.slice(0, 3).join(", ")}${brokenLinks.length > 3 ? "…" : ""})`,
2131
+ );
2132
+ }
2133
+ } catch (err) {
2134
+ fail("Command symlinks", `Could not read commands directory: ${err.message}`);
2135
+ }
2136
+ }
2137
+
2138
+ // ── Check 2: gh CLI installed + authenticated ──────────────────────────────
2139
+ let ghAvailable = false;
2140
+ {
2141
+ let ghInstalled = false;
2142
+ let ghAuthed = false;
2143
+ try {
2144
+ execSync("gh --version", { stdio: ["ignore", "pipe", "ignore"] });
2145
+ ghInstalled = true;
2146
+ } catch {
2147
+ // gh not installed
2148
+ }
2149
+
2150
+ if (!ghInstalled) {
2151
+ fail("gh CLI", "Install the GitHub CLI: https://cli.github.com/");
2152
+ } else {
2153
+ try {
2154
+ execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"] });
2155
+ ghAuthed = true;
2156
+ } catch {
2157
+ // Not authenticated
2158
+ }
2159
+
2160
+ if (ghAuthed) {
2161
+ pass("gh CLI", "installed and authenticated");
2162
+ ghAvailable = true;
2163
+ } else {
2164
+ fail("gh CLI", "Run: gh auth login");
2165
+ }
2166
+ }
2167
+ }
2168
+
2169
+ // ── Check 3: git configured ────────────────────────────────────────────────
2170
+ {
2171
+ let gitName = "";
2172
+ let gitEmail = "";
2173
+ try {
2174
+ gitName = execSync("git config --global user.name", {
2175
+ stdio: ["ignore", "pipe", "ignore"],
2176
+ encoding: "utf-8",
2177
+ }).trim();
2178
+ } catch {
2179
+ /* not set */
2180
+ }
2181
+ try {
2182
+ gitEmail = execSync("git config --global user.email", {
2183
+ stdio: ["ignore", "pipe", "ignore"],
2184
+ encoding: "utf-8",
2185
+ }).trim();
2186
+ } catch {
2187
+ /* not set */
2188
+ }
2189
+
2190
+ if (gitName && gitEmail) {
2191
+ pass("git config", `user.name="${gitName}", user.email="${gitEmail}"`);
2192
+ } else {
2193
+ const missing = [
2194
+ !gitName && "user.name",
2195
+ !gitEmail && "user.email",
2196
+ ].filter(Boolean).join(", ");
2197
+ fail(
2198
+ "git config",
2199
+ `Run: git config --global user.name "Your Name" and git config --global user.email "you@example.com" (missing: ${missing})`,
2200
+ );
2201
+ }
2202
+ }
2203
+
2204
+ // ── Check 4: forge.yaml exists + has required keys ─────────────────────────
2205
+ let forgeOwner = null;
2206
+ let forgeRepo = null;
2207
+ {
2208
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
2209
+ if (!existsSync(forgeYamlPath)) {
2210
+ warn(
2211
+ "forge.yaml",
2212
+ "No forge.yaml in current directory. Run: npx forgedock init",
2213
+ );
2214
+ } else {
2215
+ let content = "";
2216
+ try {
2217
+ content = readFileSync(forgeYamlPath, "utf-8");
2218
+ } catch (err) {
2219
+ fail("forge.yaml", `Cannot read forge.yaml: ${err.message}`);
2220
+ }
2221
+
2222
+ if (content) {
2223
+ const hasProject = /^project:/m.test(content);
2224
+ const hasPaths = /^paths:/m.test(content);
2225
+ const hasBranches = /^branches:/m.test(content);
2226
+ const missing = [
2227
+ !hasProject && "project",
2228
+ !hasPaths && "paths",
2229
+ !hasBranches && "branches",
2230
+ ].filter(Boolean);
2231
+
2232
+ if (missing.length === 0) {
2233
+ pass("forge.yaml", "exists with required keys");
2234
+ // Extract owner and repo for the label check (simple regex, no YAML parser needed)
2235
+ const ownerMatch = content.match(/^\s+owner:\s+"?([^"\n]+)"?\s*$/m);
2236
+ const repoMatch = content.match(/^\s+repo:\s+"?([^"\n]+)"?\s*$/m);
2237
+ if (ownerMatch) forgeOwner = ownerMatch[1].trim();
2238
+ if (repoMatch) forgeRepo = repoMatch[1].trim();
2239
+ } else {
2240
+ fail("forge.yaml", `Missing required keys: ${missing.join(", ")}. Edit forge.yaml or run: npx forgedock init`);
2241
+ }
2242
+ }
2243
+ }
2244
+ }
2245
+
2246
+ // ── Check 5: SessionStart hook registered ─────────────────────────────────
2247
+ {
2248
+ try {
2249
+ const settings = readClaudeSettings();
2250
+ const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
2251
+ ? settings.hooks.SessionStart
2252
+ : [];
2253
+ const hookPresent = sessionStartEntries.some((entry) => {
2254
+ if (!entry || typeof entry !== "object") return false;
2255
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
2256
+ return hooks.some(
2257
+ (h) =>
2258
+ h &&
2259
+ typeof h.command === "string" &&
2260
+ isForgeSessionStartHook(h.command),
2261
+ );
2262
+ });
2263
+
2264
+ if (hookPresent) {
2265
+ pass("SessionStart hook", "registered in ~/.claude/settings.json");
2266
+ } else {
2267
+ fail(
2268
+ "SessionStart hook",
2269
+ "Run: npx forgedock install (writes hook entry to ~/.claude/settings.json)",
2270
+ );
2271
+ }
2272
+ } catch (err) {
2273
+ fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${err.message}. Run: npx forgedock install`);
2274
+ }
2275
+ }
2276
+
2277
+ // ── Check 6: CLAUDE.md has ForgeDock block (cwd) ──────────────────────────
2278
+ {
2279
+ const claudeMdPath = join(process.cwd(), "CLAUDE.md");
2280
+ if (!existsSync(claudeMdPath)) {
2281
+ warn(
2282
+ "CLAUDE.md behavioral block",
2283
+ `No CLAUDE.md found in ${process.cwd()}. Run: npx forgedock install (from your project directory)`,
2284
+ );
2285
+ } else {
2286
+ try {
2287
+ const content = readFileSync(claudeMdPath, "utf-8");
2288
+ if (
2289
+ content.includes(CLAUDE_BLOCK_BEGIN) &&
2290
+ content.includes(CLAUDE_BLOCK_END)
2291
+ ) {
2292
+ pass("CLAUDE.md behavioral block", `found in ${claudeMdPath}`);
2293
+ } else {
2294
+ fail(
2295
+ "CLAUDE.md behavioral block",
2296
+ `Run: npx forgedock install (from ${process.cwd()}) to inject the ForgeDock pipeline rules block`,
2297
+ );
2298
+ }
2299
+ } catch (err) {
2300
+ fail("CLAUDE.md behavioral block", `Cannot read CLAUDE.md: ${err.message}`);
2301
+ }
2302
+ }
2303
+ }
2304
+
2305
+ // ── Check 7: Required workflow labels on GitHub repo ───────────────────────
2306
+ {
2307
+ if (!forgeOwner || !forgeRepo) {
2308
+ warn(
2309
+ "GitHub workflow labels",
2310
+ "Skipped — could not resolve owner/repo from forge.yaml",
2311
+ );
2312
+ } else if (!ghAvailable) {
2313
+ warn(
2314
+ "GitHub workflow labels",
2315
+ "Skipped — gh CLI not authenticated (see check 2 above)",
2316
+ );
2317
+ } else {
2318
+ // Read expected labels from labels.json (co-located in bin/)
2319
+ const labelsJsonPath = join(__dirname, "labels.json");
2320
+ let expectedLabels = [];
2321
+ let labelsJsonOk = false;
2322
+ try {
2323
+ const labelsRaw = readFileSync(labelsJsonPath, "utf-8");
2324
+ const allLabels = JSON.parse(labelsRaw);
2325
+ expectedLabels = allLabels
2326
+ .filter((l) => l.name.startsWith("workflow:"))
2327
+ .map((l) => l.name);
2328
+ labelsJsonOk = true;
2329
+ } catch {
2330
+ warn(
2331
+ "GitHub workflow labels",
2332
+ "Could not read bin/labels.json — skipping label check",
2333
+ );
2334
+ }
2335
+
2336
+ if (labelsJsonOk && expectedLabels.length > 0) {
2337
+ let existingLabels = [];
2338
+ let ghLabelOk = false;
2339
+ try {
2340
+ const out = execFileSync(
2341
+ "gh",
2342
+ ["label", "list", "-R", `${forgeOwner}/${forgeRepo}`, "--json", "name", "--limit", "200"],
2343
+ { stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" },
2344
+ );
2345
+ const parsed = JSON.parse(out);
2346
+ existingLabels = parsed.map((l) => l.name);
2347
+ ghLabelOk = true;
2348
+ } catch {
2349
+ warn(
2350
+ "GitHub workflow labels",
2351
+ `Could not fetch labels from ${forgeOwner}/${forgeRepo}. Ensure gh is authenticated and repo is accessible.`,
2352
+ );
2353
+ }
2354
+
2355
+ if (ghLabelOk) {
2356
+ const missingLabels = expectedLabels.filter(
2357
+ (l) => !existingLabels.includes(l),
2358
+ );
2359
+ if (missingLabels.length === 0) {
2360
+ pass(
2361
+ "GitHub workflow labels",
2362
+ `all ${expectedLabels.length} workflow labels present on ${forgeOwner}/${forgeRepo}`,
2363
+ );
2364
+ } else {
2365
+ fail(
2366
+ "GitHub workflow labels",
2367
+ `Run: npx forgedock labels setup (missing: ${missingLabels.join(", ")})`,
2368
+ );
2369
+ }
2370
+ }
2371
+ }
2372
+ }
2373
+ }
2374
+
2375
+ // ── Check 8: FORGE_HOME environment variable ───────────────────────────────
2376
+ {
2377
+ const envForgeHome = process.env.FORGE_HOME;
2378
+ if (envForgeHome) {
2379
+ pass("FORGE_HOME env var", `set to "${envForgeHome}"`);
2380
+ } else {
2381
+ fail(
2382
+ "FORGE_HOME env var",
2383
+ `Add to your shell profile: export FORGE_HOME="${FORGE_HOME}" then restart your shell (or run: source ~/.bashrc)`,
2384
+ );
2385
+ }
2386
+ }
2387
+
2388
+ // ── Check 9: Playwright MCP registered ────────────────────────────────────
2389
+ // Playwright MCP is a guaranteed ForgeDock dependency for browser automation
2390
+ // commands (/qa-sweep). This check is advisory (warn, not fail) because MCP
2391
+ // server presence is orthogonal to the core ForgeDock install — but missing
2392
+ // it causes /qa-sweep to silently fail mid-sweep.
2393
+ {
2394
+ const mcpServersPath = join(HOME, ".claude", "mcp_servers.json");
2395
+ let playwrightFound = false;
2396
+ let fileReadable = false;
2397
+ let hasMcpServers = false;
2398
+
2399
+ try {
2400
+ const mcpRaw = readFileSync(mcpServersPath, "utf-8");
2401
+ const mcpConfig = JSON.parse(mcpRaw);
2402
+ // Read + parse succeeded — the file exists and contains valid JSON.
2403
+ fileReadable = true;
2404
+ const servers = mcpConfig?.mcpServers;
2405
+ if (servers && typeof servers === "object") {
2406
+ hasMcpServers = true;
2407
+ // Search for a registered server whose name or command references playwright
2408
+ for (const [name, entry] of Object.entries(servers)) {
2409
+ const nameLower = name.toLowerCase();
2410
+ const cmdStr = [
2411
+ entry?.command ?? "",
2412
+ ...(Array.isArray(entry?.args) ? entry.args : []),
2413
+ ].join(" ").toLowerCase();
2414
+ if (nameLower.includes("playwright") || cmdStr.includes("playwright")) {
2415
+ playwrightFound = true;
2416
+ break;
2417
+ }
2418
+ }
2419
+ }
2420
+ } catch {
2421
+ // File absent or unreadable — treat as no MCP servers configured
2422
+ }
2423
+
2424
+ if (!fileReadable) {
2425
+ warn(
2426
+ "Playwright MCP",
2427
+ "No MCP servers file found (~/.claude/mcp_servers.json). Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest",
2428
+ );
2429
+ } else if (!hasMcpServers) {
2430
+ warn(
2431
+ "Playwright MCP",
2432
+ "MCP servers file found (~/.claude/mcp_servers.json) but it has no 'mcpServers' key. Register Playwright MCP to enable /qa-sweep: claude mcp add playwright npx @playwright/mcp@latest",
2433
+ );
2434
+ } else if (playwrightFound) {
2435
+ pass("Playwright MCP", "registered in Claude Code MCP servers");
2436
+ } else {
2437
+ warn(
2438
+ "Playwright MCP",
2439
+ "Not registered — /qa-sweep and browser automation commands will fail. Run: claude mcp add playwright npx @playwright/mcp@latest",
2440
+ );
2441
+ }
2442
+ }
2443
+
2444
+ // ── Summary ────────────────────────────────────────────────────────────────
2445
+ console.log("");
2446
+ if (failures === 0 && warnings === 0) {
2447
+ console.log(`${GREEN}${BOLD}All checks passed.${RESET} ForgeDock installation is healthy.`);
2448
+ } else if (failures === 0) {
2449
+ console.log(`${YELLOW}${BOLD}${warnings} warning(s).${RESET} Checks passed with notes above.`);
2450
+ } else {
2451
+ console.log(
2452
+ `${RED}${BOLD}${failures} check(s) failed${warnings > 0 ? `, ${warnings} warning(s)` : ""}.${RESET} See fix hints above.`,
800
2453
  );
801
2454
  }
802
- summaryLines.push(
803
- `${green("→")} Add ${cyan("forge.yaml")} to ${cyan(".gitignore")} if it contains sensitive paths`,
804
- );
805
- summaryLines.push(
806
- `${green("→")} Run ${cyan("/forgedock-init")} inside Claude Code for guided AI-powered setup`,
807
- );
2455
+ console.log("");
808
2456
 
809
- process.stderr.write(
810
- box(summaryLines, { title: "forge.yaml Generated" }) + "\n",
811
- );
2457
+ if (failures > 0) {
2458
+ process.exit(1);
2459
+ }
812
2460
  }
813
2461
 
814
2462
  function help() {
@@ -820,6 +2468,10 @@ function help() {
820
2468
  ["npx forgedock init", "Generate forge.yaml config for your project"],
821
2469
  ["npx forgedock uninstall", "Remove commands"],
822
2470
  ["npx forgedock update", "Pull latest & reinstall"],
2471
+ ["npx forgedock enable [dir]", "Mark directory as ForgeDock-managed"],
2472
+ ["npx forgedock disable [dir]", "Opt directory out of ForgeDock"],
2473
+ ["npx forgedock status [dir]", "Show resolved state for a directory"],
2474
+ ["npx forgedock doctor", "Check installation health"],
823
2475
  ["npx forgedock help", "Show this help"],
824
2476
  ];
825
2477
 
@@ -843,6 +2495,18 @@ switch (command) {
843
2495
  case "update":
844
2496
  await update();
845
2497
  break;
2498
+ case "enable":
2499
+ await enable(args[1]);
2500
+ break;
2501
+ case "disable":
2502
+ await disable(args[1]);
2503
+ break;
2504
+ case "status":
2505
+ await status(args[1]);
2506
+ break;
2507
+ case "doctor":
2508
+ await doctor();
2509
+ break;
846
2510
  case "help":
847
2511
  case "--help":
848
2512
  case "-h":