forgedock 1.0.15 → 1.0.17

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 (71) hide show
  1. package/README.md +82 -60
  2. package/bin/forgedock.mjs +1992 -540
  3. package/bin/github-app-manifest.json.license +2 -0
  4. package/bin/hooks/session-start.mjs +4 -5
  5. package/bin/registry.mjs +20 -0
  6. package/bin/tui.mjs +172 -96
  7. package/commands/adopt.md +433 -0
  8. package/commands/analytics.md +2 -0
  9. package/commands/audit-agents.md +2 -0
  10. package/commands/audit.md +2 -0
  11. package/commands/changelog.md +354 -0
  12. package/commands/cleanup.md +2 -0
  13. package/commands/compat-audit.md +330 -0
  14. package/commands/deploy-info.md +6 -5
  15. package/commands/diagnose.md +483 -0
  16. package/commands/explain.md +349 -0
  17. package/commands/forge-stats.md +2 -0
  18. package/commands/forgedock-init.md +2 -0
  19. package/commands/geo-audit.md +2 -0
  20. package/commands/incident-response.md +2 -0
  21. package/commands/issue.md +2 -0
  22. package/commands/milestone.md +2 -0
  23. package/commands/optimize.md +411 -0
  24. package/commands/orchestrate.md +118 -0
  25. package/commands/pipeline-health.md +60 -1
  26. package/commands/qa-sweep.md +2 -0
  27. package/commands/quality-gate.md +37 -0
  28. package/commands/replay.md +263 -0
  29. package/commands/resume.md +264 -0
  30. package/commands/review-pr-agents.md +5 -1
  31. package/commands/review-pr-staging.md +2 -0
  32. package/commands/review-pr.md +85 -3
  33. package/commands/rollback.md +2 -0
  34. package/commands/scope.md +280 -0
  35. package/commands/security-audit.md +2 -0
  36. package/commands/status.md +262 -0
  37. package/commands/sync-ecosystem.md +2 -0
  38. package/commands/validate.md +2 -0
  39. package/commands/work-on/build/architect.md +128 -18
  40. package/commands/work-on/build/context.md +130 -17
  41. package/commands/work-on/build/implement.md +2 -0
  42. package/commands/work-on/build/validate.md +2 -0
  43. package/commands/work-on/build.md +32 -7
  44. package/commands/work-on/close.md +85 -0
  45. package/commands/work-on/decompose.md +2 -0
  46. package/commands/work-on/investigate.md +30 -3
  47. package/commands/work-on/review.md +13 -1
  48. package/commands/work-on-monolithic.md +2 -0
  49. package/commands/work-on.md +624 -36
  50. package/docs/BRAND.md +51 -0
  51. package/docs/CONFIG.md +94 -0
  52. package/docs/articles/per-repo-adaptive-scripts-token-savings.md +174 -0
  53. package/docs/claude-breakpoints.json +47 -0
  54. package/docs/site/getting-started.md +21 -0
  55. package/package.json +10 -2
  56. package/scripts/classify-lane.sh +145 -0
  57. package/scripts/transition-label.sh +161 -0
  58. package/scripts/validate-pr-target.sh +30 -0
  59. package/templates/devdocs/README.md +72 -0
  60. package/templates/devdocs/agent/using-forgedock.md +3 -0
  61. package/templates/devdocs/agent/using-github.md +3 -0
  62. package/templates/devdocs/index.yaml +162 -0
  63. package/templates/devdocs/project/architecture.md +3 -0
  64. package/templates/devdocs/project/conventions.md +3 -0
  65. package/templates/devdocs/project/custom-instructions.md +3 -0
  66. package/templates/devdocs/project/glossary.md +3 -0
  67. package/templates/devdocs/project/stack.md +3 -0
  68. package/templates/devdocs/templates/commit-convention.md +3 -0
  69. package/templates/devdocs/templates/issue.md +3 -0
  70. package/templates/devdocs/templates/pull-request.md +3 -0
  71. package/templates/devdocs/templates/review-finding.md +3 -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,416 @@ 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
+ i += 2;
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
+ j += 2;
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
+ * Install the ForgeDock SessionStart hook into ~/.claude/settings.json
326
+ * idempotently. Does not modify any existing hooks unrelated to ForgeDock.
327
+ *
328
+ * Returns:
329
+ * 'installed' — hook was newly added
330
+ * 'already-present' — hook was already there (idempotent)
331
+ * 'failed' — an error occurred (non-fatal; install continues)
332
+ *
333
+ * @returns {Promise<'installed' | 'already-present' | 'failed'>}
334
+ */
335
+ async function installSessionStartHook() {
336
+ try {
337
+ const settings = readClaudeSettings();
338
+
339
+ // Ensure the hooks section exists
340
+ if (!settings.hooks || typeof settings.hooks !== "object") {
341
+ settings.hooks = {};
342
+ }
343
+
344
+ // Ensure SessionStart array exists
345
+ if (!Array.isArray(settings.hooks.SessionStart)) {
346
+ settings.hooks.SessionStart = [];
347
+ }
348
+
349
+ const cmd = sessionStartHookCommand();
350
+
351
+ // Check if already present — match by path suffix so uninstall/reinstall
352
+ // across different FORGE_HOME paths still works correctly. (ref: forge#537)
353
+ const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
354
+ if (!entry || typeof entry !== "object") return false;
355
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
356
+ return hooks.some(
357
+ (h) =>
358
+ h &&
359
+ typeof h.command === "string" &&
360
+ isForgeSessionStartHook(h.command),
361
+ );
362
+ });
363
+
364
+ if (alreadyPresent) return "already-present";
365
+
366
+ // Append the new hook entry (matcher omitted — fires on every session start)
367
+ settings.hooks.SessionStart.push({
368
+ hooks: [
369
+ {
370
+ type: "command",
371
+ command: cmd,
372
+ timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
373
+ },
374
+ ],
375
+ });
376
+
377
+ writeClaudeSettings(settings);
378
+ return "installed";
379
+ } catch {
380
+ return "failed";
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
386
+ * idempotently. Only removes the entry written by installSessionStartHook().
387
+ * Unrelated hooks are preserved.
388
+ *
389
+ * Returns:
390
+ * 'removed' — hook was found and removed
391
+ * 'not-present' — hook was not in settings.json (already clean)
392
+ * 'failed' — an error occurred (non-fatal; uninstall continues)
393
+ *
394
+ * @returns {Promise<'removed' | 'not-present' | 'failed'>}
395
+ */
396
+ async function removeSessionStartHook() {
397
+ try {
398
+ const settings = readClaudeSettings();
399
+
400
+ if (
401
+ !settings.hooks ||
402
+ !Array.isArray(settings.hooks.SessionStart) ||
403
+ settings.hooks.SessionStart.length === 0
404
+ ) {
405
+ return "not-present";
406
+ }
407
+
408
+ const originalLength = settings.hooks.SessionStart.length;
409
+
410
+ // Filter out the ForgeDock-managed entry, keep everything else
411
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
412
+ (entry) => {
413
+ if (!entry || typeof entry !== "object") return true;
414
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
415
+ const isForgeEntry = hooks.some(
416
+ (h) =>
417
+ h &&
418
+ typeof h.command === "string" &&
419
+ isForgeSessionStartHook(h.command),
420
+ );
421
+ return !isForgeEntry;
422
+ },
423
+ );
424
+
425
+ if (settings.hooks.SessionStart.length === originalLength) {
426
+ return "not-present";
427
+ }
428
+
429
+ // Clean up empty SessionStart array to leave settings.json tidy
430
+ if (settings.hooks.SessionStart.length === 0) {
431
+ delete settings.hooks.SessionStart;
432
+ }
433
+
434
+ // Clean up empty hooks object
435
+ if (
436
+ settings.hooks &&
437
+ typeof settings.hooks === "object" &&
438
+ Object.keys(settings.hooks).length === 0
439
+ ) {
440
+ delete settings.hooks;
441
+ }
442
+
443
+ writeClaudeSettings(settings);
444
+ return "removed";
445
+ } catch {
446
+ return "failed";
447
+ }
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Version + splash — read package.json version and render branded logo
54
452
  // ---------------------------------------------------------------------------
55
453
 
56
454
  function getVersion() {
@@ -69,38 +467,42 @@ function splash() {
69
467
  process.stderr.write(renderLogo({ version }) + "\n");
70
468
  }
71
469
 
470
+ // ---------------------------------------------------------------------------
471
+ // CLAUDE.md / AGENTS.md managed-block injection
472
+ // ---------------------------------------------------------------------------
473
+
72
474
  /**
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).
475
+ * Marker strings that delimit the ForgeDock-managed block in CLAUDE.md / AGENTS.md.
476
+ * Must be exact — used for both detection and regex anchoring.
75
477
  */
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
- }
478
+ const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
479
+ const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
97
480
 
98
481
  /**
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).
482
+ * The behavioral rules block injected into the project's CLAUDE.md.
483
+ * Kept ≤30 lines (per issue #607 requirement).
484
+ * Content is fixed — this is NOT documentation, it is behavioral guidance.
485
+ */
486
+ const FORGEDOCK_MANAGED_BLOCK = `${CLAUDE_BLOCK_BEGIN}
487
+ ## ForgeDock Pipeline Rules
488
+
489
+ This project uses ForgeDock for structured development. Follow these rules:
490
+
491
+ 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.
492
+ 2. **Pipeline flow**: All implementation goes through \`/work-on <issue#>\`. This runs: investigate → architect → build → review → merge.
493
+ 3. **Traceability**: Every code change must link to a GitHub issue. Use FORGE annotations to pass context between pipeline phases.
494
+ 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.
495
+ 5. **Available commands**: \`/work-on\`, \`/issue\`, \`/review-pr\`, \`/orchestrate\`, \`/quality-gate\`, \`/milestone\`, \`/autopilot\`
496
+
497
+ For full documentation, see: [ForgeDock docs](https://forgedock.com/docs)
498
+ ${CLAUDE_BLOCK_END}`;
499
+
500
+ /**
501
+ * Returns true if `dir` is inside a git work tree.
502
+ * Used to guard CLAUDE.md injection against non-project directories.
503
+ *
504
+ * @param {string} dir
505
+ * @returns {boolean}
104
506
  */
105
507
  function isGitWorkTree(dir) {
106
508
  try {
@@ -115,6 +517,178 @@ function isGitWorkTree(dir) {
115
517
  }
116
518
  }
117
519
 
520
+ /**
521
+ * Idempotently inject the ForgeDock managed block into a single file.
522
+ *
523
+ * Handles all corrupt marker states (ref: forge#269, forge#291):
524
+ * - File missing → create file containing block only
525
+ * - No markers → append block to existing content
526
+ * - BEGIN + END (correct) → replace content between markers
527
+ * - BEGIN only → strip orphaned BEGIN, re-append full block
528
+ * - END only → strip orphaned END, re-append full block
529
+ * - END before BEGIN → strip both + content between, re-append full block
530
+ * - Block already current → return 'unchanged'
531
+ *
532
+ * Returns:
533
+ * 'created' — file was created with block
534
+ * 'updated' — existing block was replaced with new content
535
+ * 'unchanged' — block was already present and current
536
+ * 'appended' — block was appended (no prior markers)
537
+ *
538
+ * @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
539
+ * @returns {'created' | 'updated' | 'unchanged' | 'appended'}
540
+ */
541
+ function injectManagedBlock(filePath) {
542
+ const managed = FORGEDOCK_MANAGED_BLOCK;
543
+
544
+ // File does not exist — create it with the block
545
+ if (!existsSync(filePath)) {
546
+ atomicWriteFile(filePath, managed + "\n");
547
+ return "created";
548
+ }
549
+
550
+ let existing = readFileSync(filePath, "utf-8");
551
+
552
+ const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
553
+ const hasEnd = existing.includes(CLAUDE_BLOCK_END);
554
+
555
+ if (hasBegin && hasEnd) {
556
+ // Determine marker order
557
+ const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
558
+ const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
559
+
560
+ if (beginIdx < endIdx) {
561
+ // Normal order — replace content between markers (inclusive)
562
+ // Non-greedy match so we don't over-consume if someone manually
563
+ // duplicated the markers.
564
+ const blockRegex = new RegExp(
565
+ escapeRegExp(CLAUDE_BLOCK_BEGIN) +
566
+ "[\\s\\S]*?" +
567
+ escapeRegExp(CLAUDE_BLOCK_END),
568
+ );
569
+ const replaced = existing.replace(blockRegex, managed);
570
+ if (replaced === existing) {
571
+ // Regex matched but content was already identical
572
+ return "unchanged";
573
+ }
574
+ atomicWriteFile(filePath, replaced);
575
+ return "updated";
576
+ } else {
577
+ // Reversed markers (END before BEGIN) — strip both markers + everything
578
+ // between them, then fall through to append. (ref: forge#291 BUG-2)
579
+ const strippedReversed = existing
580
+ .replace(
581
+ new RegExp(
582
+ escapeRegExp(CLAUDE_BLOCK_END) +
583
+ "[\\s\\S]*?" +
584
+ escapeRegExp(CLAUDE_BLOCK_BEGIN),
585
+ ),
586
+ "",
587
+ )
588
+ // Strip any leftover isolated markers
589
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
590
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "");
591
+ existing = strippedReversed.trimEnd();
592
+ // Fall through to append
593
+ }
594
+ } else if (hasBegin) {
595
+ // Orphaned BEGIN — strip it, then fall through to append. (ref: forge#269)
596
+ existing = existing
597
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
598
+ .trimEnd();
599
+ // Fall through to append
600
+ } else if (hasEnd) {
601
+ // Orphaned END — strip it, then fall through to append. (ref: forge#291 BUG-1)
602
+ existing = existing
603
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
604
+ .trimEnd();
605
+ // Fall through to append
606
+ }
607
+
608
+ // No valid block present — append
609
+ const separator = existing.length > 0 ? "\n\n" : "";
610
+ atomicWriteFile(filePath, existing + separator + managed + "\n");
611
+ return hasBegin || hasEnd ? "updated" : "appended";
612
+ }
613
+
614
+ /**
615
+ * Remove the ForgeDock managed block from a single file.
616
+ * Strips only the marker-bounded section; all surrounding content is preserved.
617
+ * No-ops if the file doesn't exist or contains no markers.
618
+ *
619
+ * Returns:
620
+ * 'removed' — block was found and removed
621
+ * 'not-present' — no block in file (or file doesn't exist)
622
+ *
623
+ * @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
624
+ * @returns {'removed' | 'not-present'}
625
+ */
626
+ function removeManagedBlock(filePath) {
627
+ if (!existsSync(filePath)) return "not-present";
628
+
629
+ const existing = readFileSync(filePath, "utf-8");
630
+ const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
631
+ const hasEnd = existing.includes(CLAUDE_BLOCK_END);
632
+
633
+ if (!hasBegin && !hasEnd) return "not-present";
634
+
635
+ let cleaned = existing;
636
+
637
+ if (hasBegin && hasEnd) {
638
+ const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
639
+ const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
640
+
641
+ if (beginIdx < endIdx) {
642
+ // Normal block — remove it
643
+ cleaned = existing
644
+ .replace(
645
+ new RegExp(
646
+ escapeRegExp(CLAUDE_BLOCK_BEGIN) +
647
+ "[\\s\\S]*?" +
648
+ escapeRegExp(CLAUDE_BLOCK_END),
649
+ ),
650
+ "",
651
+ )
652
+ .trimEnd();
653
+ } else {
654
+ // Reversed — strip both and content between
655
+ cleaned = existing
656
+ .replace(
657
+ new RegExp(
658
+ escapeRegExp(CLAUDE_BLOCK_END) +
659
+ "[\\s\\S]*?" +
660
+ escapeRegExp(CLAUDE_BLOCK_BEGIN),
661
+ ),
662
+ "",
663
+ )
664
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
665
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
666
+ .trimEnd();
667
+ }
668
+ } else {
669
+ // Orphaned markers — strip them
670
+ cleaned = cleaned
671
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
672
+ .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
673
+ .trimEnd();
674
+ }
675
+
676
+ if (cleaned === existing.trimEnd()) return "not-present";
677
+
678
+ atomicWriteFile(filePath, cleaned.length > 0 ? cleaned + "\n" : "");
679
+ return "removed";
680
+ }
681
+
682
+ /**
683
+ * Escape a string for use inside a RegExp constructor.
684
+ *
685
+ * @param {string} str
686
+ * @returns {string}
687
+ */
688
+ function escapeRegExp(str) {
689
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
690
+ }
691
+
118
692
  async function findMarkdownFiles(dir) {
119
693
  const results = [];
120
694
  const entries = await readdir(dir, { withFileTypes: true });
@@ -130,13 +704,97 @@ async function findMarkdownFiles(dir) {
130
704
  }
131
705
 
132
706
  // ---------------------------------------------------------------------------
133
- // Shared file-linking logic — used by both install() and update()
707
+ // Shell profile FORGE_HOME removal
134
708
  // ---------------------------------------------------------------------------
135
709
 
136
710
  /**
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 }.
711
+ * The sentinel comment line written by install() immediately before the
712
+ * `export FORGE_HOME=` line. This is the reliable anchor for removal —
713
+ * it is never present in organic shell profiles.
714
+ */
715
+ const FORGE_HOME_COMMENT = "# ForgeDock — autonomous development pipeline";
716
+
717
+ /**
718
+ * Remove the ForgeDock FORGE_HOME block from a single shell profile file.
719
+ *
720
+ * install() appends exactly this 2-line block (preceded by a blank line):
721
+ *
722
+ * # ForgeDock — autonomous development pipeline
723
+ * export FORGE_HOME="<path>"
724
+ *
725
+ * This function strips that block (and any orphaned `export FORGE_HOME=...`
726
+ * lines immediately following the comment, or any standalone
727
+ * `export FORGE_HOME=...` lines that have no paired comment). All other
728
+ * content is preserved.
729
+ *
730
+ * Uses an atomic write (tmp file + rename) to avoid partial writes.
731
+ *
732
+ * Returns:
733
+ * 'removed' — block was found and removed
734
+ * 'not-present' — block not found in file (or file does not exist)
735
+ * 'failed' — write error (non-fatal; callers warn and continue)
736
+ *
737
+ * @param {string} profilePath - Absolute path to .bashrc or .zshrc
738
+ * @returns {'removed' | 'not-present' | 'failed'}
739
+ */
740
+ function removeForgeHomeFromProfile(profilePath) {
741
+ if (!existsSync(profilePath)) return "not-present";
742
+
743
+ let content;
744
+ try {
745
+ content = readFileSync(profilePath, "utf-8");
746
+ } catch {
747
+ return "failed";
748
+ }
749
+
750
+ if (!content.includes(FORGE_HOME_COMMENT) && !content.includes("export FORGE_HOME=")) {
751
+ return "not-present";
752
+ }
753
+
754
+ // Step 1: Remove the 2-line ForgeDock block (comment + export), preceded
755
+ // by an optional leading blank line that install() inserts.
756
+ // The \r? handles profiles with CRLF line endings.
757
+ let cleaned = content.replace(
758
+ /\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="[^"]*"\r?\n/g,
759
+ "\n",
760
+ );
761
+
762
+ // Step 2: Remove any orphaned `export FORGE_HOME=...` lines that may
763
+ // remain if the comment line was already manually deleted.
764
+ cleaned = cleaned.replace(/^export FORGE_HOME=.*\r?\n/gm, "");
765
+
766
+ // Step 3: Trim trailing blank lines introduced by the removal, but
767
+ // preserve a single trailing newline if the original file had one.
768
+ const hadTrailingNewline = content.endsWith("\n");
769
+ cleaned = cleaned.replace(/\n{3,}/g, "\n\n").trimEnd();
770
+ if (cleaned.length > 0 && hadTrailingNewline) {
771
+ cleaned += "\n";
772
+ }
773
+
774
+ if (cleaned === content) return "not-present";
775
+
776
+ const tmpPath = profilePath + ".forgedock.tmp";
777
+ try {
778
+ writeFileSync(tmpPath, cleaned, "utf-8");
779
+ renameSync(tmpPath, profilePath);
780
+ } catch {
781
+ try {
782
+ unlinkSync(tmpPath);
783
+ } catch {
784
+ /* already gone or never created */
785
+ }
786
+ return "failed";
787
+ }
788
+
789
+ return "removed";
790
+ }
791
+
792
+ /**
793
+ * Link (symlink) all commands from COMMANDS_DIR to TARGET_DIR.
794
+ * Called inside a runSteps() step — uses step.progress() and step.note().
795
+ *
796
+ * @param {object} step - StepAPI from runSteps
797
+ * @returns {Promise<{installed: number, updated: number, skipped: number}>}
140
798
  */
141
799
  async function linkCommands(step) {
142
800
  const files = await findMarkdownFiles(COMMANDS_DIR);
@@ -145,13 +803,14 @@ async function linkCommands(step) {
145
803
  let updated = 0;
146
804
  let skipped = 0;
147
805
 
148
- for (let idx = 0; idx < files.length; idx++) {
149
- const file = files[idx];
806
+ for (let i = 0; i < files.length; i++) {
807
+ const file = files[i];
150
808
  const rel = relative(COMMANDS_DIR, file);
151
809
  const target = join(TARGET_DIR, rel);
152
810
  const targetDir = dirname(target);
153
811
 
154
812
  await mkdir(targetDir, { recursive: true });
813
+ step.progress(i + 1, total);
155
814
 
156
815
  try {
157
816
  const stats = await lstat(target);
@@ -161,299 +820,167 @@ async function linkCommands(step) {
161
820
  if (current === file) {
162
821
  skipped++;
163
822
  } else {
164
- await symlink(file, target + ".tmp");
165
- const { rename } = await import("fs/promises");
166
- await rename(target + ".tmp", target);
167
- updated++;
823
+ try {
824
+ await symlink(file, target + ".tmp");
825
+ const { rename } = await import("fs/promises");
826
+ await rename(target + ".tmp", target);
827
+ updated++;
828
+ } catch (renameErr) {
829
+ try {
830
+ await unlink(target + ".tmp");
831
+ } catch {
832
+ /* .tmp already gone or was never created */
833
+ }
834
+ if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
835
+ // Windows without Developer Mode: symlink() throws EPERM/EACCES.
836
+ // Fall back to a direct copy (matches new-install fallback behaviour).
837
+ const { copyFile } = await import("fs/promises");
838
+ await copyFile(file, target);
839
+ updated++;
840
+ } else {
841
+ throw renameErr;
842
+ }
843
+ }
168
844
  }
169
845
  } 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
- }
846
+ // Regular file — skip; user must remove manually
847
+ skipped++;
180
848
  }
181
849
  } 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.
850
+ if (err.code !== "ENOENT") throw err;
851
+ // Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
188
852
  try {
189
853
  await symlink(file, target);
190
854
  } catch (linkErr) {
191
855
  if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
192
- copyFileSync(file, target);
856
+ const { copyFile } = await import("fs/promises");
857
+ await copyFile(file, target);
193
858
  } else {
194
859
  throw linkErr;
195
860
  }
196
861
  }
197
862
  installed++;
198
863
  }
199
-
200
- step.progress(idx + 1, total);
201
864
  }
202
865
 
203
866
  return { installed, updated, skipped };
204
867
  }
205
868
 
206
- // ---------------------------------------------------------------------------
207
- // Core forge.yaml detection + write — used by both install() and init()
208
- // ---------------------------------------------------------------------------
209
-
210
869
  /**
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}>}
870
+ * Enumerate pipeline-agent scripts in SCRIPTS_DIR that should be installed
871
+ * to ~/.claude/scripts/. Only files present in PIPELINE_SCRIPTS are returned.
872
+ * Subdirectories are not traversed — scripts/ is a flat directory.
873
+ *
874
+ * @param {string} dir - Absolute path to the scripts source directory.
875
+ * @returns {Promise<string[]>} Sorted list of absolute file paths.
219
876
  */
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 ---
877
+ async function findScriptFiles(dir) {
878
+ const results = [];
879
+ let entries;
235
880
  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
881
+ entries = await readdir(dir, { withFileTypes: true });
882
+ } catch (err) {
883
+ if (err.code === "ENOENT") return results; // scripts/ dir absent — skip silently
884
+ throw err;
260
885
  }
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"
886
+ for (const entry of entries) {
887
+ if (entry.isFile() && PIPELINE_SCRIPTS.has(entry.name)) {
888
+ results.push(join(dir, entry.name));
286
889
  }
287
890
  }
891
+ return results.sort();
892
+ }
288
893
 
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
- }
894
+ /**
895
+ * Link or copy all scripts from SCRIPTS_DIR into SCRIPTS_TARGET_DIR (~/.claude/scripts/).
896
+ * Mirrors linkCommands() behaviour: symlink where permitted, copy on EPERM/EACCES (Windows).
897
+ * Called inside a runSteps() step — uses step.progress() and step.note().
898
+ *
899
+ * @param {object} step - StepAPI from runSteps
900
+ * @returns {Promise<{installed: number, updated: number, skipped: number}>}
901
+ */
902
+ async function linkScripts(step) {
903
+ const files = await findScriptFiles(SCRIPTS_DIR);
904
+ const total = files.length;
328
905
 
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
- }
906
+ if (total === 0) {
907
+ step.skip("no scripts to link");
908
+ return { installed: 0, updated: 0, skipped: 0 };
341
909
  }
342
910
 
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"
911
+ await mkdir(SCRIPTS_TARGET_DIR, { recursive: true });
419
912
 
420
- # =============================================================================
421
- # REVIEW (OPTIONAL)
422
- # Context injected into review agent prompts.
423
- # =============================================================================
913
+ let installed = 0;
914
+ let updated = 0;
915
+ let skipped = 0;
424
916
 
425
- # review:
426
- # tech_stack: "Node.js, TypeScript, PostgreSQL"
427
- # context: |
428
- # Describe your repo structure and any unusual conventions here.
917
+ for (let idx = 0; idx < files.length; idx++) {
918
+ const file = files[idx];
919
+ const rel = relative(SCRIPTS_DIR, file);
920
+ const target = join(SCRIPTS_TARGET_DIR, rel);
429
921
 
430
- # =============================================================================
431
- # VERIFICATION (OPTIONAL)
432
- # Health-check patterns for quality gate and validate commands.
433
- # =============================================================================
922
+ step.progress(idx + 1, total);
434
923
 
435
- # verification:
436
- # health_endpoint: "https://api.${repo}.io/health"
437
- # health_patterns:
438
- # - '"status": "ok"'
439
- `;
924
+ try {
925
+ const stats = await lstat(target);
440
926
 
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
- );
927
+ if (stats.isSymbolicLink()) {
928
+ const current = await readlink(target);
929
+ if (current === file) {
930
+ skipped++;
931
+ } else {
932
+ try {
933
+ await symlink(file, target + ".tmp");
934
+ const { rename } = await import("fs/promises");
935
+ await rename(target + ".tmp", target);
936
+ updated++;
937
+ } catch (renameErr) {
938
+ try {
939
+ await unlink(target + ".tmp");
940
+ } catch {
941
+ /* .tmp already gone or was never created */
942
+ }
943
+ if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
944
+ // Windows without Developer Mode: symlink() throws EPERM/EACCES.
945
+ // Fall back to a direct copy (matches new-install fallback behaviour).
946
+ const { copyFile } = await import("fs/promises");
947
+ await copyFile(file, target);
948
+ updated++;
949
+ } else {
950
+ throw renameErr;
951
+ }
952
+ }
953
+ }
954
+ } else {
955
+ // Existing regular file — refresh if contents differ.
956
+ if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
957
+ skipped++;
958
+ } else {
959
+ const { copyFile } = await import("fs/promises");
960
+ await copyFile(file, target);
961
+ updated++;
962
+ }
963
+ }
964
+ } catch (err) {
965
+ if (err.code !== "ENOENT") throw err;
966
+ // Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
967
+ try {
968
+ await symlink(file, target);
969
+ } catch (linkErr) {
970
+ if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
971
+ const { copyFile } = await import("fs/promises");
972
+ await copyFile(file, target);
973
+ } else {
974
+ throw linkErr;
975
+ }
976
+ }
977
+ installed++;
978
+ }
448
979
  }
449
980
 
450
- return { ...detected, outputPath, wrote: true };
981
+ return { installed, updated, skipped };
451
982
  }
452
983
 
453
- // ---------------------------------------------------------------------------
454
- // Subcommands
455
- // ---------------------------------------------------------------------------
456
-
457
984
  async function install() {
458
985
  const result = await runSteps([
459
986
  {
@@ -467,25 +994,53 @@ async function install() {
467
994
  label: "Linking commands",
468
995
  async run(step) {
469
996
  const { installed, updated, skipped } = await linkCommands(step);
997
+ const total = installed + updated + skipped;
470
998
  step.note(
471
- `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
999
+ `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped (${total} commands total)`,
472
1000
  );
473
1001
  },
474
1002
  },
475
1003
  {
476
- label: "Configuring session hook",
1004
+ label: "Linking scripts",
1005
+ async run(step) {
1006
+ const { installed, updated, skipped } = await linkScripts(step);
1007
+ if (installed + updated + skipped > 0) {
1008
+ step.note(
1009
+ `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
1010
+ );
1011
+ }
1012
+ },
1013
+ },
1014
+ {
1015
+ label: "Configuring shell profile",
477
1016
  async run(step) {
1017
+ // Set FORGE_HOME in shell profiles
478
1018
  let profileUpdated = false;
479
1019
  for (const profile of [
480
- join(process.env.HOME, ".bashrc"),
481
- join(process.env.HOME, ".zshrc"),
1020
+ join(HOME, ".bashrc"),
1021
+ join(HOME, ".zshrc"),
482
1022
  ]) {
483
1023
  if (existsSync(profile)) {
484
1024
  const content = readFileSync(profile, "utf-8");
485
- if (!content.includes("FORGE_HOME")) {
1025
+ // Escape shell metacharacters in FORGE_HOME before embedding it in
1026
+ // a double-quoted shell assignment. A crafted install path
1027
+ // containing $(...), backticks, or " characters would otherwise
1028
+ // inject arbitrary shell commands executed on the user's next login.
1029
+ // shellEscapeDoubleQuotedPath() applies the same escaping used for
1030
+ // the SessionStart hook command (ref: forge#808, forge#792, forge#451).
1031
+ const safeForgeHome = shellEscapeDoubleQuotedPath(FORGE_HOME);
1032
+ const exactExport = `export FORGE_HOME="${safeForgeHome}"`;
1033
+ if (content.includes(exactExport)) {
1034
+ // Already set to the current path — no update needed.
1035
+ } else {
1036
+ // Either not present, or set to a stale path — refresh it.
1037
+ if (content.includes("FORGE_HOME")) {
1038
+ // Strip the old block before appending the updated one.
1039
+ removeForgeHomeFromProfile(profile);
1040
+ }
486
1041
  appendFileSync(
487
1042
  profile,
488
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
1043
+ `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${safeForgeHome}"\n`,
489
1044
  );
490
1045
  profileUpdated = true;
491
1046
  }
@@ -496,13 +1051,23 @@ async function install() {
496
1051
  }
497
1052
  },
498
1053
  },
1054
+ {
1055
+ label: "Registering SessionStart hook",
1056
+ async run(step) {
1057
+ const hookResult = await installSessionStartHook();
1058
+ if (hookResult === "already-present") {
1059
+ step.skip("already present");
1060
+ } else if (hookResult === "failed") {
1061
+ // Non-fatal — warn but don't throw
1062
+ step.note("could not write — run npx forgedock install again to retry");
1063
+ }
1064
+ },
1065
+ },
499
1066
  {
500
1067
  label: "Generating forge.yaml",
501
1068
  async run(step) {
502
1069
  // 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.
1070
+ // Only do this inside a real git project (ref: forge#585).
506
1071
  const cwd = process.cwd();
507
1072
  const forgeYamlPath = join(cwd, "forge.yaml");
508
1073
  if (existsSync(forgeYamlPath)) {
@@ -510,19 +1075,47 @@ async function install() {
510
1075
  return;
511
1076
  }
512
1077
  if (!isGitWorkTree(cwd)) {
513
- step.skip(
514
- "not a git project — run npx forgedock init inside your project",
515
- );
1078
+ step.skip("not a git project — run npx forgedock init inside your project");
1079
+ return;
1080
+ }
1081
+ await init(true);
1082
+ },
1083
+ },
1084
+ {
1085
+ label: "Updating CLAUDE.md",
1086
+ async run(step) {
1087
+ // Inject behavioral rules into CLAUDE.md (and AGENTS.md if present).
1088
+ // Only inside a git project — guards against non-project cwd (ref: forge#585).
1089
+ const installCwd = process.cwd();
1090
+ if (!isGitWorkTree(installCwd)) {
1091
+ step.skip("not a git project");
516
1092
  return;
517
1093
  }
518
- const detected = await detectAndBuildForgeYaml(cwd, {
519
- onNote: () => {},
520
- });
521
- if (!detected.remoteDetected) {
522
- step.note("created — fill in project.owner and project.repo");
1094
+
1095
+ const claudeMdPath = join(installCwd, "CLAUDE.md");
1096
+ const agentsMdPath = join(installCwd, "AGENTS.md");
1097
+
1098
+ const claudeResult = injectManagedBlock(claudeMdPath);
1099
+ let note = "";
1100
+ if (claudeResult === "created") {
1101
+ note = "CLAUDE.md created";
1102
+ } else if (claudeResult === "updated") {
1103
+ note = "CLAUDE.md block refreshed";
1104
+ } else if (claudeResult === "appended") {
1105
+ note = "CLAUDE.md rules appended";
523
1106
  } else {
524
- step.note(`created for ${detected.owner}/${detected.repo}`);
1107
+ note = "CLAUDE.md already current";
1108
+ }
1109
+
1110
+ // Mirror to AGENTS.md only if it already exists (never create it)
1111
+ if (existsSync(agentsMdPath)) {
1112
+ const agentsResult = injectManagedBlock(agentsMdPath);
1113
+ if (agentsResult === "updated" || agentsResult === "appended") {
1114
+ note += " · AGENTS.md mirrored";
1115
+ }
525
1116
  }
1117
+
1118
+ step.note(note);
526
1119
  },
527
1120
  },
528
1121
  ]);
@@ -534,6 +1127,7 @@ async function install() {
534
1127
  `${green("✔")} ${bold("ForgeDock is ready")}`,
535
1128
  "",
536
1129
  `Commands installed to ${cyan(TARGET_DIR)}`,
1130
+ `Scripts installed to ${cyan(SCRIPTS_TARGET_DIR)}`,
537
1131
  `Next: ${cyan("cd <your-project>")} then ${cyan("npx forgedock init")}`,
538
1132
  ],
539
1133
  { title: "ForgeDock Installed" },
@@ -543,204 +1137,431 @@ async function install() {
543
1137
  }
544
1138
 
545
1139
  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;
1140
+ console.log("");
1141
+ console.log(`${BOLD}ForgeDock${RESET} — Removing pipeline commands`);
1142
+ console.log("");
553
1143
 
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);
1144
+ const files = await findMarkdownFiles(COMMANDS_DIR);
1145
+ let removed = 0;
558
1146
 
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
- }
1147
+ for (const file of files) {
1148
+ const rel = relative(COMMANDS_DIR, file);
1149
+ const target = join(TARGET_DIR, rel);
583
1150
 
584
- step.progress(idx + 1, total);
1151
+ try {
1152
+ const stats = await lstat(target);
1153
+ if (stats.isSymbolicLink()) {
1154
+ const current = await readlink(target);
1155
+ if (current === file) {
1156
+ const { unlink } = await import("fs/promises");
1157
+ await unlink(target);
1158
+ console.log(` ${RED}Removed${RESET}: ${rel}`);
1159
+ removed++;
585
1160
  }
1161
+ }
1162
+ } catch {
1163
+ // Doesn't exist — nothing to do
1164
+ }
1165
+ }
586
1166
 
587
- step.note(`${red(String(removed))} removed`);
588
- },
589
- },
590
- ]);
1167
+ console.log("");
1168
+ console.log(`Done. Removed: ${removed} commands.`);
1169
+ console.log("");
591
1170
 
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
- );
1171
+ // Remove scripts installed by ForgeDock from ~/.claude/scripts/
1172
+ const scriptFiles = await findScriptFiles(SCRIPTS_DIR);
1173
+ let scriptsRemoved = 0;
1174
+
1175
+ for (const file of scriptFiles) {
1176
+ const rel = relative(SCRIPTS_DIR, file);
1177
+ const target = join(SCRIPTS_TARGET_DIR, rel);
1178
+
1179
+ try {
1180
+ const stats = await lstat(target);
1181
+ const { unlink } = await import("fs/promises");
1182
+ if (stats.isSymbolicLink()) {
1183
+ const current = await readlink(target);
1184
+ if (current === file) {
1185
+ await unlink(target);
1186
+ console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
1187
+ scriptsRemoved++;
1188
+ }
1189
+ } else if (
1190
+ readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
1191
+ ) {
1192
+ // Regular file installed by ForgeDock in copy mode — content matches.
1193
+ await unlink(target);
1194
+ console.log(` ${RED}Removed${RESET}: scripts/${rel}`);
1195
+ scriptsRemoved++;
1196
+ }
1197
+ } catch (err) {
1198
+ if (err.code !== "ENOENT") throw err;
1199
+ // Doesn't exist — nothing to do
1200
+ }
603
1201
  }
604
- }
605
1202
 
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;
1203
+ if (scriptFiles.length > 0) {
1204
+ console.log("");
1205
+ console.log(`Done. Removed: ${scriptsRemoved} scripts.`);
1206
+ console.log("");
619
1207
  }
620
1208
 
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;
681
- }
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
- ]);
1209
+ // Remove the SessionStart hook from ~/.claude/settings.json
1210
+ const hookRemoveResult = await removeSessionStartHook();
1211
+ if (hookRemoveResult === "removed") {
1212
+ console.log(
1213
+ ` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}~/.claude/settings.json${RESET}`,
1214
+ );
1215
+ } else if (hookRemoveResult === "not-present") {
1216
+ console.log(
1217
+ ` ✔ SessionStart hook already absent from ~/.claude/settings.json`,
1218
+ );
1219
+ } else {
1220
+ console.log(
1221
+ ` ${YELLOW}⚠${RESET} Could not update ~/.claude/settings.json — remove the hook manually.`,
1222
+ );
1223
+ }
1224
+ console.log("");
689
1225
 
690
- if (result.ok) {
691
- process.stderr.write(
692
- box([`${green("✔")} ForgeDock is up to date`], {
693
- title: "ForgeDock Updated",
694
- }) + "\n",
1226
+ // Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present)
1227
+ const uninstallCwd = process.cwd();
1228
+ const claudeMdPath = join(uninstallCwd, "CLAUDE.md");
1229
+ const agentsMdPath = join(uninstallCwd, "AGENTS.md");
1230
+
1231
+ const claudeRemoveResult = removeManagedBlock(claudeMdPath);
1232
+ if (claudeRemoveResult === "removed") {
1233
+ console.log(
1234
+ ` ${GREEN}✔${RESET} Removed ForgeDock pipeline rules from ${CYAN}CLAUDE.md${RESET}`,
695
1235
  );
1236
+ } else {
1237
+ console.log(` ✔ No ForgeDock block in CLAUDE.md — nothing to remove`);
1238
+ }
1239
+
1240
+ if (existsSync(agentsMdPath)) {
1241
+ const agentsRemoveResult = removeManagedBlock(agentsMdPath);
1242
+ if (agentsRemoveResult === "removed") {
1243
+ console.log(
1244
+ ` ${GREEN}✔${RESET} Removed ForgeDock pipeline rules from ${CYAN}AGENTS.md${RESET}`,
1245
+ );
1246
+ } else {
1247
+ console.log(` ✔ No ForgeDock block in AGENTS.md — nothing to remove`);
1248
+ }
1249
+ }
1250
+ console.log("");
1251
+
1252
+ // Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc)
1253
+ for (const profileName of [".bashrc", ".zshrc"]) {
1254
+ const profilePath = join(HOME, profileName);
1255
+ const profileResult = removeForgeHomeFromProfile(profilePath);
1256
+ if (profileResult === "removed") {
1257
+ console.log(
1258
+ ` ${GREEN}✔${RESET} Removed FORGE_HOME export from ${CYAN}~/${profileName}${RESET}`,
1259
+ );
1260
+ } else if (profileResult === "not-present") {
1261
+ console.log(` ✔ No FORGE_HOME entry in ~/${profileName} — nothing to remove`);
1262
+ } else {
1263
+ console.log(
1264
+ ` ${YELLOW}⚠${RESET} Could not update ~/${profileName} — remove the FORGE_HOME export manually.`,
1265
+ );
1266
+ }
1267
+ }
1268
+ console.log("");
1269
+
1270
+ // Remove .forgedock marker file from cwd (created by `npx forgedock enable`)
1271
+ const markerPath = join(uninstallCwd, ".forgedock");
1272
+ if (existsSync(markerPath)) {
1273
+ try {
1274
+ unlinkSync(markerPath);
1275
+ console.log(
1276
+ ` ${GREEN}✔${RESET} Removed ${CYAN}.forgedock${RESET} marker from ${CYAN}${uninstallCwd}${RESET}`,
1277
+ );
1278
+ } catch {
1279
+ console.log(
1280
+ ` ${YELLOW}⚠${RESET} Could not remove .forgedock marker — remove it manually: ${markerPath}`,
1281
+ );
1282
+ }
1283
+ } else {
1284
+ console.log(` ✔ No .forgedock marker in current directory — nothing to remove`);
696
1285
  }
1286
+ console.log("");
1287
+
1288
+ // Clean up registry nudgeSeen entry for cwd (written by the session-start hook)
1289
+ try {
1290
+ const { clearNudgeSeen } = await import(
1291
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1292
+ );
1293
+ await clearNudgeSeen(uninstallCwd);
1294
+ console.log(
1295
+ ` ${GREEN}✔${RESET} Cleaned registry entries for ${CYAN}${uninstallCwd}${RESET}`,
1296
+ );
1297
+ } catch {
1298
+ // Non-fatal — registry is best-effort
1299
+ console.log(
1300
+ ` ${YELLOW}⚠${RESET} Could not clean registry entries — safe to ignore.`,
1301
+ );
1302
+ }
1303
+ console.log("");
1304
+
1305
+ console.log(`${GREEN}ForgeDock uninstalled.${RESET} No residue remains from this directory.`);
1306
+ console.log(` Re-install at any time with: ${CYAN}npx forgedock${RESET}`);
1307
+ console.log("");
1308
+ }
1309
+
1310
+ async function update() {
1311
+ console.log("");
1312
+ console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
1313
+ console.log("");
1314
+
1315
+ // Check if installed via npm (no .git directory) or via git clone
1316
+ const gitDir = join(FORGE_HOME, ".git");
1317
+ if (existsSync(gitDir)) {
1318
+ try {
1319
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
1320
+ cwd: FORGE_HOME,
1321
+ encoding: "utf-8",
1322
+ }).trim();
1323
+ if (branch !== "main") {
1324
+ console.log(` Not on main branch (${branch}) — skipping`);
1325
+ return;
1326
+ }
1327
+
1328
+ const before = execSync("git rev-parse HEAD", {
1329
+ cwd: FORGE_HOME,
1330
+ encoding: "utf-8",
1331
+ }).trim();
1332
+ execSync("git fetch origin main --quiet", { cwd: FORGE_HOME });
1333
+ execSync("git merge --ff-only origin/main --quiet", {
1334
+ cwd: FORGE_HOME,
1335
+ });
1336
+ const after = execSync("git rev-parse HEAD", {
1337
+ cwd: FORGE_HOME,
1338
+ encoding: "utf-8",
1339
+ }).trim();
1340
+
1341
+ if (before === after) {
1342
+ console.log(` Already up to date.`);
1343
+ } else {
1344
+ console.log(` ${GREEN}Updated to latest.${RESET}`);
1345
+ await install();
1346
+ }
1347
+ } catch (err) {
1348
+ console.log(
1349
+ ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
1350
+ );
1351
+ }
1352
+ } else {
1353
+ console.log(
1354
+ ` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
1355
+ );
1356
+ }
1357
+ console.log("");
697
1358
  }
698
1359
 
699
1360
  async function init(fromInstall = false) {
1361
+ // When called from install() via runSteps(), suppress stdout to prevent
1362
+ // interleaving with the TUI spinner on stderr. runSteps() provides the
1363
+ // "Generating forge.yaml" step label as visual feedback. (#812)
1364
+ const log = fromInstall ? () => {} : console.log;
1365
+ log("");
1366
+ log(`${BOLD}ForgeDock${RESET} — Generating forge.yaml`);
1367
+ log("");
1368
+
700
1369
  const cwd = process.cwd();
1370
+ const worktreeBase = join(cwd, ".claude", "worktrees");
701
1371
  const outputPath = join(cwd, "forge.yaml");
702
1372
 
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",
1373
+ // --- Detect git remote URL and parse owner/repo ---
1374
+ let owner = "your-github-org";
1375
+ let repo = "your-repo-name";
1376
+ let remoteDetected = false;
1377
+
1378
+ try {
1379
+ const remoteUrl = execSync("git remote get-url origin", {
1380
+ cwd,
1381
+ encoding: "utf-8",
1382
+ stdio: ["pipe", "pipe", "pipe"],
1383
+ }).trim();
1384
+
1385
+ // SSH: git@github.com:owner/repo.git
1386
+ const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
1387
+ // HTTPS: https://github.com/owner/repo.git
1388
+ const httpsMatch = remoteUrl.match(
1389
+ /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
1390
+ );
1391
+
1392
+ if (sshMatch) {
1393
+ owner = sshMatch[1];
1394
+ repo = sshMatch[2];
1395
+ remoteDetected = true;
1396
+ } else if (httpsMatch) {
1397
+ owner = httpsMatch[1];
1398
+ repo = httpsMatch[2];
1399
+ remoteDetected = true;
1400
+ } else {
1401
+ log(
1402
+ ` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
717
1403
  );
718
1404
  }
719
- return;
1405
+ } catch {
1406
+ log(
1407
+ ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1408
+ );
720
1409
  }
721
1410
 
722
- // Interactive path — run as a step-checklist with a full summary box.
723
- let detected = null;
724
- let aborted = false;
1411
+ if (remoteDetected) {
1412
+ log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
1413
+ }
725
1414
 
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`,
1415
+ // --- Detect default branch ---
1416
+ let defaultBranch = "main";
1417
+ try {
1418
+ const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1419
+ cwd,
1420
+ encoding: "utf-8",
1421
+ stdio: ["pipe", "pipe", "pipe"],
1422
+ }).trim();
1423
+ // refs/remotes/origin/main → main
1424
+ defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
1425
+ log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
1426
+ } catch {
1427
+ // Fallback: try git rev-parse
1428
+ try {
1429
+ defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
1430
+ cwd,
1431
+ encoding: "utf-8",
1432
+ stdio: ["pipe", "pipe", "pipe"],
1433
+ }).trim();
1434
+ if (defaultBranch === "HEAD") defaultBranch = "main";
1435
+ log(
1436
+ ` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
1437
+ );
1438
+ } catch {
1439
+ log(
1440
+ ` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
1441
+ );
1442
+ }
1443
+ }
1444
+
1445
+ // --- Detect staging branch ---
1446
+ let stagingBranch = "staging";
1447
+ try {
1448
+ const remoteBranches = execSync("git branch -r", {
1449
+ cwd,
1450
+ encoding: "utf-8",
1451
+ stdio: ["pipe", "pipe", "pipe"],
1452
+ });
1453
+ if (remoteBranches.includes("origin/staging")) {
1454
+ stagingBranch = "staging";
1455
+ log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
1456
+ } else {
1457
+ stagingBranch = defaultBranch;
1458
+ log(
1459
+ ` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
1460
+ );
1461
+ }
1462
+ } catch {
1463
+ log(
1464
+ ` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
730
1465
  );
731
- process.stderr.write(
732
- ` Continuing will back up your existing config and replace it.\n\n`,
1466
+ stagingBranch = defaultBranch;
1467
+ }
1468
+
1469
+ // --- Auto-detect project description from README.md ---
1470
+ let description = "";
1471
+ try {
1472
+ const readmePath = join(cwd, "README.md");
1473
+ if (existsSync(readmePath)) {
1474
+ const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
1475
+ // Skip the first heading line (# Title), grab the first non-empty paragraph
1476
+ const lines = readmeContent.split("\n");
1477
+ let inFirstParagraph = false;
1478
+ const paragraphLines = [];
1479
+ for (const line of lines) {
1480
+ // Skip heading lines at the top
1481
+ if (!inFirstParagraph && line.match(/^#/)) continue;
1482
+ // Skip blank lines before paragraph starts
1483
+ if (!inFirstParagraph && line.trim() === "") continue;
1484
+ // Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
1485
+ if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
1486
+ if (!inFirstParagraph && line.match(/^---/)) continue;
1487
+ // Start collecting
1488
+ if (!inFirstParagraph) {
1489
+ inFirstParagraph = true;
1490
+ }
1491
+ // Stop at blank line (end of paragraph)
1492
+ if (line.trim() === "") break;
1493
+ paragraphLines.push(line.trim());
1494
+ }
1495
+ if (paragraphLines.length > 0) {
1496
+ // Flatten to single line, strip markdown links/bold/inline code
1497
+ description = paragraphLines
1498
+ .join(" ")
1499
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1500
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1501
+ .replace(/`([^`]+)`/g, "$1")
1502
+ .slice(0, 200)
1503
+ .trim();
1504
+ }
1505
+ }
1506
+ } catch {
1507
+ // Best-effort only — silently fall back to empty description
1508
+ }
1509
+
1510
+ if (description) {
1511
+ log(
1512
+ ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from README.md)`,
733
1513
  );
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`,
1514
+ }
1515
+
1516
+ // --- Auto-detect project description from CLAUDE.md (fallback) ---
1517
+ if (!description) {
1518
+ try {
1519
+ const claudePath = join(cwd, "CLAUDE.md");
1520
+ if (existsSync(claudePath)) {
1521
+ const claudeContent = readFileSync(claudePath, "utf-8").slice(0, 2048);
1522
+ const lines = claudeContent.split("\n");
1523
+ let inFirstParagraph = false;
1524
+ const paragraphLines = [];
1525
+ for (const line of lines) {
1526
+ // Skip heading lines at the top
1527
+ if (!inFirstParagraph && line.match(/^#/)) continue;
1528
+ // Skip blank lines before paragraph starts
1529
+ if (!inFirstParagraph && line.trim() === "") continue;
1530
+ // Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
1531
+ if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
1532
+ if (!inFirstParagraph && line.match(/^---/)) continue;
1533
+ // Start collecting
1534
+ if (!inFirstParagraph) {
1535
+ inFirstParagraph = true;
1536
+ }
1537
+ // Stop at blank line (end of paragraph)
1538
+ if (line.trim() === "") break;
1539
+ paragraphLines.push(line.trim());
1540
+ }
1541
+ if (paragraphLines.length > 0) {
1542
+ // Flatten to single line, strip markdown links/bold/inline code
1543
+ description = paragraphLines
1544
+ .join(" ")
1545
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1546
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
1547
+ .replace(/`([^`]+)`/g, "$1")
1548
+ .slice(0, 200)
1549
+ .trim();
1550
+ }
1551
+ }
1552
+ } catch {
1553
+ // Best-effort only — silently fall back to empty description
1554
+ }
1555
+
1556
+ if (description) {
1557
+ log(
1558
+ ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from CLAUDE.md)`,
738
1559
  );
739
- return;
740
1560
  }
741
- process.stderr.write("\n");
1561
+ }
742
1562
 
743
- // Back up existing file before proceeding
1563
+ // --- Handle existing forge.yaml ---
1564
+ if (existsSync(outputPath)) {
744
1565
  const baseBak = join(cwd, "forge.yaml.bak");
745
1566
  const backupPath = existsSync(baseBak)
746
1567
  ? join(
@@ -750,65 +1571,680 @@ async function init(fromInstall = false) {
750
1571
  : baseBak;
751
1572
  const backupName = backupPath.split("/").pop();
752
1573
  renameSync(outputPath, backupPath);
753
- process.stderr.write(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}\n\n`);
1574
+ log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
754
1575
  }
755
1576
 
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
- ]);
1577
+ // --- Generate forge.yaml content ---
1578
+ const projectName = repo
1579
+ .split(/[-_]/)
1580
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1581
+ .join(" ");
766
1582
 
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
- )}`,
1583
+ const content = `# forge.yaml — ForgeDock Configuration
1584
+ #
1585
+ # Auto-generated by: npx forgedock init
1586
+ # Edit this file with your project details.
1587
+ #
1588
+ # Required sections: project, paths, branches
1589
+ # Optional sections: repos, project_board, services, review, verification
1590
+ #
1591
+ # See docs/CONFIG.md for full reference.
1592
+
1593
+ # =============================================================================
1594
+ # PROJECT (REQUIRED)
1595
+ # =============================================================================
1596
+
1597
+ project:
1598
+ name: "${projectName}"
1599
+ owner: "${owner}"
1600
+ repo: "${repo}"
1601
+ description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1602
+
1603
+ # =============================================================================
1604
+ # PATHS (REQUIRED)
1605
+ # =============================================================================
1606
+
1607
+ paths:
1608
+ root: "${cwd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1609
+ worktree_base: "${worktreeBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1610
+
1611
+ # =============================================================================
1612
+ # BRANCHES (REQUIRED)
1613
+ # =============================================================================
1614
+
1615
+ branches:
1616
+ default: "${defaultBranch}"
1617
+ staging: "${stagingBranch}"
1618
+ feature_pattern: "milestone/{slug}"
1619
+
1620
+ # =============================================================================
1621
+ # REPOS (OPTIONAL)
1622
+ # Multi-repo configuration. Remove the # to enable.
1623
+ # =============================================================================
1624
+
1625
+ # repos:
1626
+ # default:
1627
+ # repo: "${owner}/${repo}"
1628
+ # staging_branch: "${stagingBranch}"
1629
+ # satellites:
1630
+ # - prefix: "mcp"
1631
+ # repo: "${owner}/your-satellite-repo"
1632
+ # staging_branch: "main"
1633
+ # local_path: "${join(cwd, "..", "your-satellite-repo").replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1634
+
1635
+ # =============================================================================
1636
+ # PROJECT BOARD (OPTIONAL)
1637
+ # GitHub Projects v2 integration.
1638
+ # To find IDs: gh project list --owner ${owner}
1639
+ # =============================================================================
1640
+
1641
+ # project_board:
1642
+ # owner: "${owner}"
1643
+ # project_number: 1
1644
+ # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
1645
+ # field_ids:
1646
+ # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1647
+ # lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1648
+ # component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1649
+ # priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1650
+ # workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1651
+
1652
+ # =============================================================================
1653
+ # REVIEW (OPTIONAL)
1654
+ # Context injected into review agent prompts.
1655
+ # =============================================================================
1656
+
1657
+ # review:
1658
+ # tech_stack: "Node.js, TypeScript, PostgreSQL"
1659
+ # context: |
1660
+ # Describe your repo structure and any unusual conventions here.
1661
+
1662
+ # =============================================================================
1663
+ # VERIFICATION (OPTIONAL)
1664
+ # Health-check patterns for quality gate and validate commands.
1665
+ # =============================================================================
1666
+
1667
+ # verification:
1668
+ # health_endpoint: "https://api.${repo}.io/health"
1669
+ # health_patterns:
1670
+ # - '"status": "ok"'
1671
+ `;
1672
+
1673
+ atomicWriteFile(outputPath, content);
1674
+
1675
+ log(` ${GREEN}Created${RESET}: forge.yaml`);
1676
+ log("");
1677
+
1678
+ if (fromInstall) {
1679
+ // Called automatically from install() — only print what still needs attention.
1680
+ // NOTE: log() is a no-op when fromInstall=true (stdout suppressed to avoid
1681
+ // interleaving with TUI spinner on stderr). If git remote was not detected,
1682
+ // the user will see the incomplete placeholder values in forge.yaml directly. (#812)
1683
+ if (!remoteDetected) {
1684
+ log(`${YELLOW}Action required:${RESET}`);
1685
+ log(
1686
+ ` Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET} (git remote not detected)`,
1687
+ );
1688
+ log("");
1689
+ }
1690
+ } else {
1691
+ // Called explicitly via `npx forgedock init` — print full next steps
1692
+ log(`${BOLD}Next steps:${RESET}`);
1693
+ if (!remoteDetected) {
1694
+ log(
1695
+ ` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
1696
+ );
1697
+ } else {
1698
+ log(
1699
+ ` 1. Review ${CYAN}forge.yaml${RESET} — all required fields were auto-detected`,
1700
+ );
1701
+ }
1702
+ log(
1703
+ ` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
790
1704
  );
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")}`,
1705
+ log(
1706
+ ` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
796
1707
  );
1708
+ log("");
1709
+ }
1710
+ }
1711
+
1712
+ /**
1713
+ * Mark a directory as ForgeDock-managed by removing it from the opt-out
1714
+ * registry and creating a .forgedock marker file.
1715
+ *
1716
+ * @param {string} [dir] - Directory to enable (default: process.cwd()).
1717
+ */
1718
+ async function enable(dir) {
1719
+ const targetDir = resolve(dir || process.cwd());
1720
+
1721
+ let setOptOut;
1722
+ try {
1723
+ ({ setOptOut } = await import(
1724
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1725
+ ));
1726
+ } catch (err) {
1727
+ console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
1728
+ process.exit(1);
1729
+ }
1730
+
1731
+ // Remove from opt-out registry
1732
+ try {
1733
+ await setOptOut(targetDir, false);
1734
+ } catch (err) {
1735
+ console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
1736
+ process.exit(1);
1737
+ }
1738
+
1739
+ // Create .forgedock marker file so the directory is treated as managed
1740
+ // even without a forge.yaml (user can run `npx forgedock init` next)
1741
+ const markerPath = join(targetDir, ".forgedock");
1742
+ if (!existsSync(markerPath)) {
1743
+ try {
1744
+ writeFileSync(markerPath, "", "utf-8");
1745
+ console.log(` ${GREEN}Created${RESET}: .forgedock marker in ${targetDir}`);
1746
+ } catch (err) {
1747
+ console.log(` ${YELLOW}Warning${RESET}: could not create .forgedock marker: ${err.message}`);
1748
+ }
797
1749
  } else {
798
- summaryLines.push(
799
- `${green("→")} Review ${cyan("forge.yaml")} — all required fields were auto-detected`,
1750
+ console.log(` ✔ .forgedock marker already present in ${targetDir}`);
1751
+ }
1752
+
1753
+ console.log(` ${GREEN}✔${RESET} ForgeDock ${GREEN}enabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
1754
+ console.log(` Removed from opt-out registry. Run ${CYAN}npx forgedock init${RESET} to generate forge.yaml.`);
1755
+ console.log("");
1756
+ }
1757
+
1758
+ /**
1759
+ * Opt a directory out of ForgeDock by adding it to the opt-out registry.
1760
+ * The session-start hook will be silent for this directory in future sessions.
1761
+ *
1762
+ * @param {string} [dir] - Directory to disable (default: process.cwd()).
1763
+ */
1764
+ async function disable(dir) {
1765
+ const targetDir = resolve(dir || process.cwd());
1766
+
1767
+ let setOptOut;
1768
+ try {
1769
+ ({ setOptOut } = await import(
1770
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1771
+ ));
1772
+ } catch (err) {
1773
+ console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
1774
+ process.exit(1);
1775
+ }
1776
+
1777
+ try {
1778
+ await setOptOut(targetDir, true);
1779
+ } catch (err) {
1780
+ console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
1781
+ process.exit(1);
1782
+ }
1783
+
1784
+ console.log(` ${GREEN}✔${RESET} ForgeDock ${YELLOW}disabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
1785
+ console.log(` Added to opt-out registry. The session-start hook will be silent here.`);
1786
+ console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
1787
+ console.log("");
1788
+ }
1789
+
1790
+ /**
1791
+ * Show the resolved ForgeDock state for a directory.
1792
+ * Prints human-readable state (managed-active, managed-optedout, unmanaged)
1793
+ * with relevant detail about what markers or registry entries were found.
1794
+ *
1795
+ * @param {string} [dir] - Directory to inspect (default: process.cwd()).
1796
+ */
1797
+ async function status(dir) {
1798
+ const targetDir = resolve(dir || process.cwd());
1799
+
1800
+ let resolveState;
1801
+ try {
1802
+ ({ resolveState } = await import(
1803
+ pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1804
+ ));
1805
+ } catch (err) {
1806
+ console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
1807
+ process.exit(1);
1808
+ }
1809
+
1810
+ let state;
1811
+ try {
1812
+ state = resolveState(targetDir);
1813
+ } catch (err) {
1814
+ console.error(`${RED}Error resolving state: ${err.message}${RESET}`);
1815
+ process.exit(1);
1816
+ }
1817
+
1818
+ const hasForgeYaml = existsSync(join(targetDir, "forge.yaml"));
1819
+ const hasMarker = existsSync(join(targetDir, ".forgedock"));
1820
+
1821
+ console.log("");
1822
+ console.log(`${BOLD}ForgeDock Status${RESET}`);
1823
+ console.log(` Directory: ${CYAN}${targetDir}${RESET}`);
1824
+ console.log("");
1825
+
1826
+ switch (state) {
1827
+ case "managed-active":
1828
+ console.log(` State: ${GREEN}managed-active${RESET}`);
1829
+ console.log(` ForgeDock is active in this directory.`);
1830
+ if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
1831
+ if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
1832
+ console.log(` The session-start hook will inject context on session start.`);
1833
+ console.log(` To disable: ${CYAN}npx forgedock disable${RESET}`);
1834
+ break;
1835
+
1836
+ case "managed-optedout":
1837
+ console.log(` State: ${YELLOW}managed-optedout${RESET}`);
1838
+ console.log(` ForgeDock is installed but opted out for this directory.`);
1839
+ if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
1840
+ if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
1841
+ console.log(` The session-start hook is silent here (opt-out registry entry present).`);
1842
+ console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
1843
+ break;
1844
+
1845
+ case "unmanaged":
1846
+ default:
1847
+ console.log(` State: ${RED}unmanaged${RESET}`);
1848
+ console.log(` ForgeDock is not active in this directory.`);
1849
+ console.log(` No forge.yaml or .forgedock marker found.`);
1850
+ console.log(` To enable: ${CYAN}npx forgedock enable${RESET}`);
1851
+ console.log(` To set up: ${CYAN}npx forgedock init${RESET}`);
1852
+ break;
1853
+ }
1854
+ console.log("");
1855
+ }
1856
+
1857
+ /**
1858
+ * Run installation health checks and report pass/fail for each.
1859
+ *
1860
+ * Checks (in order):
1861
+ * 1. Command symlinks — TARGET_DIR entries point to correct COMMANDS_DIR files
1862
+ * 2. gh CLI installed and authenticated
1863
+ * 3. git configured (user.name + user.email)
1864
+ * 4. forge.yaml exists and has required keys
1865
+ * 5. SessionStart hook registered in ~/.claude/settings.json
1866
+ * 6. CLAUDE.md has the ForgeDock behavioral block (cwd)
1867
+ * 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
1868
+ * 8. FORGE_HOME environment variable is set
1869
+ *
1870
+ * Exits with code 0 if all checks pass, code 1 if any fail.
1871
+ */
1872
+ async function doctor() {
1873
+ console.log("");
1874
+ console.log(`${BOLD}ForgeDock Doctor${RESET} — Installation Health Check`);
1875
+ console.log("");
1876
+
1877
+ let failures = 0;
1878
+ let warnings = 0;
1879
+
1880
+ /**
1881
+ * Print a pass line.
1882
+ * @param {string} label
1883
+ * @param {string} [detail]
1884
+ */
1885
+ function pass(label, detail) {
1886
+ const suffix = detail ? ` ${detail}` : "";
1887
+ console.log(` ${GREEN}✔${RESET} ${label}${suffix}`);
1888
+ }
1889
+
1890
+ /**
1891
+ * Print a fail line with a remediation hint.
1892
+ * @param {string} label
1893
+ * @param {string} hint
1894
+ */
1895
+ function fail(label, hint) {
1896
+ failures++;
1897
+ console.log(` ${RED}✗${RESET} ${BOLD}${label}${RESET}`);
1898
+ console.log(` Fix: ${hint}`);
1899
+ }
1900
+
1901
+ /**
1902
+ * Print a warning line (informational, does not count as failure).
1903
+ * @param {string} label
1904
+ * @param {string} hint
1905
+ */
1906
+ function warn(label, hint) {
1907
+ warnings++;
1908
+ console.log(` ${YELLOW}⚠${RESET} ${label}`);
1909
+ console.log(` Note: ${hint}`);
1910
+ }
1911
+
1912
+ // ── Check 1: Command symlinks ──────────────────────────────────────────────
1913
+ {
1914
+ let symlinkOk = true;
1915
+ let checked = 0;
1916
+ let broken = 0;
1917
+ const brokenLinks = [];
1918
+
1919
+ try {
1920
+ // Collect all .md files from COMMANDS_DIR recursively
1921
+ const collectMd = async (dir) => {
1922
+ const results = [];
1923
+ let entries;
1924
+ try {
1925
+ entries = await readdir(dir, { withFileTypes: true });
1926
+ } catch {
1927
+ return results;
1928
+ }
1929
+ for (const entry of entries) {
1930
+ const full = join(dir, entry.name);
1931
+ if (entry.isDirectory()) {
1932
+ results.push(...(await collectMd(full)));
1933
+ } else if (entry.name.endsWith(".md")) {
1934
+ results.push(full);
1935
+ }
1936
+ }
1937
+ return results;
1938
+ };
1939
+
1940
+ const sourceFiles = await collectMd(COMMANDS_DIR);
1941
+
1942
+ for (const src of sourceFiles) {
1943
+ const rel = relative(COMMANDS_DIR, src);
1944
+ const tgt = join(TARGET_DIR, rel);
1945
+ checked++;
1946
+ try {
1947
+ const lstats = await lstat(tgt);
1948
+ if (lstats.isSymbolicLink()) {
1949
+ const dest = await readlink(tgt);
1950
+ if (dest !== src) {
1951
+ broken++;
1952
+ brokenLinks.push(rel);
1953
+ symlinkOk = false;
1954
+ }
1955
+ } else {
1956
+ // Regular file — not a symlink (install left a copy)
1957
+ broken++;
1958
+ brokenLinks.push(`${rel} (not a symlink)`);
1959
+ symlinkOk = false;
1960
+ }
1961
+ } catch {
1962
+ // Symlink missing
1963
+ broken++;
1964
+ brokenLinks.push(`${rel} (missing)`);
1965
+ symlinkOk = false;
1966
+ }
1967
+ }
1968
+
1969
+ if (symlinkOk) {
1970
+ pass("Command symlinks", `${checked} symlinks valid`);
1971
+ } else {
1972
+ fail(
1973
+ "Command symlinks",
1974
+ `Run: npx forgedock install (${broken}/${checked} broken: ${brokenLinks.slice(0, 3).join(", ")}${brokenLinks.length > 3 ? "…" : ""})`,
1975
+ );
1976
+ }
1977
+ } catch (err) {
1978
+ fail("Command symlinks", `Could not read commands directory: ${err.message}`);
1979
+ }
1980
+ }
1981
+
1982
+ // ── Check 2: gh CLI installed + authenticated ──────────────────────────────
1983
+ let ghAvailable = false;
1984
+ {
1985
+ let ghInstalled = false;
1986
+ let ghAuthed = false;
1987
+ try {
1988
+ execSync("gh --version", { stdio: ["ignore", "pipe", "ignore"] });
1989
+ ghInstalled = true;
1990
+ } catch {
1991
+ // gh not installed
1992
+ }
1993
+
1994
+ if (!ghInstalled) {
1995
+ fail("gh CLI", "Install the GitHub CLI: https://cli.github.com/");
1996
+ } else {
1997
+ try {
1998
+ execSync("gh auth status", { stdio: ["ignore", "pipe", "ignore"] });
1999
+ ghAuthed = true;
2000
+ } catch {
2001
+ // Not authenticated
2002
+ }
2003
+
2004
+ if (ghAuthed) {
2005
+ pass("gh CLI", "installed and authenticated");
2006
+ ghAvailable = true;
2007
+ } else {
2008
+ fail("gh CLI", "Run: gh auth login");
2009
+ }
2010
+ }
2011
+ }
2012
+
2013
+ // ── Check 3: git configured ────────────────────────────────────────────────
2014
+ {
2015
+ let gitName = "";
2016
+ let gitEmail = "";
2017
+ try {
2018
+ gitName = execSync("git config --global user.name", {
2019
+ stdio: ["ignore", "pipe", "ignore"],
2020
+ encoding: "utf-8",
2021
+ }).trim();
2022
+ } catch {
2023
+ /* not set */
2024
+ }
2025
+ try {
2026
+ gitEmail = execSync("git config --global user.email", {
2027
+ stdio: ["ignore", "pipe", "ignore"],
2028
+ encoding: "utf-8",
2029
+ }).trim();
2030
+ } catch {
2031
+ /* not set */
2032
+ }
2033
+
2034
+ if (gitName && gitEmail) {
2035
+ pass("git config", `user.name="${gitName}", user.email="${gitEmail}"`);
2036
+ } else {
2037
+ const missing = [
2038
+ !gitName && "user.name",
2039
+ !gitEmail && "user.email",
2040
+ ].filter(Boolean).join(", ");
2041
+ fail(
2042
+ "git config",
2043
+ `Run: git config --global user.name "Your Name" and git config --global user.email "you@example.com" (missing: ${missing})`,
2044
+ );
2045
+ }
2046
+ }
2047
+
2048
+ // ── Check 4: forge.yaml exists + has required keys ─────────────────────────
2049
+ let forgeOwner = null;
2050
+ let forgeRepo = null;
2051
+ {
2052
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
2053
+ if (!existsSync(forgeYamlPath)) {
2054
+ warn(
2055
+ "forge.yaml",
2056
+ "No forge.yaml in current directory. Run: npx forgedock init",
2057
+ );
2058
+ } else {
2059
+ let content = "";
2060
+ try {
2061
+ content = readFileSync(forgeYamlPath, "utf-8");
2062
+ } catch (err) {
2063
+ fail("forge.yaml", `Cannot read forge.yaml: ${err.message}`);
2064
+ }
2065
+
2066
+ if (content) {
2067
+ const hasProject = /^project:/m.test(content);
2068
+ const hasPaths = /^paths:/m.test(content);
2069
+ const hasBranches = /^branches:/m.test(content);
2070
+ const missing = [
2071
+ !hasProject && "project",
2072
+ !hasPaths && "paths",
2073
+ !hasBranches && "branches",
2074
+ ].filter(Boolean);
2075
+
2076
+ if (missing.length === 0) {
2077
+ pass("forge.yaml", "exists with required keys");
2078
+ // Extract owner and repo for the label check (simple regex, no YAML parser needed)
2079
+ const ownerMatch = content.match(/^\s+owner:\s+"?([^"\n]+)"?\s*$/m);
2080
+ const repoMatch = content.match(/^\s+repo:\s+"?([^"\n]+)"?\s*$/m);
2081
+ if (ownerMatch) forgeOwner = ownerMatch[1].trim();
2082
+ if (repoMatch) forgeRepo = repoMatch[1].trim();
2083
+ } else {
2084
+ fail("forge.yaml", `Missing required keys: ${missing.join(", ")}. Edit forge.yaml or run: npx forgedock init`);
2085
+ }
2086
+ }
2087
+ }
2088
+ }
2089
+
2090
+ // ── Check 5: SessionStart hook registered ─────────────────────────────────
2091
+ {
2092
+ try {
2093
+ const settings = readClaudeSettings();
2094
+ const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
2095
+ ? settings.hooks.SessionStart
2096
+ : [];
2097
+ const hookPresent = sessionStartEntries.some((entry) => {
2098
+ if (!entry || typeof entry !== "object") return false;
2099
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
2100
+ return hooks.some(
2101
+ (h) =>
2102
+ h &&
2103
+ typeof h.command === "string" &&
2104
+ isForgeSessionStartHook(h.command),
2105
+ );
2106
+ });
2107
+
2108
+ if (hookPresent) {
2109
+ pass("SessionStart hook", "registered in ~/.claude/settings.json");
2110
+ } else {
2111
+ fail(
2112
+ "SessionStart hook",
2113
+ "Run: npx forgedock install (writes hook entry to ~/.claude/settings.json)",
2114
+ );
2115
+ }
2116
+ } catch (err) {
2117
+ fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${err.message}. Run: npx forgedock install`);
2118
+ }
2119
+ }
2120
+
2121
+ // ── Check 6: CLAUDE.md has ForgeDock block (cwd) ──────────────────────────
2122
+ {
2123
+ const claudeMdPath = join(process.cwd(), "CLAUDE.md");
2124
+ if (!existsSync(claudeMdPath)) {
2125
+ warn(
2126
+ "CLAUDE.md behavioral block",
2127
+ `No CLAUDE.md found in ${process.cwd()}. Run: npx forgedock install (from your project directory)`,
2128
+ );
2129
+ } else {
2130
+ try {
2131
+ const content = readFileSync(claudeMdPath, "utf-8");
2132
+ if (
2133
+ content.includes(CLAUDE_BLOCK_BEGIN) &&
2134
+ content.includes(CLAUDE_BLOCK_END)
2135
+ ) {
2136
+ pass("CLAUDE.md behavioral block", `found in ${claudeMdPath}`);
2137
+ } else {
2138
+ fail(
2139
+ "CLAUDE.md behavioral block",
2140
+ `Run: npx forgedock install (from ${process.cwd()}) to inject the ForgeDock pipeline rules block`,
2141
+ );
2142
+ }
2143
+ } catch (err) {
2144
+ fail("CLAUDE.md behavioral block", `Cannot read CLAUDE.md: ${err.message}`);
2145
+ }
2146
+ }
2147
+ }
2148
+
2149
+ // ── Check 7: Required workflow labels on GitHub repo ───────────────────────
2150
+ {
2151
+ if (!forgeOwner || !forgeRepo) {
2152
+ warn(
2153
+ "GitHub workflow labels",
2154
+ "Skipped — could not resolve owner/repo from forge.yaml",
2155
+ );
2156
+ } else if (!ghAvailable) {
2157
+ warn(
2158
+ "GitHub workflow labels",
2159
+ "Skipped — gh CLI not authenticated (see check 2 above)",
2160
+ );
2161
+ } else {
2162
+ // Read expected labels from labels.json (co-located in bin/)
2163
+ const labelsJsonPath = join(__dirname, "labels.json");
2164
+ let expectedLabels = [];
2165
+ let labelsJsonOk = false;
2166
+ try {
2167
+ const labelsRaw = readFileSync(labelsJsonPath, "utf-8");
2168
+ const allLabels = JSON.parse(labelsRaw);
2169
+ expectedLabels = allLabels
2170
+ .filter((l) => l.name.startsWith("workflow:"))
2171
+ .map((l) => l.name);
2172
+ labelsJsonOk = true;
2173
+ } catch {
2174
+ warn(
2175
+ "GitHub workflow labels",
2176
+ "Could not read bin/labels.json — skipping label check",
2177
+ );
2178
+ }
2179
+
2180
+ if (labelsJsonOk && expectedLabels.length > 0) {
2181
+ let existingLabels = [];
2182
+ let ghLabelOk = false;
2183
+ try {
2184
+ const out = execFileSync(
2185
+ "gh",
2186
+ ["label", "list", "-R", `${forgeOwner}/${forgeRepo}`, "--json", "name", "--limit", "200"],
2187
+ { stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" },
2188
+ );
2189
+ const parsed = JSON.parse(out);
2190
+ existingLabels = parsed.map((l) => l.name);
2191
+ ghLabelOk = true;
2192
+ } catch {
2193
+ warn(
2194
+ "GitHub workflow labels",
2195
+ `Could not fetch labels from ${forgeOwner}/${forgeRepo}. Ensure gh is authenticated and repo is accessible.`,
2196
+ );
2197
+ }
2198
+
2199
+ if (ghLabelOk) {
2200
+ const missingLabels = expectedLabels.filter(
2201
+ (l) => !existingLabels.includes(l),
2202
+ );
2203
+ if (missingLabels.length === 0) {
2204
+ pass(
2205
+ "GitHub workflow labels",
2206
+ `all ${expectedLabels.length} workflow labels present on ${forgeOwner}/${forgeRepo}`,
2207
+ );
2208
+ } else {
2209
+ fail(
2210
+ "GitHub workflow labels",
2211
+ `Run: npx forgedock labels setup (missing: ${missingLabels.join(", ")})`,
2212
+ );
2213
+ }
2214
+ }
2215
+ }
2216
+ }
2217
+ }
2218
+
2219
+ // ── Check 8: FORGE_HOME environment variable ───────────────────────────────
2220
+ {
2221
+ const envForgeHome = process.env.FORGE_HOME;
2222
+ if (envForgeHome) {
2223
+ pass("FORGE_HOME env var", `set to "${envForgeHome}"`);
2224
+ } else {
2225
+ fail(
2226
+ "FORGE_HOME env var",
2227
+ `Add to your shell profile: export FORGE_HOME="${FORGE_HOME}" then restart your shell (or run: source ~/.bashrc)`,
2228
+ );
2229
+ }
2230
+ }
2231
+
2232
+ // ── Summary ────────────────────────────────────────────────────────────────
2233
+ console.log("");
2234
+ if (failures === 0 && warnings === 0) {
2235
+ console.log(`${GREEN}${BOLD}All checks passed.${RESET} ForgeDock installation is healthy.`);
2236
+ } else if (failures === 0) {
2237
+ console.log(`${YELLOW}${BOLD}${warnings} warning(s).${RESET} Checks passed with notes above.`);
2238
+ } else {
2239
+ console.log(
2240
+ `${RED}${BOLD}${failures} check(s) failed${warnings > 0 ? `, ${warnings} warning(s)` : ""}.${RESET} See fix hints above.`,
800
2241
  );
801
2242
  }
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
- );
2243
+ console.log("");
808
2244
 
809
- process.stderr.write(
810
- box(summaryLines, { title: "forge.yaml Generated" }) + "\n",
811
- );
2245
+ if (failures > 0) {
2246
+ process.exit(1);
2247
+ }
812
2248
  }
813
2249
 
814
2250
  function help() {
@@ -820,6 +2256,10 @@ function help() {
820
2256
  ["npx forgedock init", "Generate forge.yaml config for your project"],
821
2257
  ["npx forgedock uninstall", "Remove commands"],
822
2258
  ["npx forgedock update", "Pull latest & reinstall"],
2259
+ ["npx forgedock enable [dir]", "Mark directory as ForgeDock-managed"],
2260
+ ["npx forgedock disable [dir]", "Opt directory out of ForgeDock"],
2261
+ ["npx forgedock status [dir]", "Show resolved state for a directory"],
2262
+ ["npx forgedock doctor", "Check installation health"],
823
2263
  ["npx forgedock help", "Show this help"],
824
2264
  ];
825
2265
 
@@ -843,6 +2283,18 @@ switch (command) {
843
2283
  case "update":
844
2284
  await update();
845
2285
  break;
2286
+ case "enable":
2287
+ await enable(args[1]);
2288
+ break;
2289
+ case "disable":
2290
+ await disable(args[1]);
2291
+ break;
2292
+ case "status":
2293
+ await status(args[1]);
2294
+ break;
2295
+ case "doctor":
2296
+ await doctor();
2297
+ break;
846
2298
  case "help":
847
2299
  case "--help":
848
2300
  case "-h":