forgedock 1.0.7 → 1.0.9

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.
package/bin/forgedock.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "path";
14
14
  import {
15
15
  mkdir,
16
+ rm,
16
17
  symlink,
17
18
  copyFile,
18
19
  readlink,
@@ -53,7 +54,14 @@ import {
53
54
  input,
54
55
  createProgressBar,
55
56
  spinner,
57
+ annotatedReviewScreen,
56
58
  } from "./tui.mjs";
59
+ import { detectConfig } from "./init-detect.mjs";
60
+ import {
61
+ enrich as enrichViaAPI,
62
+ parseEnrichedDraft,
63
+ } from "./init-enrich-api.mjs";
64
+ import { resolveState, setOptOut } from "./registry.mjs";
57
65
 
58
66
  const __filename = fileURLToPath(import.meta.url);
59
67
  const __dirname = dirname(__filename);
@@ -68,6 +76,11 @@ const TARGET_DIR = join(HOME, ".claude", "commands");
68
76
  const args = process.argv.slice(2);
69
77
  const command = args[0];
70
78
  const forceYes = args.includes("--yes") || args.includes("-y");
79
+ // --manual: bypass autopilot enrichment and present the annotated review screen
80
+ // with detection baseline values only (no AI-enriched suggestions).
81
+ // --verbose: surface field detection sources and confidences during the init flow.
82
+ const manualMode = args.includes("--manual");
83
+ const verboseMode = args.includes("--verbose");
71
84
 
72
85
  // ---------------------------------------------------------------------------
73
86
  // Version — read dynamically from package.json
@@ -132,6 +145,295 @@ async function removeInstallModeMarker() {
132
145
  }
133
146
  }
134
147
 
148
+ // ---------------------------------------------------------------------------
149
+ // SessionStart hook — settings.json helpers
150
+ // ---------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Path to the user-level Claude Code settings file.
154
+ * This is where the SessionStart hook entry is written/removed.
155
+ */
156
+ const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
157
+
158
+ /**
159
+ * Timeout (in seconds) for the SessionStart hook entry written into
160
+ * ~/.claude/settings.json. Claude Code hook timeouts are in seconds —
161
+ * not milliseconds like the Node.js child_process timeouts elsewhere in
162
+ * this file.
163
+ */
164
+ const SESSION_START_HOOK_TIMEOUT_SECONDS = 10;
165
+
166
+ /**
167
+ * The command value written into the SessionStart hook entry.
168
+ * Identifies the hook by a path suffix so it can be found even if
169
+ * FORGE_HOME changes between install and uninstall runs.
170
+ *
171
+ * Uses forward slashes even on Windows — Node accepts them natively and
172
+ * they survive hook-runner invocations that do not go through a shell
173
+ * (e.g. execFile). Backslashes emitted by path.join() on Windows would
174
+ * be fragile when the hook runner has no shell to interpret the quoted
175
+ * command string.
176
+ */
177
+ function sessionStartHookCommand() {
178
+ const hookPath = join(
179
+ FORGE_HOME,
180
+ "bin",
181
+ "hooks",
182
+ "session-start.mjs",
183
+ ).replace(/\\/g, "/");
184
+ // Escape embedded double quotes so a crafted install path cannot break out
185
+ // of the quoted argument and inject shell tokens.
186
+ const safePath = hookPath.replace(/"/g, '\\"');
187
+ return `node "${safePath}"`;
188
+ }
189
+
190
+ /**
191
+ * Check whether a hook command string refers to the ForgeDock SessionStart hook.
192
+ *
193
+ * Uses a platform-safe RegExp that matches both POSIX forward-slash paths
194
+ * (Linux/macOS) and Windows backslash paths, so uninstall can locate and
195
+ * remove the hook even when FORGE_HOME was different at install time.
196
+ *
197
+ * @param {string} command - The command string from a hook entry.
198
+ * @returns {boolean}
199
+ */
200
+ function isForgeSessionStartHook(command) {
201
+ // Match: ...bin[/\]hooks[/\]session-start.mjs (quote-terminated or EOL)
202
+ return /[/\\]bin[/\\]hooks[/\\]session-start\.mjs["']?\s*$/.test(command);
203
+ }
204
+
205
+ /**
206
+ * Strip JSONC syntax (single-line comments, block comments, trailing commas)
207
+ * from a raw JSON string, returning a plain JSON string that JSON.parse() accepts.
208
+ *
209
+ * The implementation is a single-pass character-walk state machine that correctly
210
+ * skips content inside string literals, so comment-like sequences embedded in
211
+ * JSON string values are preserved.
212
+ *
213
+ * Handles:
214
+ * - Single-line comments: // ... \n
215
+ * - Block comments: /* ... *\/
216
+ * - Trailing commas: ,\s*[}\]]
217
+ *
218
+ * @param {string} raw - Raw text of a JSONC file.
219
+ * @returns {string} - Valid JSON string.
220
+ */
221
+ function stripJsonc(raw) {
222
+ let result = "";
223
+ let i = 0;
224
+ const len = raw.length;
225
+
226
+ while (i < len) {
227
+ const ch = raw[i];
228
+
229
+ // Inside a string literal — copy until unescaped closing quote
230
+ if (ch === '"') {
231
+ result += ch;
232
+ i++;
233
+ while (i < len) {
234
+ const sc = raw[i];
235
+ result += sc;
236
+ if (sc === "\\" && i + 1 < len) {
237
+ // Escaped character — copy the next char verbatim and advance past it
238
+ i++;
239
+ result += raw[i];
240
+ } else if (sc === '"') {
241
+ break; // End of string literal
242
+ }
243
+ i++;
244
+ }
245
+ i++;
246
+ continue;
247
+ }
248
+
249
+ // Single-line comment — skip until newline
250
+ if (ch === "/" && i + 1 < len && raw[i + 1] === "/") {
251
+ while (i < len && raw[i] !== "\n") i++;
252
+ continue;
253
+ }
254
+
255
+ // Block comment — skip until */
256
+ if (ch === "/" && i + 1 < len && raw[i + 1] === "*") {
257
+ i += 2;
258
+ while (i + 1 < len && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
259
+ i += 2; // Skip closing */
260
+ continue;
261
+ }
262
+
263
+ result += ch;
264
+ i++;
265
+ }
266
+
267
+ // Remove trailing commas before } or ]
268
+ return result.replace(/,(\s*[}\]])/g, "$1");
269
+ }
270
+
271
+ /**
272
+ * Read ~/.claude/settings.json, returning a parsed object.
273
+ * Returns an empty object if the file does not exist.
274
+ * Tolerates JSONC syntax (comments, trailing commas) that Claude Code's
275
+ * own settings editor may insert into user-edited settings.json files.
276
+ * Throws if the file exists but cannot be parsed even after JSONC stripping.
277
+ *
278
+ * @returns {object}
279
+ */
280
+ function readClaudeSettings() {
281
+ try {
282
+ const raw = readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
283
+ return JSON.parse(stripJsonc(raw));
284
+ } catch (err) {
285
+ if (err.code === "ENOENT") return {};
286
+ throw err;
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Atomically write an object to ~/.claude/settings.json.
292
+ * Uses a .tmp sibling + renameSync to avoid partial writes.
293
+ *
294
+ * @param {object} settings
295
+ */
296
+ function writeClaudeSettings(settings) {
297
+ const tmpPath = CLAUDE_SETTINGS_PATH + ".forgedock.tmp";
298
+ try {
299
+ writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
300
+ renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
301
+ } catch (err) {
302
+ // Clean up temp file if it was created before the error
303
+ try {
304
+ unlinkSync(tmpPath);
305
+ } catch {
306
+ /* already gone or never created */
307
+ }
308
+ throw err;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Install the ForgeDock SessionStart hook into ~/.claude/settings.json
314
+ * idempotently. Does not modify any existing hooks unrelated to ForgeDock.
315
+ *
316
+ * Returns:
317
+ * 'installed' — hook was newly added
318
+ * 'already-present' — hook was already there (idempotent)
319
+ * 'failed' — an error occurred (non-fatal; install continues)
320
+ *
321
+ * @returns {Promise<'installed' | 'already-present' | 'failed'>}
322
+ */
323
+ async function installSessionStartHook() {
324
+ try {
325
+ const settings = readClaudeSettings();
326
+
327
+ // Ensure the hooks section exists
328
+ if (!settings.hooks || typeof settings.hooks !== "object") {
329
+ settings.hooks = {};
330
+ }
331
+
332
+ // Ensure SessionStart array exists
333
+ if (!Array.isArray(settings.hooks.SessionStart)) {
334
+ settings.hooks.SessionStart = [];
335
+ }
336
+
337
+ const command = sessionStartHookCommand();
338
+
339
+ // Check if already present (idempotent — match by path suffix)
340
+ const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
341
+ if (!entry || typeof entry !== "object") return false;
342
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
343
+ return hooks.some(
344
+ (h) =>
345
+ h &&
346
+ typeof h.command === "string" &&
347
+ isForgeSessionStartHook(h.command),
348
+ );
349
+ });
350
+
351
+ if (alreadyPresent) return "already-present";
352
+
353
+ // Append the new hook entry
354
+ settings.hooks.SessionStart.push({
355
+ hooks: [
356
+ {
357
+ type: "command",
358
+ command,
359
+ timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
360
+ },
361
+ ],
362
+ });
363
+
364
+ writeClaudeSettings(settings);
365
+ return "installed";
366
+ } catch {
367
+ return "failed";
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
373
+ * idempotently. Only removes the entry written by installSessionStartHook().
374
+ * Unrelated hooks are preserved.
375
+ *
376
+ * Returns:
377
+ * 'removed' — hook was found and removed
378
+ * 'not-present' — hook was not in settings.json (already clean)
379
+ * 'failed' — an error occurred (non-fatal; uninstall continues)
380
+ *
381
+ * @returns {Promise<'removed' | 'not-present' | 'failed'>}
382
+ */
383
+ async function removeSessionStartHook() {
384
+ try {
385
+ const settings = readClaudeSettings();
386
+
387
+ if (
388
+ !settings.hooks ||
389
+ !Array.isArray(settings.hooks.SessionStart) ||
390
+ settings.hooks.SessionStart.length === 0
391
+ ) {
392
+ return "not-present";
393
+ }
394
+
395
+ const originalLength = settings.hooks.SessionStart.length;
396
+
397
+ // Filter out the ForgeDock-managed entry, keep everything else
398
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
399
+ (entry) => {
400
+ if (!entry || typeof entry !== "object") return true;
401
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
402
+ const isForgeEntry = hooks.some(
403
+ (h) =>
404
+ h &&
405
+ typeof h.command === "string" &&
406
+ isForgeSessionStartHook(h.command),
407
+ );
408
+ return !isForgeEntry;
409
+ },
410
+ );
411
+
412
+ if (settings.hooks.SessionStart.length === originalLength) {
413
+ return "not-present";
414
+ }
415
+
416
+ // Clean up empty SessionStart array to leave settings.json tidy
417
+ if (settings.hooks.SessionStart.length === 0) {
418
+ delete settings.hooks.SessionStart;
419
+ }
420
+
421
+ // Clean up empty hooks object
422
+ if (
423
+ settings.hooks &&
424
+ typeof settings.hooks === "object" &&
425
+ Object.keys(settings.hooks).length === 0
426
+ ) {
427
+ delete settings.hooks;
428
+ }
429
+
430
+ writeClaudeSettings(settings);
431
+ return "removed";
432
+ } catch {
433
+ return "failed";
434
+ }
435
+ }
436
+
135
437
  // ---------------------------------------------------------------------------
136
438
  // Install state detection
137
439
  // ---------------------------------------------------------------------------
@@ -537,10 +839,14 @@ async function runPrerequisiteChecklist() {
537
839
  // Try to get primary org from gh api
538
840
  let orgLabel = "";
539
841
  try {
540
- const orgsJson = execSync("gh api /user/orgs --jq '.[0].login'", {
541
- encoding: "utf-8",
542
- stdio: ["pipe", "pipe", "pipe"],
543
- }).trim();
842
+ const orgsJson = execFileSync(
843
+ "gh",
844
+ ["api", "/user/orgs", "--jq", ".[0].login"],
845
+ {
846
+ encoding: "utf-8",
847
+ stdio: ["pipe", "pipe", "pipe"],
848
+ },
849
+ ).trim();
544
850
  if (orgsJson && orgsJson !== "null" && orgsJson !== "") {
545
851
  orgLabel = ` (${orgsJson})`;
546
852
  }
@@ -905,7 +1211,11 @@ async function install() {
905
1211
  ...conflicts.map((c) => ` ${red("✖")} ${c}`),
906
1212
  "",
907
1213
  ` ${dim("To let ForgeDock manage these, remove or rename each file:")}`,
908
- ...conflicts.map((c) => ` ${dim(`rm ~/.claude/commands/${c}`)}`),
1214
+ ...conflicts.map((c) =>
1215
+ process.platform === "win32"
1216
+ ? ` ${dim(`Remove-Item ~\\.claude\\commands\\${c.replace(/\//g, "\\")}`)}`
1217
+ : ` ${dim(`rm ~/.claude/commands/${c}`)}`,
1218
+ ),
909
1219
  "",
910
1220
  ];
911
1221
  process.stdout.write(
@@ -921,27 +1231,57 @@ async function install() {
921
1231
 
922
1232
  console.log("");
923
1233
  let forgeHomeSet = false;
924
- for (const profile of [join(HOME, ".bashrc"), join(HOME, ".zshrc")]) {
925
- if (existsSync(profile)) {
926
- const content = readFileSync(profile, "utf-8");
927
- if (!content.includes("FORGE_HOME")) {
928
- appendFileSync(
929
- profile,
930
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
931
- );
932
- const profileShort = profile.replace(HOME, "~");
933
- console.log(
934
- ` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`,
935
- );
936
- forgeHomeSet = true;
1234
+ if (process.platform === "win32") {
1235
+ // On Windows, ~/.bashrc and ~/.zshrc do not exist.
1236
+ // Persist FORGE_HOME for the current user via setx (built-in, no elevation needed).
1237
+ // If setx fails for any reason, print a PowerShell one-liner the user can run manually.
1238
+ try {
1239
+ // setx exits 0 even when it truncates; capture stdout and check for the truncation
1240
+ // warning before declaring success. <!-- Added: forge#415 -->
1241
+ const setxOut = execFileSync("setx", ["FORGE_HOME", FORGE_HOME], {
1242
+ stdio: "pipe",
1243
+ });
1244
+ if (
1245
+ setxOut.toString().includes("WARNING: Data being saved is truncated")
1246
+ ) {
1247
+ throw new Error("setx truncated the FORGE_HOME value");
1248
+ }
1249
+ console.log(
1250
+ ` ${green("✔")} ${bold("FORGE_HOME")} set via ${cyan("setx")} ${dim("(restart your terminal to apply)")}`,
1251
+ );
1252
+ } catch {
1253
+ // setx unavailable, failed, or truncated — print manual guidance
1254
+ console.log(
1255
+ ` ${yellow("⚠")} ${bold("FORGE_HOME")} not set automatically — run this in PowerShell:`,
1256
+ );
1257
+ console.log(
1258
+ ` ${cyan(`[System.Environment]::SetEnvironmentVariable('FORGE_HOME', '${FORGE_HOME.replace(/'/g, "''")}', 'User')`)}`,
1259
+ );
1260
+ }
1261
+ forgeHomeSet = true;
1262
+ } else {
1263
+ for (const profile of [join(HOME, ".bashrc"), join(HOME, ".zshrc")]) {
1264
+ if (existsSync(profile)) {
1265
+ const content = readFileSync(profile, "utf-8");
1266
+ if (!content.includes("FORGE_HOME")) {
1267
+ appendFileSync(
1268
+ profile,
1269
+ `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
1270
+ );
1271
+ const profileShort = profile.replace(HOME, "~");
1272
+ console.log(
1273
+ ` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`,
1274
+ );
1275
+ forgeHomeSet = true;
1276
+ }
937
1277
  }
938
1278
  }
939
- }
940
1279
 
941
- if (!forgeHomeSet) {
942
- console.log(
943
- ` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
944
- );
1280
+ if (!forgeHomeSet) {
1281
+ console.log(
1282
+ ` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
1283
+ );
1284
+ }
945
1285
  }
946
1286
 
947
1287
  console.log("");
@@ -950,6 +1290,31 @@ async function install() {
950
1290
  );
951
1291
  console.log("");
952
1292
 
1293
+ // -------------------------------------------------------------------------
1294
+ // Phase 6: SessionStart hook — install into ~/.claude/settings.json
1295
+ // -------------------------------------------------------------------------
1296
+
1297
+ const hookResult = await installSessionStartHook();
1298
+ if (hookResult === "installed") {
1299
+ console.log(
1300
+ ` ${green("✔")} ${bold("SessionStart hook")} installed in ${cyan("~/.claude/settings.json")}`,
1301
+ );
1302
+ } else if (hookResult === "already-present") {
1303
+ console.log(
1304
+ ` ${dim("✔")} ${dim("SessionStart hook already present in ~/.claude/settings.json")}`,
1305
+ );
1306
+ } else {
1307
+ // "failed" — warning only, does not block install
1308
+ console.log(
1309
+ ` ${yellow("⚠")} ${yellow("Could not write SessionStart hook to ~/.claude/settings.json")}`,
1310
+ );
1311
+ console.log(
1312
+ ` ${dim(" Run")} ${cyan("npx forgedock install")} ${dim("again to retry.")}`,
1313
+ );
1314
+ }
1315
+
1316
+ console.log("");
1317
+
953
1318
  // forge.yaml advisory — guide users to run init if config is missing
954
1319
  const forgeYamlPath = join(process.cwd(), "forge.yaml");
955
1320
  if (!existsSync(forgeYamlPath)) {
@@ -1011,10 +1376,39 @@ async function uninstall() {
1011
1376
  }
1012
1377
  });
1013
1378
 
1379
+ // On Windows, always attempt FORGE_HOME cleanup — do not gate on process.env.FORGE_HOME.
1380
+ // setx writes to HKCU\Environment (the registry) but does NOT update process.env in the
1381
+ // current running process. If the user runs install then uninstall in the same terminal
1382
+ // session, process.env.FORGE_HOME is undefined even though the registry key was written.
1383
+ // The REG delete call in Phase 5 is already wrapped in try/catch and handles the
1384
+ // key-not-found case gracefully, so unconditional detection is safe.
1385
+ const hasWindowsForgeHome = process.platform === "win32";
1386
+
1014
1387
  // Check for forge.yaml in cwd
1015
1388
  const forgeYamlPath = join(process.cwd(), "forge.yaml");
1016
1389
  const hasForgeYaml = existsSync(forgeYamlPath);
1017
1390
 
1391
+ // Check whether the SessionStart hook is present in settings.json
1392
+ let hasSessionStartHook = false;
1393
+ try {
1394
+ const settings = readClaudeSettings();
1395
+ const sessionStartEntries = settings?.hooks?.SessionStart;
1396
+ if (Array.isArray(sessionStartEntries)) {
1397
+ hasSessionStartHook = sessionStartEntries.some((entry) => {
1398
+ if (!entry || typeof entry !== "object") return false;
1399
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
1400
+ return hooks.some(
1401
+ (h) =>
1402
+ h &&
1403
+ typeof h.command === "string" &&
1404
+ isForgeSessionStartHook(h.command),
1405
+ );
1406
+ });
1407
+ }
1408
+ } catch {
1409
+ // settings.json unreadable — treat as not present
1410
+ }
1411
+
1018
1412
  // -------------------------------------------------------------------------
1019
1413
  // Phase 2: Pre-removal summary
1020
1414
  // -------------------------------------------------------------------------
@@ -1022,7 +1416,9 @@ async function uninstall() {
1022
1416
  if (
1023
1417
  toRemove.length === 0 &&
1024
1418
  profilesWithForgeHome.length === 0 &&
1025
- !hasForgeYaml
1419
+ !hasWindowsForgeHome &&
1420
+ !hasForgeYaml &&
1421
+ !hasSessionStartHook
1026
1422
  ) {
1027
1423
  console.log(
1028
1424
  ` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`,
@@ -1045,6 +1441,16 @@ async function uninstall() {
1045
1441
  ` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(HOME, "~")).join(", "))}`,
1046
1442
  );
1047
1443
  }
1444
+ if (hasWindowsForgeHome) {
1445
+ summaryLines.push(
1446
+ ` ${yellow("Env var")}: FORGE_HOME set in Windows user environment`,
1447
+ );
1448
+ }
1449
+ if (hasSessionStartHook) {
1450
+ summaryLines.push(
1451
+ ` ${yellow("Hook")}: SessionStart hook in ${dim("~/.claude/settings.json")}`,
1452
+ );
1453
+ }
1048
1454
  if (hasForgeYaml) {
1049
1455
  summaryLines.push(` ${cyan("forge.yaml")}: present in current directory`);
1050
1456
  }
@@ -1062,7 +1468,9 @@ async function uninstall() {
1062
1468
  const profileLabel =
1063
1469
  profilesWithForgeHome.length > 0
1064
1470
  ? ` and FORGE_HOME from ${profilesWithForgeHome.length === 1 ? "shell profile" : "shell profiles"}`
1065
- : "";
1471
+ : hasWindowsForgeHome
1472
+ ? " and FORGE_HOME from Windows user environment"
1473
+ : "";
1066
1474
 
1067
1475
  let confirmed = forceYes;
1068
1476
  if (!confirmed) {
@@ -1109,6 +1517,42 @@ async function uninstall() {
1109
1517
  // Phase 5: FORGE_HOME cleanup (only if detected)
1110
1518
  // -------------------------------------------------------------------------
1111
1519
 
1520
+ if (hasWindowsForgeHome) {
1521
+ // Windows: delete FORGE_HOME from HKCU\Environment via REG delete.
1522
+ // setx cannot delete registry keys — `setx VAR ""` writes an empty REG_SZ
1523
+ // entry rather than removing the key. REG delete is the correct approach
1524
+ // for HKCU and requires no elevation.
1525
+ console.log("");
1526
+ const cleanWindows = forceYes
1527
+ ? true
1528
+ : await confirm("Remove FORGE_HOME from Windows user environment?", true);
1529
+
1530
+ if (cleanWindows) {
1531
+ try {
1532
+ execFileSync(
1533
+ "REG",
1534
+ ["delete", "HKCU\\Environment", "/v", "FORGE_HOME", "/f"],
1535
+ { stdio: "pipe" },
1536
+ );
1537
+ console.log(
1538
+ ` ${green("✔")} Removed FORGE_HOME from Windows user environment`,
1539
+ );
1540
+ } catch {
1541
+ // REG delete unavailable or key not found — print PowerShell fallback
1542
+ console.log(
1543
+ ` ${yellow("⚠")} Could not remove FORGE_HOME automatically — run this in PowerShell:`,
1544
+ );
1545
+ console.log(
1546
+ ` ${cyan("[System.Environment]::SetEnvironmentVariable('FORGE_HOME', $null, 'User')")}`,
1547
+ );
1548
+ }
1549
+ } else {
1550
+ console.log(
1551
+ ` ${dim("Skipped — FORGE_HOME left in Windows user environment.")}`,
1552
+ );
1553
+ }
1554
+ }
1555
+
1112
1556
  if (profilesWithForgeHome.length > 0) {
1113
1557
  console.log("");
1114
1558
  const cleanProfiles = forceYes
@@ -1159,7 +1603,42 @@ async function uninstall() {
1159
1603
  }
1160
1604
 
1161
1605
  // -------------------------------------------------------------------------
1162
- // Phase 6: forge.yaml handling (default: keep)
1606
+ // Phase 6: SessionStart hook removal
1607
+ // -------------------------------------------------------------------------
1608
+
1609
+ if (hasSessionStartHook) {
1610
+ console.log("");
1611
+ const removeHook = forceYes
1612
+ ? true
1613
+ : await confirm(
1614
+ "Remove SessionStart hook from ~/.claude/settings.json?",
1615
+ true,
1616
+ );
1617
+
1618
+ if (removeHook) {
1619
+ const hookRemoveResult = await removeSessionStartHook();
1620
+ if (hookRemoveResult === "removed") {
1621
+ console.log(
1622
+ ` ${green("✔")} Removed SessionStart hook from ${dim("~/.claude/settings.json")}`,
1623
+ );
1624
+ } else if (hookRemoveResult === "not-present") {
1625
+ console.log(
1626
+ ` ${dim("✔")} SessionStart hook already absent from ~/.claude/settings.json`,
1627
+ );
1628
+ } else {
1629
+ console.log(
1630
+ ` ${red("✖")} Could not update ~/.claude/settings.json — remove the hook manually.`,
1631
+ );
1632
+ }
1633
+ } else {
1634
+ console.log(
1635
+ ` ${dim("Skipped — SessionStart hook left in settings.json.")}`,
1636
+ );
1637
+ }
1638
+ }
1639
+
1640
+ // -------------------------------------------------------------------------
1641
+ // Phase 7: forge.yaml handling (default: keep)
1163
1642
  // -------------------------------------------------------------------------
1164
1643
 
1165
1644
  if (hasForgeYaml) {
@@ -1183,18 +1662,23 @@ async function uninstall() {
1183
1662
  }
1184
1663
 
1185
1664
  // -------------------------------------------------------------------------
1186
- // Phase 7: Post-removal summary
1665
+ // Phase 8: Post-removal summary
1187
1666
  // -------------------------------------------------------------------------
1188
1667
 
1189
1668
  console.log("");
1190
1669
  console.log(
1191
1670
  `${green("Uninstall complete.")} ForgeDock commands have been removed.`,
1192
1671
  );
1193
- if (profilesWithForgeHome.length > 0) {
1672
+ if (profilesWithForgeHome.length > 0 && process.platform !== "win32") {
1194
1673
  console.log(
1195
1674
  ` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`,
1196
1675
  );
1197
1676
  }
1677
+ if (hasWindowsForgeHome) {
1678
+ console.log(
1679
+ ` ${dim("Restart your terminal to apply environment changes.")}`,
1680
+ );
1681
+ }
1198
1682
  console.log("");
1199
1683
  }
1200
1684
 
@@ -1215,6 +1699,9 @@ function queryNpmRegistry(pkg) {
1215
1699
  encoding: "utf-8",
1216
1700
  stdio: ["pipe", "pipe", "pipe"],
1217
1701
  timeout: 5000,
1702
+ // On Windows, npm is a .cmd shim — shell: true enables .cmd resolution.
1703
+ // On POSIX, npm resolves directly — no shell needed. (Ref: review-finding #413)
1704
+ shell: process.platform === "win32",
1218
1705
  });
1219
1706
  return result.trim() || null;
1220
1707
  } catch {
@@ -1556,12 +2043,16 @@ async function update() {
1556
2043
  // Show changelog between current HEAD and remote HEAD
1557
2044
  let changelogLines = [];
1558
2045
  try {
1559
- const log = execSync(`git log --oneline ${before}..origin/main`, {
1560
- cwd: FORGE_HOME,
1561
- encoding: "utf-8",
1562
- stdio: ["pipe", "pipe", "pipe"],
1563
- timeout: 5000,
1564
- }).trim();
2046
+ const log = execFileSync(
2047
+ "git",
2048
+ ["log", "--oneline", `${before}..origin/main`],
2049
+ {
2050
+ cwd: FORGE_HOME,
2051
+ encoding: "utf-8",
2052
+ stdio: ["pipe", "pipe", "pipe"],
2053
+ timeout: 5000,
2054
+ },
2055
+ ).trim();
1565
2056
  if (log) {
1566
2057
  changelogLines = log.split("\n").map((l) => ` ${dim(l)}`);
1567
2058
  }
@@ -1739,278 +2230,325 @@ async function update() {
1739
2230
  }
1740
2231
  }
1741
2232
 
1742
- async function init() {
1743
- // init() generates forge.yaml from prompts and git remote detection.
1744
- // It does NOT require gh CLI or gh auth for core operation (project board
1745
- // auto-discovery is optional and guarded separately). Only warn if Claude
1746
- // Code is missing — it's the primary consumer of the generated config.
1747
- try {
1748
- execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
1749
- } catch {
1750
- console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
1751
- console.log(
1752
- ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
1753
- );
1754
- console.log("");
2233
+ // ---------------------------------------------------------------------------
2234
+ // Backend-selection ladder helpers — detect, enrich via skill, enrich via API
2235
+ // ---------------------------------------------------------------------------
2236
+
2237
+ /**
2238
+ * Determine which enrichment backend is available.
2239
+ *
2240
+ * Ladder:
2241
+ * 1. skill — running inside a Claude Code session (CLAUDE_CODE_SESSION_ID set)
2242
+ * 2. api — ANTHROPIC_API_KEY env var is present and non-empty
2243
+ * 3. none — fall through to deterministic baseline
2244
+ *
2245
+ * @returns {'skill'|'api'|'none'}
2246
+ */
2247
+ function _detectBackend() {
2248
+ // Primary signal: Claude Code sets CLAUDE_CODE_SESSION_ID in its environment.
2249
+ // This is the only reliable indicator of an active CC session — non-TTY stdin
2250
+ // alone is not sufficient (CI/CD and piped scripts also run non-interactively).
2251
+ if (process.env.CLAUDE_CODE_SESSION_ID) {
2252
+ return "skill";
1755
2253
  }
1756
2254
 
1757
- console.log("");
1758
- console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
1759
- console.log("");
2255
+ // API backend: BYO key present.
2256
+ if (process.env.ANTHROPIC_API_KEY) {
2257
+ return "api";
2258
+ }
1760
2259
 
1761
- const cwd = process.cwd();
1762
- const outputPath = join(cwd, "forge.yaml");
1763
-
1764
- // ---------------------------------------------------------------------------
1765
- // Auto-detect defaults (silent — used as pre-fill values for prompts)
1766
- // ---------------------------------------------------------------------------
1767
-
1768
- // Detect git remote URL and parse owner/repo
1769
- let detectedOwner = "your-github-org";
1770
- let detectedRepo = "your-repo-name";
1771
- let remoteDetected = false;
2260
+ return "none";
2261
+ }
1772
2262
 
2263
+ /**
2264
+ * Enrich a ConfigDraft by invoking the /forgedock-init skill backend via the
2265
+ * Claude Code CLI (`claude -p`), with the full prompt piped via stdin.
2266
+ *
2267
+ * Uses execFileSync with the `input` option to pipe the prompt
2268
+ * (`/forgedock-init <draftJSON>`) to the claude process's stdin rather than
2269
+ * embedding it in a CLI argument. This avoids OS ARG_MAX limits for large
2270
+ * JSON payloads and keeps the argument list minimal.
2271
+ *
2272
+ * @param {object} draft - ConfigDraft from detectConfig()
2273
+ * @param {string} cwd - Working directory for the claude invocation
2274
+ * @returns {Promise<object>} Enriched ConfigDraft, or the original draft on failure
2275
+ */
2276
+ async function _enrichViaSkill(draft, cwd) {
1773
2277
  try {
1774
- const remoteUrl = execSync("git remote get-url origin", {
2278
+ const draftJson = JSON.stringify(draft);
2279
+ const output = execFileSync("claude", ["-p"], {
1775
2280
  cwd,
1776
2281
  encoding: "utf-8",
2282
+ timeout: 120000, // 2 minutes — enrichment may take a while for large repos
2283
+ input: `/forgedock-init ${draftJson}`,
1777
2284
  stdio: ["pipe", "pipe", "pipe"],
1778
- }).trim();
1779
-
1780
- // SSH: git@github.com:owner/repo.git
1781
- const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
1782
- // HTTPS: https://github.com/owner/repo.git
1783
- const httpsMatch = remoteUrl.match(
1784
- /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
1785
- );
2285
+ // On Windows, npm-installed CLIs like `claude` are .cmd shims. execFileSync
2286
+ // does not resolve .cmd extensions without a shell — shell: true enables the
2287
+ // cmd.exe lookup that finds claude.cmd on PATH. Safe here: executable name is
2288
+ // hardcoded, no user input is involved. (Ref: review-finding #382)
2289
+ shell: process.platform === "win32",
2290
+ });
1786
2291
 
1787
- if (sshMatch) {
1788
- detectedOwner = sshMatch[1];
1789
- detectedRepo = sshMatch[2];
1790
- remoteDetected = true;
1791
- } else if (httpsMatch) {
1792
- detectedOwner = httpsMatch[1];
1793
- detectedRepo = httpsMatch[2];
1794
- remoteDetected = true;
2292
+ return parseEnrichedDraft(output, draft);
2293
+ } catch (err) {
2294
+ // Log the failure at debug level but never surface to user — fall back silently.
2295
+ if (process.env.FORGEDOCK_DEBUG) {
2296
+ console.error(
2297
+ ` ${dim("[debug]")} skill enrichment failed: ${err.message}`,
2298
+ );
1795
2299
  }
1796
- } catch {
1797
- // No remote — use placeholders
2300
+ return draft;
1798
2301
  }
2302
+ }
1799
2303
 
1800
- // Detect default branch
1801
- let detectedDefault = "main";
1802
- try {
1803
- const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1804
- cwd,
1805
- encoding: "utf-8",
1806
- stdio: ["pipe", "pipe", "pipe"],
1807
- }).trim();
1808
- detectedDefault = headRef.replace(/^refs\/remotes\/origin\//, "");
1809
- } catch {
1810
- try {
1811
- const cur = execSync("git rev-parse --abbrev-ref HEAD", {
1812
- cwd,
1813
- encoding: "utf-8",
1814
- stdio: ["pipe", "pipe", "pipe"],
1815
- }).trim();
1816
- if (cur && cur !== "HEAD") detectedDefault = cur;
1817
- } catch {
1818
- // Keep "main"
1819
- }
1820
- }
2304
+ /**
2305
+ * Build an optionalSections object from an enriched ConfigDraft, promoting
2306
+ * sections that have medium-or-higher confidence to active (non-commented) YAML.
2307
+ * Sections with only low-confidence fields are omitted so they remain commented out.
2308
+ *
2309
+ * @param {object} enrichedDraft - ConfigDraft returned by an enrichment backend
2310
+ * @returns {object} optionalSections compatible with _writeForgeYaml / buildForgeYamlContent
2311
+ */
2312
+ function _optionalSectionsFromDraft(enrichedDraft) {
2313
+ const sections = {};
1821
2314
 
1822
- // Detect staging branch
1823
- let detectedStaging = detectedDefault;
1824
- try {
1825
- const remoteBranches = execSync("git branch -r", {
1826
- cwd,
1827
- encoding: "utf-8",
1828
- stdio: ["pipe", "pipe", "pipe"],
1829
- });
1830
- if (remoteBranches.includes("origin/staging")) {
1831
- detectedStaging = "staging";
1832
- }
1833
- } catch {
1834
- // Keep default
2315
+ // project_board — include if project_number has at least medium confidence
2316
+ const pb = enrichedDraft.project_board;
2317
+ if (
2318
+ pb &&
2319
+ pb.project_number &&
2320
+ pb.project_number.confidence !== "low" &&
2321
+ pb.project_number.value
2322
+ ) {
2323
+ sections.projectBoard = {
2324
+ projectNumber: parseInt(pb.project_number.value, 10) || 1,
2325
+ projectId: pb.project_id?.value || "",
2326
+ fieldIds: {
2327
+ status: pb.field_ids?.status?.value || "",
2328
+ lane: pb.field_ids?.lane?.value || "",
2329
+ component: pb.field_ids?.component?.value || "",
2330
+ priority: pb.field_ids?.priority?.value || "",
2331
+ workflow: pb.field_ids?.workflow?.value || "",
2332
+ },
2333
+ optionIds: pb.option_ids || {},
2334
+ };
1835
2335
  }
1836
2336
 
1837
- // Derive project name from repo slug
1838
- const detectedName = detectedRepo
1839
- .split(/[-_]/)
1840
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1841
- .join(" ");
1842
-
1843
- // Non-TTY: skip interactive prompts and use detected values directly
1844
- if (!process.stdin.isTTY) {
1845
- if (!remoteDetected) {
1846
- console.log(
1847
- ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1848
- );
2337
+ // repos.satellites — include if at least one satellite found
2338
+ const repos = enrichedDraft.repos;
2339
+ if (
2340
+ repos &&
2341
+ repos.satellites &&
2342
+ Array.isArray(repos.satellites) &&
2343
+ repos.satellites.length > 0
2344
+ ) {
2345
+ const first = repos.satellites[0];
2346
+ if (first && first.prefix && first.prefix.value) {
2347
+ sections.multiRepo = {
2348
+ prefix: first.prefix.value || "sat",
2349
+ satelliteRepo:
2350
+ (first.repo?.value || "").split("/").pop() || "satellite",
2351
+ satelliteBranch: first.staging_branch?.value || "main",
2352
+ };
1849
2353
  }
1850
- // Silent write — same as pre-interactive behavior
1851
- _writeForgeYaml({
1852
- outputPath,
1853
- cwd,
1854
- owner: detectedOwner,
1855
- repo: detectedRepo,
1856
- projectName: detectedName,
1857
- description: "",
1858
- root: cwd,
1859
- worktreeBase: join(cwd, ".claude", "worktrees"),
1860
- defaultBranch: detectedDefault,
1861
- stagingBranch: detectedStaging,
1862
- });
1863
- console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1864
- console.log("");
1865
- await injectClaudeMd(cwd);
1866
- console.log("");
1867
- _printNextSteps({ remoteDetected });
1868
- await validate(outputPath);
1869
- return;
1870
2354
  }
1871
2355
 
1872
- // ---------------------------------------------------------------------------
1873
- // Interactive flow — prompt for each required section
1874
- // ---------------------------------------------------------------------------
1875
-
1876
- // Loop until user confirms the preview
1877
- let confirmed = false;
1878
-
1879
- while (!confirmed) {
1880
- console.log(
1881
- dim(
1882
- " Auto-detected values are shown as defaults. Press Enter to accept.",
1883
- ),
1884
- );
1885
- console.log("");
2356
+ // review — include if tech_stack has at least medium confidence
2357
+ const review = enrichedDraft.review;
2358
+ if (
2359
+ review &&
2360
+ review.tech_stack &&
2361
+ review.tech_stack.confidence !== "low" &&
2362
+ review.tech_stack.value
2363
+ ) {
2364
+ sections.review = {
2365
+ techStack: review.tech_stack.value,
2366
+ context: review.context?.value || "",
2367
+ };
2368
+ }
1886
2369
 
1887
- // --- Project section ---
1888
- console.log(bold(" Project"));
2370
+ // verification — include if health_endpoint has at least medium confidence
2371
+ const verification = enrichedDraft.verification;
2372
+ if (
2373
+ verification &&
2374
+ verification.health_endpoint &&
2375
+ verification.health_endpoint.confidence !== "low" &&
2376
+ verification.health_endpoint.value
2377
+ ) {
2378
+ sections.verification = {
2379
+ healthEndpoint: verification.health_endpoint.value,
2380
+ };
2381
+ }
1889
2382
 
1890
- const ownerInput = await input(
1891
- " GitHub owner (org or user)",
1892
- detectedOwner,
1893
- );
1894
- const repoInput = await input(" Repository name", detectedRepo);
1895
- const nameInput = await input(
1896
- " Project name",
1897
- detectedName !== "Your-repo-name"
1898
- ? detectedName
1899
- : repoInput
1900
- .split(/[-_]/)
1901
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1902
- .join(" "),
1903
- );
1904
- const descInput = await input(" Brief description", "");
2383
+ return sections;
2384
+ }
1905
2385
 
1906
- // --- Paths section ---
1907
- console.log("");
1908
- console.log(bold(" Paths"));
2386
+ /**
2387
+ * Returns true if an enrichment backend produced enriched data for the draft.
2388
+ * Used in three places: skill-backend spinner display, api-backend spinner display,
2389
+ * and the enrichmentSucceeded gate that controls optional-section auto-population.
2390
+ *
2391
+ * @param {object} draft - The (possibly) enriched draft
2392
+ * @param {object} baseDraft - The original un-enriched draft
2393
+ * @returns {boolean}
2394
+ */
2395
+ function _isEnriched(draft, baseDraft) {
2396
+ return draft !== baseDraft || Boolean(draft.meta?.enriched);
2397
+ }
1909
2398
 
1910
- const rootInput = await input(" Repository root (absolute path)", cwd);
1911
- const worktreeInput = await input(
1912
- " Worktree base (for git worktrees)",
1913
- join(rootInput || cwd, ".claude", "worktrees"),
1914
- );
2399
+ /**
2400
+ * Module-level constant — single source of truth for the optional section
2401
+ * choice list used in both the enrichment and manual branches of init().
2402
+ * Add new sections here; all call sites update automatically.
2403
+ */
2404
+ const OPTIONAL_SECTION_CHOICES = [
2405
+ {
2406
+ label:
2407
+ "Project Board — GitHub Projects v2 integration for workflow tracking",
2408
+ value: "projectBoard",
2409
+ },
2410
+ {
2411
+ label: "Multi-Repo — Satellite repos for cross-repo milestones",
2412
+ value: "multiRepo",
2413
+ },
2414
+ {
2415
+ label: "Review Context — Tech stack and conventions for PR review agents",
2416
+ value: "review",
2417
+ },
2418
+ {
2419
+ label: "Verification — Health check endpoints and response patterns",
2420
+ value: "verification",
2421
+ },
2422
+ ];
1915
2423
 
1916
- // --- Branches section ---
1917
- console.log("");
1918
- console.log(bold(" Branches"));
2424
+ /**
2425
+ * Maps camelCase optional-section keys to their forge.yaml section names.
2426
+ * Used for display messages after enrichment and after writing forge.yaml.
2427
+ */
2428
+ const SECTION_KEY_TO_YAML_NAME = {
2429
+ projectBoard: "project_board",
2430
+ multiRepo: "repos",
2431
+ review: "review",
2432
+ verification: "verification",
2433
+ };
1919
2434
 
1920
- const defaultBranchInput = await input(" Default branch", detectedDefault);
1921
- const stagingBranchInput = await input(
1922
- " Staging branch (PR target for fast-lane changes)",
1923
- detectedStaging,
2435
+ /**
2436
+ * Non-TTY (headless) init path: enrich (already done), silently write forge.yaml,
2437
+ * inject CLAUDE.md, print next steps, and validate.
2438
+ *
2439
+ * Extracted from init() to reduce its size.
2440
+ *
2441
+ * @param {object} draft - Enriched (or baseline) ConfigDraft
2442
+ * @param {string} outputPath - Absolute path to forge.yaml
2443
+ * @param {string} cwd - Current working directory
2444
+ * @param {boolean} remoteDetected - Whether a git remote was detected
2445
+ */
2446
+ async function _initNonTTY(draft, outputPath, cwd, remoteDetected) {
2447
+ if (!remoteDetected) {
2448
+ console.log(
2449
+ ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1924
2450
  );
2451
+ }
2452
+ // Derive optional sections from the enriched draft for the silent write.
2453
+ const enrichedSections = _optionalSectionsFromDraft(draft);
2454
+ _writeForgeYaml({
2455
+ outputPath,
2456
+ cwd,
2457
+ owner: draft.project.owner.value,
2458
+ repo: draft.project.repo.value,
2459
+ projectName: draft.project.name.value,
2460
+ description: "",
2461
+ root: cwd,
2462
+ worktreeBase: join(cwd, ".claude", "worktrees"),
2463
+ defaultBranch: draft.branches.default.value,
2464
+ stagingBranch: draft.branches.staging.value,
2465
+ optionalSections: enrichedSections,
2466
+ });
2467
+ console.log(` ${GREEN}Created${RESET}: forge.yaml`);
2468
+ console.log("");
2469
+ await injectClaudeMd(cwd);
2470
+ console.log("");
2471
+ _printNextSteps({ remoteDetected });
2472
+ await validate(outputPath);
2473
+ }
1925
2474
 
1926
- // --- Preview ---
1927
- console.log("");
1928
-
1929
- const previewLines = buildForgeYamlContent({
1930
- owner: ownerInput,
1931
- repo: repoInput,
1932
- projectName: nameInput,
1933
- description: descInput,
1934
- root: rootInput || cwd,
1935
- worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
1936
- defaultBranch: defaultBranchInput,
1937
- stagingBranch: stagingBranchInput,
1938
- });
2475
+ /**
2476
+ * Interactive optional-section collection wizard.
2477
+ *
2478
+ * When enrichmentSucceeded, pre-populates sections from the enriched draft and
2479
+ * offers a multi-select for any remaining sections not auto-discovered.
2480
+ * When enrichment is unavailable (manualMode or no backend), presents the full
2481
+ * multi-select wizard for all sections.
2482
+ *
2483
+ * Returns the populated optionalSections record for use by _writeForgeYaml.
2484
+ *
2485
+ * @param {object} opts
2486
+ * @param {string} opts.ownerInput - Sanitized owner value from review screen
2487
+ * @param {string} opts.detectedOwner - Owner value from detection baseline
2488
+ * @param {string} opts.repoInput - Sanitized repo value from review screen
2489
+ * @param {string} opts.detectedRepo - Repo value from detection baseline
2490
+ * @param {boolean} opts.enrichmentSucceeded - Whether AI enrichment produced a result
2491
+ * @param {object} opts.draft - The (possibly enriched) ConfigDraft
2492
+ * @returns {Promise<Record<string, object>>} Populated optional sections map
2493
+ */
2494
+ async function _collectOptionalSections({
2495
+ ownerInput,
2496
+ detectedOwner,
2497
+ repoInput,
2498
+ detectedRepo,
2499
+ enrichmentSucceeded,
2500
+ draft,
2501
+ }) {
2502
+ /** @type {Record<string, object>} */
2503
+ const optionalSections = {};
2504
+
2505
+ if (enrichmentSucceeded) {
2506
+ // Lift discovered sections directly from the enriched draft.
2507
+ // Only sections with medium-or-higher confidence are included; low-confidence
2508
+ // sections remain commented-out in the output (no behaviour change for users
2509
+ // without an enrichment backend).
2510
+ Object.assign(optionalSections, _optionalSectionsFromDraft(draft));
2511
+
2512
+ // Surface which sections were auto-populated by enrichment.
2513
+ const autoSections = Object.keys(optionalSections);
2514
+ if (autoSections.length > 0) {
2515
+ console.log("");
2516
+ console.log(
2517
+ ` ${GREEN}Auto-configured${RESET} by enrichment: ${autoSections
2518
+ .map((s) => SECTION_KEY_TO_YAML_NAME[s])
2519
+ .join(", ")}`,
2520
+ );
2521
+ }
1939
2522
 
1940
- // Show preview in a box
1941
- const previewDisplay = previewLines
1942
- .split("\n")
1943
- .slice(0, 30) // show first 30 lines of required sections only
1944
- .join("\n");
2523
+ // Still offer the multi-select so the user can add sections that enrichment
2524
+ // couldn't discover (e.g. project_board when gh is unauthenticated).
2525
+ const alreadyConfigured = new Set(autoSections);
1945
2526
 
1946
- process.stdout.write(
1947
- box(previewDisplay, {
1948
- title: "forge.yaml preview (required sections)",
1949
- padding: 1,
1950
- }),
2527
+ const remainingSectionChoices = OPTIONAL_SECTION_CHOICES.filter(
2528
+ (c) => !alreadyConfigured.has(c.value),
1951
2529
  );
1952
2530
 
1953
- confirmed = await confirm(" Write this forge.yaml?", true);
1954
-
1955
- if (!confirmed) {
1956
- console.log("");
1957
- console.log(dim(" Starting over — re-enter values below."));
1958
- console.log("");
1959
- } else {
1960
- // -----------------------------------------------------------------------
1961
- // Optional sections — multi-select, then guided prompts per section
1962
- // -----------------------------------------------------------------------
2531
+ if (remainingSectionChoices.length > 0) {
1963
2532
  console.log("");
1964
- console.log(bold(" Optional Sections"));
1965
- console.log(
1966
- dim(
1967
- " Select sections to configure now. Unselected sections are written as",
1968
- ),
1969
- );
2533
+ console.log(bold(" Additional Optional Sections"));
1970
2534
  console.log(
1971
2535
  dim(
1972
- " commented-out placeholders — you can enable them later by editing forge.yaml.",
2536
+ " The following sections were not auto-discovered. Select any to configure now.",
1973
2537
  ),
1974
2538
  );
1975
2539
  console.log("");
1976
2540
 
1977
- const OPTIONAL_SECTION_CHOICES = [
1978
- {
1979
- label:
1980
- "Project Board — GitHub Projects v2 integration for workflow tracking",
1981
- value: "projectBoard",
1982
- },
1983
- {
1984
- label: "Multi-Repo — Satellite repos for cross-repo milestones",
1985
- value: "multiRepo",
1986
- },
1987
- {
1988
- label:
1989
- "Review Context — Tech stack and conventions for PR review agents",
1990
- value: "review",
1991
- },
1992
- {
1993
- label:
1994
- "Verification — Health check endpoints and response patterns",
1995
- value: "verification",
1996
- },
1997
- ];
1998
-
1999
2541
  const selectedSections = await multiSelect(
2000
- " Which optional sections would you like to configure?",
2001
- OPTIONAL_SECTION_CHOICES,
2542
+ " Which additional sections would you like to configure?",
2543
+ remainingSectionChoices,
2002
2544
  );
2003
2545
 
2004
- /** @type {Record<string, object>} */
2005
- const optionalSections = {};
2006
-
2007
- // --- Project Board prompts ---
2546
+ // --- Project Board prompts (manual, only when enrichment didn't find it) ---
2008
2547
  if (selectedSections.includes("projectBoard")) {
2009
2548
  const discovered = await discoverProjectBoard(
2010
2549
  ownerInput || detectedOwner,
2011
2550
  );
2012
2551
  if (discovered) {
2013
- // Auto-discovery succeeded — use resolved IDs
2014
2552
  optionalSections.projectBoard = {
2015
2553
  projectNumber: discovered.projectNumber,
2016
2554
  projectId: discovered.projectId,
@@ -2018,7 +2556,6 @@ async function init() {
2018
2556
  optionIds: discovered.optionIds,
2019
2557
  };
2020
2558
  } else {
2021
- // Fallback: manual entry (no regression from previous behaviour)
2022
2559
  console.log("");
2023
2560
  console.log(bold(" Project Board (manual)"));
2024
2561
  console.log(
@@ -2099,122 +2636,393 @@ async function init() {
2099
2636
  );
2100
2637
  optionalSections.verification = { healthEndpoint };
2101
2638
  }
2639
+ }
2640
+ } else {
2641
+ // No enrichment — use the full interactive multi-select wizard (existing behaviour).
2642
+ console.log("");
2643
+ console.log(bold(" Optional Sections"));
2644
+ console.log(
2645
+ dim(
2646
+ " Select sections to configure now. Unselected sections are written as",
2647
+ ),
2648
+ );
2649
+ console.log(
2650
+ dim(
2651
+ " commented-out placeholders — you can enable them later by editing forge.yaml.",
2652
+ ),
2653
+ );
2654
+ console.log("");
2102
2655
 
2103
- // -----------------------------------------------------------------------
2104
- // Handle existing forge.yaml with confirmation
2105
- // -----------------------------------------------------------------------
2106
- if (existsSync(outputPath)) {
2656
+ const selectedSections = await multiSelect(
2657
+ " Which optional sections would you like to configure?",
2658
+ OPTIONAL_SECTION_CHOICES,
2659
+ );
2660
+
2661
+ // --- Project Board prompts ---
2662
+ if (selectedSections.includes("projectBoard")) {
2663
+ const discovered = await discoverProjectBoard(
2664
+ ownerInput || detectedOwner,
2665
+ );
2666
+ if (discovered) {
2667
+ optionalSections.projectBoard = {
2668
+ projectNumber: discovered.projectNumber,
2669
+ projectId: discovered.projectId,
2670
+ fieldIds: discovered.fieldIds,
2671
+ optionIds: discovered.optionIds,
2672
+ };
2673
+ } else {
2107
2674
  console.log("");
2108
- console.log(` ${YELLOW}forge.yaml already exists.${RESET}`);
2109
- const shouldOverwrite = await confirm(
2110
- " Back up existing forge.yaml and overwrite?",
2111
- true,
2675
+ console.log(bold(" Project Board (manual)"));
2676
+ console.log(
2677
+ dim(
2678
+ " Find your project number: gh project list --owner " +
2679
+ (ownerInput || detectedOwner),
2680
+ ),
2112
2681
  );
2113
- if (!shouldOverwrite) {
2114
- console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
2115
- console.log("");
2116
- return;
2117
- }
2118
-
2119
- // Backup with timestamped name if .bak already exists (fix from #36)
2120
- const baseBak = join(cwd, "forge.yaml.bak");
2121
- const backupPath = existsSync(baseBak)
2122
- ? join(
2123
- cwd,
2124
- `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
2125
- )
2126
- : baseBak;
2127
- const backupName = basename(backupPath);
2128
- renameSync(outputPath, backupPath);
2129
- console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
2130
- }
2131
-
2132
- // Write the file
2133
- _writeForgeYaml({
2134
- outputPath,
2135
- cwd,
2136
- owner: ownerInput,
2137
- repo: repoInput,
2138
- projectName: nameInput,
2139
- description: descInput,
2140
- root: rootInput || cwd,
2141
- worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
2142
- defaultBranch: defaultBranchInput,
2143
- stagingBranch: stagingBranchInput,
2144
- optionalSections,
2145
- });
2146
-
2147
- console.log("");
2148
- console.log(` ${GREEN}Created${RESET}: forge.yaml`);
2149
- if (selectedSections.length > 0) {
2682
+ const projectNumber = await input(
2683
+ " GitHub Projects v2 project number",
2684
+ "1",
2685
+ );
2686
+ optionalSections.projectBoard = {
2687
+ projectNumber: parseInt(projectNumber, 10) || 1,
2688
+ };
2150
2689
  console.log(
2151
- ` ${GREEN}Configured${RESET}: ${selectedSections
2152
- .map(
2153
- (s) =>
2154
- ({
2155
- projectBoard: "project_board",
2156
- multiRepo: "repos",
2157
- review: "review",
2158
- verification: "verification",
2159
- })[s],
2160
- )
2161
- .join(", ")}`,
2690
+ dim(
2691
+ " Field IDs (PVT_/PVTSSF_ strings) must be added manually after generation.",
2692
+ ),
2693
+ );
2694
+ console.log(
2695
+ dim(
2696
+ " Run: gh project field-list " +
2697
+ (projectNumber || "1") +
2698
+ " --owner " +
2699
+ (ownerInput || detectedOwner),
2700
+ ),
2162
2701
  );
2163
2702
  }
2703
+ }
2704
+
2705
+ // --- Multi-Repo prompts ---
2706
+ if (selectedSections.includes("multiRepo")) {
2707
+ console.log("");
2708
+ console.log(bold(" Multi-Repo"));
2709
+ console.log(
2710
+ dim(" Configure one satellite repo (add more by editing forge.yaml)."),
2711
+ );
2712
+ const prefix = await input(
2713
+ " Satellite repo prefix (e.g. 'mcp', 'sdk')",
2714
+ "sat",
2715
+ );
2716
+ const satelliteRepo = await input(
2717
+ " Satellite repo name (just the name, owner will be reused)",
2718
+ "your-satellite-repo",
2719
+ );
2720
+ const satelliteBranch = await input(
2721
+ " Satellite default/staging branch",
2722
+ "main",
2723
+ );
2724
+ optionalSections.multiRepo = { prefix, satelliteRepo, satelliteBranch };
2725
+ }
2726
+
2727
+ // --- Review Context prompts ---
2728
+ if (selectedSections.includes("review")) {
2164
2729
  console.log("");
2165
- await injectClaudeMd(cwd);
2730
+ console.log(bold(" Review Context"));
2731
+ const techStack = await input(
2732
+ " Tech stack (e.g. Next.js, FastAPI, PostgreSQL)",
2733
+ "Node.js, TypeScript",
2734
+ );
2735
+ const context = await input(
2736
+ " Architecture notes (one line; expand in forge.yaml later)",
2737
+ "",
2738
+ );
2739
+ optionalSections.review = { techStack, context };
2740
+ }
2741
+
2742
+ // --- Verification prompts ---
2743
+ if (selectedSections.includes("verification")) {
2166
2744
  console.log("");
2167
- _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
2168
- await validate(outputPath);
2745
+ console.log(bold(" Verification"));
2746
+ const healthEndpoint = await input(
2747
+ " Health check endpoint URL",
2748
+ `https://api.${repoInput || detectedRepo}.io/health`,
2749
+ );
2750
+ optionalSections.verification = { healthEndpoint };
2169
2751
  }
2170
2752
  }
2753
+
2754
+ return optionalSections;
2171
2755
  }
2172
2756
 
2173
- // ---------------------------------------------------------------------------
2174
- // CLAUDE.md Integration — injectClaudeMd()
2175
- // ---------------------------------------------------------------------------
2757
+ async function init() {
2758
+ // init() generates forge.yaml from prompts and git remote detection.
2759
+ // It does NOT require gh CLI or gh auth for core operation (project board
2760
+ // auto-discovery is optional and guarded separately). Only warn if Claude
2761
+ // Code is missing — it's the primary consumer of the generated config.
2762
+ try {
2763
+ execFileSync("claude", ["--version"], {
2764
+ stdio: ["pipe", "pipe", "pipe"],
2765
+ timeout: 5000,
2766
+ // On Windows, claude is a .cmd shim — execFileSync needs shell: true to find it.
2767
+ // (Ref: review-finding #382)
2768
+ shell: process.platform === "win32",
2769
+ });
2770
+ } catch {
2771
+ console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
2772
+ console.log(
2773
+ ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
2774
+ );
2775
+ console.log("");
2776
+ }
2176
2777
 
2177
- const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
2178
- const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
2778
+ console.log("");
2779
+ console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
2780
+ console.log("");
2179
2781
 
2180
- /**
2181
- * Extract the `description:` value from a command file's YAML frontmatter.
2182
- * Returns null if no frontmatter or no description is found.
2183
- *
2184
- * @param {string} content - Full file content
2185
- * @returns {string|null}
2186
- */
2187
- function _extractFrontmatterDescription(content) {
2188
- // Match YAML frontmatter block: starts with ---, ends with ---
2189
- const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2190
- if (!fmMatch) return null;
2191
- const fm = fmMatch[1];
2192
- // Extract description: value (may span the rest of the line)
2193
- const descMatch = fm.match(/^description:\s*(.+)$/m);
2194
- if (!descMatch) return null;
2195
- return descMatch[1].trim();
2196
- }
2782
+ const cwd = process.cwd();
2783
+ const outputPath = join(cwd, "forge.yaml");
2197
2784
 
2198
- /**
2199
- * Build the managed ForgeDock block content (between markers, not including them).
2200
- * Reads all commands/*.md files, extracts frontmatter descriptions, and generates
2201
- * a concise command index.
2202
- *
2203
- * @returns {Promise<string>} Block content
2204
- */
2205
- async function _buildForgeDockBlock() {
2206
- const files = await findMarkdownFiles(COMMANDS_DIR);
2785
+ // ---------------------------------------------------------------------------
2786
+ // Step 1: Auto-detect defaults via init-detect module.
2787
+ // Returns a ConfigDraft with per-field { value, confidence, source, why }.
2788
+ // ---------------------------------------------------------------------------
2207
2789
 
2208
- /** @type {Array<{name: string, description: string}>} */
2209
- const commands = [];
2790
+ const baseDraft = await detectConfig(cwd);
2791
+ const remoteDetected = baseDraft.meta.remoteDetected;
2210
2792
 
2211
- for (const file of files) {
2212
- let content = "";
2213
- try {
2214
- content = readFileSync(file, "utf-8");
2215
- } catch {
2216
- continue; // skip unreadable files
2217
- }
2793
+ // ---------------------------------------------------------------------------
2794
+ // Step 2: Backend-selection ladder — enrich the draft when possible.
2795
+ // Ladder: skill (Claude Code session) → api (ANTHROPIC_API_KEY) → none (baseline)
2796
+ // --manual bypasses enrichment entirely: presents the annotated review screen
2797
+ // with detection baseline values only — no AI-enriched suggestions applied.
2798
+ // ---------------------------------------------------------------------------
2799
+
2800
+ const backend = _detectBackend();
2801
+ let draft = baseDraft;
2802
+
2803
+ if (manualMode) {
2804
+ // Escape hatch: skip all AI enrichment; review screen shows detection baseline.
2805
+ console.log(
2806
+ ` ${CYAN}--manual${RESET} mode: skipping autopilot enrichment — annotated review screen`,
2807
+ );
2808
+ console.log("");
2809
+ } else if (backend === "skill") {
2810
+ const s = spinner("Enriching config via skill backend…");
2811
+ draft = await _enrichViaSkill(baseDraft, cwd);
2812
+ const enriched = _isEnriched(draft, baseDraft);
2813
+ s.stop(
2814
+ enriched ? "success" : "warn",
2815
+ enriched
2816
+ ? `${green("[✓]")} Config enriched via Claude Code`
2817
+ : `${yellow("[!]")} Skill enrichment unavailable — using detected baseline`,
2818
+ );
2819
+ } else if (backend === "api") {
2820
+ const s = spinner("Enriching config via Anthropic API…");
2821
+ draft = await enrichViaAPI(baseDraft);
2822
+ const enriched = _isEnriched(draft, baseDraft);
2823
+ s.stop(
2824
+ enriched ? "success" : "warn",
2825
+ enriched
2826
+ ? `${green("[✓]")} Config enriched via Anthropic API`
2827
+ : `${yellow("[!]")} API enrichment unavailable — using detected baseline`,
2828
+ );
2829
+ }
2830
+ // backend === 'none' (or manualMode): proceed silently with the deterministic baseline
2831
+
2832
+ const detectedOwner = draft.project.owner.value;
2833
+ const detectedRepo = draft.project.repo.value;
2834
+ const detectedName = draft.project.name.value;
2835
+ const detectedDefault = draft.branches.default.value;
2836
+ const detectedStaging = draft.branches.staging.value;
2837
+
2838
+ // Non-TTY: enrich (done above) then silently write without interactive prompts.
2839
+ if (!process.stdin.isTTY) {
2840
+ await _initNonTTY(draft, outputPath, cwd, remoteDetected);
2841
+ return;
2842
+ }
2843
+
2844
+ // ---------------------------------------------------------------------------
2845
+ // Step 3: Annotated review screen — single screen replacing the per-field wizard
2846
+ // ---------------------------------------------------------------------------
2847
+
2848
+ // Read existing content for diff-style display if forge.yaml already exists.
2849
+ let existingContent = "";
2850
+ const hasExistingConfig = existsSync(outputPath);
2851
+ if (hasExistingConfig) {
2852
+ try {
2853
+ existingContent = readFileSync(outputPath, "utf-8");
2854
+ } catch {
2855
+ // Best-effort — missing or unreadable existing file is handled below
2856
+ }
2857
+ }
2858
+
2859
+ // Show the annotated review screen. The enriched draft populates confidence
2860
+ // badges from both init-detect and init-enrich. Returns accepted/edited values
2861
+ // plus the list of field keys that had low confidence (for TODO comment injection).
2862
+ // --verbose: pass showSources:true so the screen surfaces each field's detection
2863
+ // source and confidence rationale (the .source and .why metadata from ConfigDraft).
2864
+ const reviewed = await annotatedReviewScreen(draft, {
2865
+ hasExistingConfig,
2866
+ existingContent,
2867
+ showSources: verboseMode,
2868
+ });
2869
+
2870
+ const ownerInput = _sanitizeYamlValue(reviewed.owner || detectedOwner);
2871
+ const repoInput = _sanitizeYamlValue(reviewed.repo || detectedRepo);
2872
+ const nameInput = _sanitizeYamlValue(reviewed.name || detectedName);
2873
+ const descInput = _sanitizeYamlValue(reviewed.description || "");
2874
+ const rootInput = reviewed.root || cwd;
2875
+ const worktreeInput =
2876
+ reviewed.worktreeBase || join(cwd, ".claude", "worktrees");
2877
+ const defaultBranchInput = _sanitizeYamlValue(
2878
+ reviewed.defaultBranch || detectedDefault,
2879
+ );
2880
+ const stagingBranchInput = _sanitizeYamlValue(
2881
+ reviewed.stagingBranch || detectedStaging,
2882
+ );
2883
+
2884
+ // -----------------------------------------------------------------------
2885
+ // Optional sections — pre-populate from enriched draft if available,
2886
+ // otherwise fall back to interactive multi-select + guided prompts.
2887
+ // -----------------------------------------------------------------------
2888
+
2889
+ // manualMode skips enrichment — enrichmentSucceeded is false so optional sections
2890
+ // use the interactive multi-select path rather than the enrichment-lifted path.
2891
+ const enrichmentSucceeded =
2892
+ !manualMode && backend !== "none" && _isEnriched(draft, baseDraft);
2893
+
2894
+ const optionalSections = await _collectOptionalSections({
2895
+ ownerInput,
2896
+ detectedOwner,
2897
+ repoInput,
2898
+ detectedRepo,
2899
+ enrichmentSucceeded,
2900
+ draft,
2901
+ });
2902
+
2903
+ // -----------------------------------------------------------------------
2904
+ // Handle existing forge.yaml with backup
2905
+ // -----------------------------------------------------------------------
2906
+ if (hasExistingConfig) {
2907
+ console.log("");
2908
+ const shouldOverwrite = await confirm(
2909
+ " Back up existing forge.yaml and overwrite?",
2910
+ true,
2911
+ );
2912
+ if (!shouldOverwrite) {
2913
+ console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
2914
+ console.log("");
2915
+ return;
2916
+ }
2917
+
2918
+ // Backup with timestamped name if .bak already exists (fix from #36)
2919
+ const baseBak = join(cwd, "forge.yaml.bak");
2920
+ const backupPath = existsSync(baseBak)
2921
+ ? join(
2922
+ cwd,
2923
+ `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
2924
+ )
2925
+ : baseBak;
2926
+ const backupName = basename(backupPath);
2927
+ renameSync(outputPath, backupPath);
2928
+ console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
2929
+ }
2930
+
2931
+ // Write the file — passing lowConfidenceKeys so _writeForgeYaml can inject
2932
+ // # TODO(forgedock:<field>) comments for fields that were low-confidence.
2933
+ _writeForgeYaml({
2934
+ outputPath,
2935
+ cwd,
2936
+ owner: ownerInput,
2937
+ repo: repoInput,
2938
+ projectName: nameInput,
2939
+ description: descInput,
2940
+ root: rootInput || cwd,
2941
+ worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
2942
+ defaultBranch: defaultBranchInput,
2943
+ stagingBranch: stagingBranchInput,
2944
+ optionalSections,
2945
+ lowConfidenceKeys: reviewed.lowConfidenceKeys ?? [],
2946
+ });
2947
+
2948
+ console.log("");
2949
+ console.log(` ${GREEN}Created${RESET}: forge.yaml`);
2950
+ const configuredSectionKeys = Object.keys(optionalSections);
2951
+ if (configuredSectionKeys.length > 0) {
2952
+ console.log(
2953
+ ` ${GREEN}Configured${RESET}: ${configuredSectionKeys
2954
+ .map((s) => SECTION_KEY_TO_YAML_NAME[s])
2955
+ .filter(Boolean)
2956
+ .join(", ")}`,
2957
+ );
2958
+ }
2959
+
2960
+ // Show TODO flag summary for low-confidence fields written with comments
2961
+ if (reviewed.lowConfidenceKeys && reviewed.lowConfidenceKeys.length > 0) {
2962
+ console.log("");
2963
+ console.log(
2964
+ ` ${YELLOW}⚠${RESET} ${reviewed.lowConfidenceKeys.length} field(s) written with ${CYAN}# TODO(forgedock:<field>)${RESET} comments:`,
2965
+ );
2966
+ for (const key of reviewed.lowConfidenceKeys) {
2967
+ console.log(` ${dim("·")} ${key}`);
2968
+ }
2969
+ console.log(
2970
+ ` ${dim("Search for")} ${CYAN}TODO(forgedock:${RESET} ${dim("in forge.yaml to find them.")}`,
2971
+ );
2972
+ }
2973
+
2974
+ console.log("");
2975
+ await injectClaudeMd(cwd);
2976
+ console.log("");
2977
+ _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
2978
+ await validate(outputPath);
2979
+ }
2980
+
2981
+ // ---------------------------------------------------------------------------
2982
+ // CLAUDE.md Integration — injectClaudeMd()
2983
+ // ---------------------------------------------------------------------------
2984
+
2985
+ const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
2986
+ const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
2987
+
2988
+ /**
2989
+ * Extract the `description:` value from a command file's YAML frontmatter.
2990
+ * Returns null if no frontmatter or no description is found.
2991
+ *
2992
+ * @param {string} content - Full file content
2993
+ * @returns {string|null}
2994
+ */
2995
+ function _extractFrontmatterDescription(content) {
2996
+ // Match YAML frontmatter block: starts with ---, ends with ---
2997
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2998
+ if (!fmMatch) return null;
2999
+ const fm = fmMatch[1];
3000
+ // Extract description: value (may span the rest of the line)
3001
+ const descMatch = fm.match(/^description:\s*(.+)$/m);
3002
+ if (!descMatch) return null;
3003
+ return descMatch[1].trim();
3004
+ }
3005
+
3006
+ /**
3007
+ * Build the managed ForgeDock block content (between markers, not including them).
3008
+ * Reads all commands/*.md files, extracts frontmatter descriptions, and generates
3009
+ * a concise command index.
3010
+ *
3011
+ * @returns {Promise<string>} Block content
3012
+ */
3013
+ async function _buildForgeDockBlock() {
3014
+ const files = await findMarkdownFiles(COMMANDS_DIR);
3015
+
3016
+ /** @type {Array<{name: string, description: string}>} */
3017
+ const commands = [];
3018
+
3019
+ for (const file of files) {
3020
+ let content = "";
3021
+ try {
3022
+ content = readFileSync(file, "utf-8");
3023
+ } catch {
3024
+ continue; // skip unreadable files
3025
+ }
2218
3026
  const description = _extractFrontmatterDescription(content);
2219
3027
  if (!description) continue;
2220
3028
 
@@ -2379,7 +3187,9 @@ async function injectClaudeMd(cwd) {
2379
3187
  /**
2380
3188
  * Sanitize a string value for safe insertion into a YAML double-quoted scalar.
2381
3189
  * Escapes backslashes (so a trailing \ does not corrupt the closing quote),
2382
- * then strips double-quotes and newlines to prevent YAML injection.
3190
+ * then strips C0/C1 Unicode control characters (\x00-\x08, \x0B, \x0C, \x0E-\x1F, \x7F-\x9F)
3191
+ * that are invalid per YAML 1.2 in double-quoted scalars, then strips double-quotes
3192
+ * and newlines to prevent YAML injection.
2383
3193
  * Backslash escaping MUST come first — before any other replacement.
2384
3194
  *
2385
3195
  * @param {string} value
@@ -2388,6 +3198,7 @@ async function injectClaudeMd(cwd) {
2388
3198
  function _sanitizeYamlValue(value) {
2389
3199
  return String(value)
2390
3200
  .replace(/\\/g, "\\\\")
3201
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, "")
2391
3202
  .replace(/"/g, "")
2392
3203
  .replace(/[\r\n]/g, " ")
2393
3204
  .trim();
@@ -2408,6 +3219,30 @@ function _sanitizePathValue(value) {
2408
3219
  .trim();
2409
3220
  }
2410
3221
 
3222
+ /**
3223
+ * Sanitize a string for safe use as an unquoted YAML mapping key.
3224
+ * Strips or normalizes characters that are structurally significant in YAML outside of
3225
+ * quoted scalars, or that produce non-idiomatic key names:
3226
+ * - Whitespace (space, tab, newline, carriage return, and all Unicode whitespace) →
3227
+ * underscore, so LLM-derived names like "In Progress" become "In_Progress".
3228
+ * - Colon (key separator) and '#' (comment marker) → underscore.
3229
+ * Sequences of replaced characters are collapsed to a single underscore so the key
3230
+ * remains readable. Falls back to "_" if the result is empty.
3231
+ * <!-- Added: forge#396 -->
3232
+ *
3233
+ * @param {string} key
3234
+ * @returns {string}
3235
+ */
3236
+ function _sanitizeYamlKey(key) {
3237
+ return (
3238
+ String(key)
3239
+ .replace(/[\s:#]+/g, "_") // whitespace (incl. space) + structural YAML chars → underscore
3240
+ .replace(/_+/g, "_") // collapse multiple underscores
3241
+ .replace(/^_|_$/g, "") // strip leading/trailing underscores
3242
+ .trim() || "_" // fallback if result is empty
3243
+ );
3244
+ }
3245
+
2411
3246
  /**
2412
3247
  * Attempt to auto-discover GitHub Projects v2 configuration for the given owner.
2413
3248
  *
@@ -2760,10 +3595,15 @@ function buildForgeYamlContent({
2760
3595
  techStack: _sanitizeYamlValue(
2761
3596
  review.techStack || "Node.js, TypeScript, PostgreSQL",
2762
3597
  ),
2763
- // context uses a block scalar (|) — only strip double-quotes; newlines are handled by split/join
3598
+ // context uses a block scalar (|) — normalize CR/CRLF to LF, strip C0/C1 control chars
3599
+ // (excluding \t=\x09 and LF=\x0A which are valid in block scalars), then strip double-quotes.
3600
+ // Must normalize \r first so split("\n") doesn't embed bare \r. split/join handles indentation.
2764
3601
  context: (
2765
3602
  review.context || "Add architecture notes and conventions here."
2766
- ).replace(/"/g, ""),
3603
+ )
3604
+ .replace(/\r\n|\r/g, "\n")
3605
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, "")
3606
+ .replace(/"/g, ""),
2767
3607
  }
2768
3608
  : null;
2769
3609
 
@@ -2802,6 +3642,26 @@ function buildForgeYamlContent({
2802
3642
  const resolvedFieldIds = projectBoard?.fieldIds ?? {};
2803
3643
  const resolvedOptionIds = projectBoard?.optionIds ?? {};
2804
3644
 
3645
+ // Sanitize all LLM-derived project_board string values before YAML interpolation.
3646
+ // projectId and fieldIds come from the LLM enrichment path (_optionalSectionsFromDraft)
3647
+ // and must be treated as untrusted — a crafted response containing a double-quote or
3648
+ // newline would break out of the YAML double-quoted scalar.
3649
+ // projectNumber is numeric (unquoted in YAML) and does not need sanitization.
3650
+ // <!-- Added: forge#384 -->
3651
+ const safeProjectBoard = projectBoard
3652
+ ? {
3653
+ projectId: _sanitizeYamlValue(projectBoard.projectId ?? ""),
3654
+ projectNumber: projectBoard.projectNumber,
3655
+ fieldIds: Object.fromEntries(
3656
+ FIELD_KEYS.map((k) => [
3657
+ k,
3658
+ _sanitizeYamlValue(resolvedFieldIds[k] ?? ""),
3659
+ ]),
3660
+ ),
3661
+ optionIds: projectBoard.optionIds,
3662
+ }
3663
+ : null;
3664
+
2805
3665
  /**
2806
3666
  * Build a YAML block for option_ids given a nested map of { fieldKey: { optionKey: id } }.
2807
3667
  * Only writes fields that have at least one mapped option.
@@ -2813,9 +3673,11 @@ function buildForgeYamlContent({
2813
3673
  if (entries.length === 0) return "";
2814
3674
  const lines = [" option_ids:"];
2815
3675
  for (const [fieldKey, opts] of entries) {
2816
- lines.push(` ${fieldKey}:`);
3676
+ lines.push(` ${_sanitizeYamlKey(fieldKey)}:`);
2817
3677
  for (const [optKey, optId] of Object.entries(opts)) {
2818
- lines.push(` ${optKey}: "${optId}"`);
3678
+ lines.push(
3679
+ ` ${_sanitizeYamlKey(optKey)}: "${_sanitizeYamlValue(String(optId))}"`,
3680
+ );
2819
3681
  }
2820
3682
  }
2821
3683
  return "\n" + lines.join("\n");
@@ -2823,20 +3685,20 @@ function buildForgeYamlContent({
2823
3685
 
2824
3686
  const fieldIdsBlock = FIELD_KEYS.map(
2825
3687
  (k) =>
2826
- ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
3688
+ ` ${k}: "${safeProjectBoard?.fieldIds[k] || "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
2827
3689
  ).join("\n");
2828
3690
 
2829
3691
  const optionIdsBlock = projectBoard?.optionIds
2830
3692
  ? _buildOptionIdsBlock(resolvedOptionIds)
2831
3693
  : "";
2832
3694
 
2833
- const hasDiscoveredId = !!projectBoard?.projectId;
3695
+ const hasDiscoveredId = !!safeProjectBoard?.projectId;
2834
3696
 
2835
3697
  const projectBoardSection = projectBoard
2836
3698
  ? `project_board:
2837
3699
  owner: "${owner}"
2838
3700
  project_number: ${projectBoard.projectNumber || 1}
2839
- project_id: "${hasDiscoveredId ? projectBoard.projectId : "PVT_kwHOxxxxxxxxxxxxxxxx"}"
3701
+ project_id: "${hasDiscoveredId ? safeProjectBoard.projectId : "PVT_kwHOxxxxxxxxxxxxxxxx"}"
2840
3702
  field_ids:
2841
3703
  ${fieldIdsBlock}${optionIdsBlock}${!hasDiscoveredId ? `\n# To find IDs: gh project field-list ${projectBoard.projectNumber || 1} --owner ${owner}` : ""}`
2842
3704
  : `# project_board:
@@ -2940,10 +3802,120 @@ ${verificationSection}
2940
3802
  `;
2941
3803
  }
2942
3804
 
2943
- /** Write forge.yaml to disk. Accepts all params for buildForgeYamlContent plus outputPath. */
3805
+ /**
3806
+ * Mapping from annotatedReviewScreen field keys to the YAML key patterns they
3807
+ * correspond to in the generated forge.yaml. Used to inject TODO comments.
3808
+ *
3809
+ * Each entry is either:
3810
+ * - A plain string: the leading-whitespace + key prefix that identifies the line
3811
+ * (matches in any YAML section).
3812
+ * - An object `{ pattern: string, section: string }`: only matches when the
3813
+ * scanner is currently inside the named top-level YAML section. Use this form
3814
+ * when the same key name appears in multiple sections but should only be
3815
+ * annotated in one (e.g. `owner:` appears in both `project:` and `project_board:`).
3816
+ */
3817
+ const TODO_FIELD_YAML_KEYS = {
3818
+ owner: { pattern: " owner:", section: "project" },
3819
+ repo: " repo:",
3820
+ name: " name:",
3821
+ description: " description:",
3822
+ root: " root:",
3823
+ worktreeBase: " worktree_base:",
3824
+ defaultBranch: " default:",
3825
+ stagingBranch: " staging:",
3826
+ };
3827
+
3828
+ /**
3829
+ * Inject `# TODO(forgedock:<fieldKey>)` comments above each low-confidence
3830
+ * field line in the YAML content string.
3831
+ *
3832
+ * The comment is inserted as a full line above the matching key line.
3833
+ * Example output:
3834
+ * # TODO(forgedock:owner) — low-confidence: verify and update
3835
+ * owner: "your-github-org"
3836
+ *
3837
+ * Section tracking: the scanner maintains `currentSection` by detecting
3838
+ * unindented top-level keys (lines matching `/^[a-z_]+:/`). Entries in
3839
+ * TODO_FIELD_YAML_KEYS that specify a `section` constraint are only annotated
3840
+ * when `currentSection` matches — preventing false annotations on keys that
3841
+ * share the same name across multiple YAML sections (e.g. `owner:` appears in
3842
+ * both `project:` and `project_board:`).
3843
+ *
3844
+ * @param {string} content - Generated forge.yaml content
3845
+ * @param {string[]} lowConfidenceKeys - Array of field key strings to flag
3846
+ * @returns {string} - Content with TODO comments injected
3847
+ */
3848
+ function _injectTodoComments(content, lowConfidenceKeys) {
3849
+ if (!lowConfidenceKeys || lowConfidenceKeys.length === 0) return content;
3850
+
3851
+ const lines = content.split("\n");
3852
+ const result = [];
3853
+ // Track the current top-level YAML section so section-scoped entries in
3854
+ // TODO_FIELD_YAML_KEYS are only matched within their intended section.
3855
+ let currentSection = null;
3856
+
3857
+ for (const line of lines) {
3858
+ // Detect top-level section headers: unindented lines of the form `word:` or `word_word:`.
3859
+ // Ignore comment lines (starting with #) and blank lines.
3860
+ const sectionMatch = line.match(/^([a-z][a-z0-9_]*):/);
3861
+ if (sectionMatch) {
3862
+ currentSection = sectionMatch[1];
3863
+ }
3864
+
3865
+ // Check if this line matches any low-confidence field key pattern.
3866
+ for (const key of lowConfidenceKeys) {
3867
+ const entry = TODO_FIELD_YAML_KEYS[key];
3868
+ if (!entry) continue;
3869
+
3870
+ // Support both plain-string entries and { pattern, section } object entries.
3871
+ const pattern = typeof entry === "string" ? entry : entry.pattern;
3872
+ const requiredSection =
3873
+ typeof entry === "string" ? null : (entry.section ?? null);
3874
+
3875
+ if (
3876
+ line.startsWith(pattern) &&
3877
+ (requiredSection === null || currentSection === requiredSection)
3878
+ ) {
3879
+ // Insert the TODO comment above the key line (same indentation).
3880
+ const indent = line.match(/^(\s*)/)[1];
3881
+ result.push(
3882
+ `${indent}# TODO(forgedock:${key}) — low-confidence: verify and update`,
3883
+ );
3884
+ break;
3885
+ }
3886
+ }
3887
+ result.push(line);
3888
+ }
3889
+
3890
+ return result.join("\n");
3891
+ }
3892
+
3893
+ /** Write forge.yaml to disk. Accepts all params for buildForgeYamlContent plus outputPath and lowConfidenceKeys.
3894
+ * Uses a tmp-file + renameSync pattern to ensure the write is atomic — consistent with how
3895
+ * writeClaudeSettings() and shell-profile writes are done elsewhere in this file.
3896
+ */
2944
3897
  function _writeForgeYaml(opts) {
2945
- const content = buildForgeYamlContent(opts);
2946
- writeFileSync(opts.outputPath, content, "utf-8");
3898
+ let content = buildForgeYamlContent(opts);
3899
+ // Inject TODO comments for low-confidence fields detected during review.
3900
+ if (opts.lowConfidenceKeys && opts.lowConfidenceKeys.length > 0) {
3901
+ content = _injectTodoComments(content, opts.lowConfidenceKeys);
3902
+ }
3903
+ // Atomic write: write to a .tmp sibling then rename into place so a kill
3904
+ // mid-write never leaves forge.yaml partially written (config is regenerable,
3905
+ // but a partial file causes confusing validation errors).
3906
+ const tmpPath = opts.outputPath + ".forgedock.tmp";
3907
+ try {
3908
+ writeFileSync(tmpPath, content, "utf-8");
3909
+ renameSync(tmpPath, opts.outputPath);
3910
+ } catch (err) {
3911
+ // Clean up temp file if it was created before the error
3912
+ try {
3913
+ unlinkSync(tmpPath);
3914
+ } catch {
3915
+ /* already gone or never created */
3916
+ }
3917
+ throw err;
3918
+ }
2947
3919
  }
2948
3920
 
2949
3921
  /** Print next-steps guidance after forge.yaml is written. */
@@ -3076,6 +4048,7 @@ async function validate(forgeYamlPath) {
3076
4048
  note: `Not found: ${forgeYamlPath}`,
3077
4049
  });
3078
4050
  _renderValidationSummary(checks);
4051
+ await _offerRemediations(checks, forgeYamlPath);
3079
4052
  return { passed: false, checks };
3080
4053
  }
3081
4054
 
@@ -3089,6 +4062,7 @@ async function validate(forgeYamlPath) {
3089
4062
  note: `Cannot read: ${err.message}`,
3090
4063
  });
3091
4064
  _renderValidationSummary(checks);
4065
+ await _offerRemediations(checks, forgeYamlPath);
3092
4066
  return { passed: false, checks };
3093
4067
  }
3094
4068
 
@@ -3218,136 +4192,504 @@ async function validate(forgeYamlPath) {
3218
4192
  timeout: 10000,
3219
4193
  },
3220
4194
  );
3221
- if (result.trim()) {
3222
- checks.push({
3223
- label: `Branch: ${branch}`,
3224
- status: "ok",
3225
- note: "Exists on remote",
3226
- });
3227
- } else {
3228
- checks.push({
3229
- label: `Branch: ${branch}`,
3230
- status: "warn",
3231
- note: "Not found on remote",
3232
- });
3233
- }
3234
- } catch {
3235
- checks.push({
3236
- label: `Branch: ${branch}`,
3237
- status: "warn",
3238
- note: "Cannot verify — git error",
3239
- });
4195
+ if (result.trim()) {
4196
+ checks.push({
4197
+ label: `Branch: ${branch}`,
4198
+ status: "ok",
4199
+ note: "Exists on remote",
4200
+ });
4201
+ } else {
4202
+ checks.push({
4203
+ label: `Branch: ${branch}`,
4204
+ status: "warn",
4205
+ note: "Not found on remote",
4206
+ });
4207
+ }
4208
+ } catch {
4209
+ checks.push({
4210
+ label: `Branch: ${branch}`,
4211
+ status: "warn",
4212
+ note: "Cannot verify — git error",
4213
+ });
4214
+ }
4215
+ }
4216
+ }
4217
+
4218
+ // 7. Project board validation
4219
+ if (_sectionActive(content, "project_board")) {
4220
+ const projectId = _parseYamlKey(content, "project_board.project_id");
4221
+ if (!projectId || projectId.includes("xxxx")) {
4222
+ checks.push({
4223
+ label: "Project board configured",
4224
+ status: "warn",
4225
+ note: "project_id is a placeholder — run /forgedock-init to configure",
4226
+ });
4227
+ } else if (!/^PVT_[A-Za-z0-9_=\-]+$/.test(projectId)) {
4228
+ checks.push({
4229
+ label: "Project board configured",
4230
+ status: "error",
4231
+ note: `project_id must match /^PVT_[A-Za-z0-9_=\\-]+$/ (got: ${projectId.slice(0, 12)}...)`,
4232
+ });
4233
+ } else {
4234
+ try {
4235
+ // Use -F (typed variable) to pass projectId as a GraphQL variable rather than
4236
+ // interpolating it into the query string, even though the regex above already
4237
+ // validates it to safe characters.
4238
+ const result = execFileSync(
4239
+ "gh",
4240
+ [
4241
+ "api",
4242
+ "graphql",
4243
+ "-F",
4244
+ `id=${projectId}`,
4245
+ "-f",
4246
+ "query=query($id: ID!) { node(id: $id) { id __typename } }",
4247
+ ],
4248
+ {
4249
+ stdio: ["pipe", "pipe", "pipe"],
4250
+ encoding: "utf-8",
4251
+ timeout: 10000,
4252
+ },
4253
+ );
4254
+ const parsed = JSON.parse(result);
4255
+ if (parsed?.data?.node?.id) {
4256
+ checks.push({
4257
+ label: "Project board configured",
4258
+ status: "ok",
4259
+ note: `${projectId.slice(0, 16)}... resolves`,
4260
+ });
4261
+ } else {
4262
+ checks.push({
4263
+ label: "Project board configured",
4264
+ status: "warn",
4265
+ note: "project_id may be invalid — verify with: gh project list",
4266
+ });
4267
+ }
4268
+ } catch {
4269
+ checks.push({
4270
+ label: "Project board configured",
4271
+ status: "warn",
4272
+ note: "Cannot verify project_id — gh error",
4273
+ });
4274
+ }
4275
+ }
4276
+ } else {
4277
+ checks.push({
4278
+ label: "Project board",
4279
+ status: "warn",
4280
+ note: "Not configured (optional)",
4281
+ });
4282
+ }
4283
+
4284
+ // 8. Satellite repo access
4285
+ if (_sectionActive(content, "repos")) {
4286
+ const satellites = _parseSatelliteRepos(content);
4287
+ if (satellites.length === 0) {
4288
+ checks.push({
4289
+ label: "Satellite repos",
4290
+ status: "warn",
4291
+ note: "repos: section active but no satellites found",
4292
+ });
4293
+ } else {
4294
+ for (const sat of satellites) {
4295
+ try {
4296
+ execFileSync("gh", ["repo", "view", sat, "--json", "name"], {
4297
+ stdio: ["pipe", "pipe", "pipe"],
4298
+ timeout: 10000,
4299
+ });
4300
+ checks.push({
4301
+ label: `Satellite: ${sat}`,
4302
+ status: "ok",
4303
+ note: "Accessible",
4304
+ });
4305
+ } catch {
4306
+ checks.push({
4307
+ label: `Satellite: ${sat}`,
4308
+ status: "warn",
4309
+ note: "Cannot access — check repo name or gh auth",
4310
+ });
4311
+ }
4312
+ }
4313
+ }
4314
+ } else {
4315
+ checks.push({
4316
+ label: "Satellite repos",
4317
+ status: "warn",
4318
+ note: "Not configured (optional)",
4319
+ });
4320
+ }
4321
+
4322
+ _renderValidationSummary(checks);
4323
+ await _offerRemediations(checks, forgeYamlPath);
4324
+ const hasError = checks.some((c) => c.status === "error");
4325
+ return { passed: !hasError, checks };
4326
+ }
4327
+
4328
+ /**
4329
+ * Offer plain-language explanations and optional auto-fixes for each failing check.
4330
+ *
4331
+ * Runs after _renderValidationSummary() so the summary is always visible first.
4332
+ * For each check that is not 'ok':
4333
+ * - Prints a plain-language sentence explaining why it matters and what to do.
4334
+ * - Prints a copy-paste command where applicable.
4335
+ * - For safe, deterministic fixes (directory creation, gh auth), offers an
4336
+ * auto-apply step gated on a TTY confirmation prompt.
4337
+ *
4338
+ * Non-destructive policy:
4339
+ * - Never auto-edits forge.yaml values.
4340
+ * - Never auto-applies project board ID changes.
4341
+ * - Auto-mkdir is offered only when the user explicitly listed the path in forge.yaml.
4342
+ * - Spawning `gh auth login` is offered only in TTY environments.
4343
+ * - Non-TTY: prints copy-paste text only, skips all interactive prompts.
4344
+ *
4345
+ * @param {Array<{label: string, status: 'ok'|'warn'|'error', note: string}>} checks
4346
+ * @param {string} forgeYamlPath - Absolute path to forge.yaml (used in explanations)
4347
+ * @returns {Promise<void>}
4348
+ */
4349
+ async function _offerRemediations(checks, forgeYamlPath) {
4350
+ // Collect only failing checks — skip 'ok' and purely-optional skipped checks
4351
+ // that have no actionable remediation (e.g. "Not configured (optional)").
4352
+ const failing = checks.filter(
4353
+ (c) =>
4354
+ c.status !== "ok" &&
4355
+ !c.note.endsWith("(optional)") &&
4356
+ c.note !== "Not configured (optional)",
4357
+ );
4358
+
4359
+ if (failing.length === 0) return;
4360
+
4361
+ /** Whether we're in an interactive terminal — gates all prompts. */
4362
+ const isTTY = Boolean(process.stdin.isTTY);
4363
+
4364
+ // Print section header
4365
+ console.log("");
4366
+ console.log(` ${bold("Remediation guide:")}`);
4367
+
4368
+ for (const check of failing) {
4369
+ const label = check.label;
4370
+ const note = check.note ?? "";
4371
+ const isError = check.status === "error";
4372
+
4373
+ // ── 1. forge.yaml not found ─────────────────────────────────────────────
4374
+ if (label === "forge.yaml found") {
4375
+ console.log("");
4376
+ console.log(` ${isError ? red("✗") : yellow("!")} ${bold(label)}`);
4377
+ console.log(
4378
+ ` ForgeDock reads ${cyan("forge.yaml")} from your project root to know which`,
4379
+ );
4380
+ console.log(
4381
+ ` GitHub repo, branches, and paths to use. Without it, no pipeline commands`,
4382
+ );
4383
+ console.log(` will function correctly.`);
4384
+ console.log(
4385
+ ` ${bold("Fix:")} Generate forge.yaml with AI assistance:`,
4386
+ );
4387
+ console.log(` ${cyan("npx forgedock init")}`);
4388
+ continue;
4389
+ }
4390
+
4391
+ // ── 2. Required fields missing ──────────────────────────────────────────
4392
+ if (label === "Required fields") {
4393
+ // Extract the field names from the note
4394
+ const missingMatch = note.match(/Missing or placeholder:\s*(.+)/);
4395
+ const fields = missingMatch ? missingMatch[1] : note;
4396
+ console.log("");
4397
+ console.log(` ${isError ? red("✗") : yellow("!")} ${bold(label)}`);
4398
+ console.log(
4399
+ ` The following fields are required but missing or still set to`,
4400
+ );
4401
+ console.log(` placeholder values: ${cyan(fields)}`);
4402
+ console.log(
4403
+ ` ForgeDock uses these to route all GitHub API calls and git operations.`,
4404
+ );
4405
+ console.log(
4406
+ ` ${bold("Fix:")} Open ${cyan("forge.yaml")} and fill in the real values:`,
4407
+ );
4408
+ for (const field of fields.split(",").map((f) => f.trim())) {
4409
+ let hint = "";
4410
+ if (field === "project.owner") hint = ` # your GitHub org or username`;
4411
+ else if (field === "project.repo")
4412
+ hint = ` # the repository name (without the owner prefix)`;
4413
+ else if (field === "paths.root")
4414
+ hint = ` # absolute path to this project on your machine`;
4415
+ else if (field === "branches.default")
4416
+ hint = ` # usually "main" or "master"`;
4417
+ console.log(` ${cyan(field)}: "<value>"${dim(hint)}`);
4418
+ }
4419
+ continue;
4420
+ }
4421
+
4422
+ // ── 3. paths.root does not exist ────────────────────────────────────────
4423
+ if (label === "paths.root exists") {
4424
+ const pathMatch = note.match(/Directory not found:\s*(.+)/);
4425
+ const missingPath = pathMatch ? pathMatch[1].trim() : note;
4426
+ console.log("");
4427
+ console.log(` ${yellow("!")} ${bold(label)}`);
4428
+ console.log(
4429
+ ` ForgeDock uses ${cyan("paths.root")} as the base directory for all git`,
4430
+ );
4431
+ console.log(
4432
+ ` worktrees and file operations. The configured path does not exist:`,
4433
+ );
4434
+ console.log(` ${dim(missingPath)}`);
4435
+ console.log(
4436
+ ` ${bold("Option A:")} If this is your project directory, create it:`,
4437
+ );
4438
+ console.log(` ${cyan(`mkdir -p "${missingPath}"`)}`);
4439
+ console.log(
4440
+ ` ${bold("Option B:")} If the project already exists elsewhere, update forge.yaml:`,
4441
+ );
4442
+ console.log(
4443
+ ` ${cyan("paths.root")}: "<correct absolute path to your project>"`,
4444
+ );
4445
+
4446
+ // Offer auto-mkdir if TTY — safe because the user explicitly listed this path
4447
+ if (isTTY) {
4448
+ console.log("");
4449
+ const shouldCreate = await confirm(
4450
+ ` Create ${dim(missingPath)} now?`,
4451
+ false,
4452
+ );
4453
+ if (shouldCreate) {
4454
+ try {
4455
+ await mkdir(missingPath, { recursive: true });
4456
+ console.log(` ${green("✔")} Created: ${dim(missingPath)}`);
4457
+ } catch (mkdirErr) {
4458
+ console.log(
4459
+ ` ${red("✖")} Could not create directory: ${mkdirErr.message}`,
4460
+ );
4461
+ }
4462
+ }
4463
+ }
4464
+ continue;
4465
+ }
4466
+
4467
+ // ── 4. worktree_base cannot be created ──────────────────────────────────
4468
+ if (label === "worktree_base exists" && note.startsWith("Cannot create:")) {
4469
+ const errMsg = note.replace("Cannot create:", "").trim();
4470
+ console.log("");
4471
+ console.log(` ${yellow("!")} ${bold(label)}`);
4472
+ console.log(
4473
+ ` ForgeDock could not create the worktree directory automatically.`,
4474
+ );
4475
+ console.log(` Error: ${dim(errMsg)}`);
4476
+ console.log(
4477
+ ` Worktrees are temporary checkout directories used during builds.`,
4478
+ );
4479
+ console.log(
4480
+ ` ${bold("Fix:")} Check that the parent directory is writable, or update`,
4481
+ );
4482
+ console.log(
4483
+ ` ${cyan("paths.worktree_base")} in forge.yaml to a writable location.`,
4484
+ );
4485
+ continue;
4486
+ }
4487
+
4488
+ // ── 5. GitHub repo inaccessible ─────────────────────────────────────────
4489
+ if (label === "GitHub repo accessible") {
4490
+ // Try to detect whether gh is unauthenticated vs wrong repo name
4491
+ let isAuthIssue = false;
4492
+ try {
4493
+ execFileSync("gh", ["auth", "status"], {
4494
+ stdio: ["pipe", "pipe", "pipe"],
4495
+ timeout: 5000,
4496
+ });
4497
+ } catch {
4498
+ isAuthIssue = true;
4499
+ }
4500
+
4501
+ // Extract the repo slug from the note if possible
4502
+ const repoMatch = note.match(/Cannot access ([^\s]+)/);
4503
+ const repoSlug = repoMatch ? repoMatch[1] : "owner/repo";
4504
+
4505
+ console.log("");
4506
+ console.log(` ${yellow("!")} ${bold(label)}`);
4507
+
4508
+ if (isAuthIssue) {
4509
+ console.log(
4510
+ ` The GitHub CLI is not authenticated. ForgeDock needs read access to`,
4511
+ );
4512
+ console.log(
4513
+ ` ${cyan(repoSlug)} to list issues, manage PRs, and run pipeline commands.`,
4514
+ );
4515
+ console.log(` ${bold("Fix:")} Authenticate with the GitHub CLI:`);
4516
+ console.log(` ${cyan("gh auth login")}`);
4517
+
4518
+ if (isTTY) {
4519
+ console.log("");
4520
+ const shouldAuth = await confirm(
4521
+ ` Run ${cyan("gh auth login")} now?`,
4522
+ false,
4523
+ );
4524
+ if (shouldAuth) {
4525
+ try {
4526
+ execFileSync("gh", ["auth", "login"], {
4527
+ stdio: "inherit",
4528
+ shell: process.platform === "win32",
4529
+ });
4530
+ } catch {
4531
+ // User may have cancelled — not an error from our side
4532
+ }
4533
+ }
4534
+ }
4535
+ } else {
4536
+ console.log(
4537
+ ` Cannot access ${cyan(repoSlug)}. The GitHub CLI is authenticated but`,
4538
+ );
4539
+ console.log(
4540
+ ` the repo may not exist, you may not have permission, or the owner/repo`,
4541
+ );
4542
+ console.log(` values in forge.yaml are incorrect.`);
4543
+ console.log(
4544
+ ` ${bold("Fix:")} Verify the repo exists and is accessible:`,
4545
+ );
4546
+ console.log(` ${cyan(`gh repo view ${repoSlug}`)}`);
4547
+ console.log(
4548
+ ` Then update ${cyan("project.owner")} and ${cyan("project.repo")} in forge.yaml if needed.`,
4549
+ );
4550
+ }
4551
+ continue;
4552
+ }
4553
+
4554
+ // ── 6. Branch not found on remote ───────────────────────────────────────
4555
+ if (label.startsWith("Branch: ")) {
4556
+ const branchName = label.replace("Branch: ", "").trim();
4557
+ console.log("");
4558
+ console.log(` ${yellow("!")} ${bold(label)}`);
4559
+ console.log(
4560
+ ` Branch ${cyan(branchName)} was not found on the remote. ForgeDock uses`,
4561
+ );
4562
+ console.log(` this branch for PRs and as a base for new work.`);
4563
+ console.log(` ${bold("Option A:")} Create and push the branch:`);
4564
+ console.log(
4565
+ ` ${cyan(`git checkout -b ${branchName} && git push -u origin ${branchName}`)}`,
4566
+ );
4567
+ console.log(
4568
+ ` ${bold("Option B:")} Update the branch name in forge.yaml to one that exists:`,
4569
+ );
4570
+ console.log(
4571
+ ` ${cyan("branches.default")} or ${cyan("branches.staging")} → the correct branch name`,
4572
+ );
4573
+ continue;
4574
+ }
4575
+
4576
+ // ── 7. Project board not configured or invalid ──────────────────────────
4577
+ if (label === "Project board configured" || label === "Project board") {
4578
+ if (note.includes("placeholder")) {
4579
+ console.log("");
4580
+ console.log(` ${yellow("!")} ${bold(label)}`);
4581
+ console.log(
4582
+ ` The ${cyan("project_board.project_id")} in forge.yaml is still a placeholder.`,
4583
+ );
4584
+ console.log(
4585
+ ` This ID is required for the pipeline to move issues across the board automatically.`,
4586
+ );
4587
+ console.log(
4588
+ ` ${bold("Fix:")} Get your project ID from the GitHub CLI:`,
4589
+ );
4590
+ console.log(
4591
+ ` ${cyan("gh project list --owner <your-org-or-username>")}`,
4592
+ );
4593
+ console.log(
4594
+ ` Copy the ${cyan("PVT_...")} ID from the output and update ${cyan("project_board.project_id")} in forge.yaml.`,
4595
+ );
4596
+ } else if (isError) {
4597
+ // Malformed project_id
4598
+ console.log("");
4599
+ console.log(` ${red("✗")} ${bold(label)}`);
4600
+ console.log(
4601
+ ` The ${cyan("project_board.project_id")} value does not match the expected`,
4602
+ );
4603
+ console.log(
4604
+ ` format (${cyan("PVT_...")} — a GitHub Projects GraphQL node ID).`,
4605
+ );
4606
+ console.log(` ${bold("Fix:")} Look up your real project ID:`);
4607
+ console.log(
4608
+ ` ${cyan("gh project list --owner <your-org-or-username>")}`,
4609
+ );
4610
+ console.log(
4611
+ ` Replace the value in forge.yaml with the ${cyan("PVT_...")} ID shown.`,
4612
+ );
4613
+ } else if (note.includes("may be invalid")) {
4614
+ console.log("");
4615
+ console.log(` ${yellow("!")} ${bold(label)}`);
4616
+ console.log(
4617
+ ` The ${cyan("project_board.project_id")} could not be verified via the GitHub API.`,
4618
+ );
4619
+ console.log(
4620
+ ` It may be invalid or the GitHub CLI may lack org-level permissions.`,
4621
+ );
4622
+ console.log(` ${bold("Fix:")} Verify your project ID:`);
4623
+ console.log(
4624
+ ` ${cyan("gh project list --owner <your-org-or-username>")}`,
4625
+ );
4626
+ } else if (note.includes("gh error")) {
4627
+ console.log("");
4628
+ console.log(` ${yellow("!")} ${bold(label)}`);
4629
+ console.log(
4630
+ ` Could not verify the project board ID — the GitHub API call failed.`,
4631
+ );
4632
+ console.log(
4633
+ ` This is usually a transient network issue or a permissions gap.`,
4634
+ );
4635
+ console.log(` ${bold("Fix:")} Check your auth and try again:`);
4636
+ console.log(` ${cyan("gh auth status")}`);
4637
+ console.log(
4638
+ ` ${cyan("gh project list --owner <your-org-or-username>")}`,
4639
+ );
3240
4640
  }
4641
+ continue;
3241
4642
  }
3242
- }
3243
4643
 
3244
- // 7. Project board validation
3245
- if (_sectionActive(content, "project_board")) {
3246
- const projectId = _parseYamlKey(content, "project_board.project_id");
3247
- if (!projectId || projectId.includes("xxxx")) {
3248
- checks.push({
3249
- label: "Project board configured",
3250
- status: "warn",
3251
- note: "project_id is a placeholder — run /forgedock-init to configure",
3252
- });
3253
- } else if (!/^PVT_[A-Za-z0-9_=\-]+$/.test(projectId)) {
3254
- checks.push({
3255
- label: "Project board configured",
3256
- status: "error",
3257
- note: `project_id must match /^PVT_[A-Za-z0-9_=\\-]+$/ (got: ${projectId.slice(0, 12)}...)`,
3258
- });
3259
- } else {
3260
- try {
3261
- // Use -F (typed variable) to pass projectId as a GraphQL variable rather than
3262
- // interpolating it into the query string, even though the regex above already
3263
- // validates it to safe characters.
3264
- const result = execFileSync(
3265
- "gh",
3266
- [
3267
- "api",
3268
- "graphql",
3269
- "-F",
3270
- `id=${projectId}`,
3271
- "-f",
3272
- "query=query($id: ID!) { node(id: $id) { id __typename } }",
3273
- ],
3274
- {
3275
- stdio: ["pipe", "pipe", "pipe"],
3276
- encoding: "utf-8",
3277
- timeout: 10000,
3278
- },
3279
- );
3280
- const parsed = JSON.parse(result);
3281
- if (parsed?.data?.node?.id) {
3282
- checks.push({
3283
- label: "Project board configured",
3284
- status: "ok",
3285
- note: `${projectId.slice(0, 16)}... resolves`,
3286
- });
3287
- } else {
3288
- checks.push({
3289
- label: "Project board configured",
3290
- status: "warn",
3291
- note: "project_id may be invalid — verify with: gh project list",
3292
- });
3293
- }
3294
- } catch {
3295
- checks.push({
3296
- label: "Project board configured",
3297
- status: "warn",
3298
- note: "Cannot verify project_id — gh error",
3299
- });
3300
- }
4644
+ // ── 8. Satellite repo inaccessible ──────────────────────────────────────
4645
+ if (label.startsWith("Satellite: ")) {
4646
+ const satRepo = label.replace("Satellite: ", "").trim();
4647
+ console.log("");
4648
+ console.log(` ${yellow("!")} ${bold(label)}`);
4649
+ console.log(` Cannot access satellite repo ${cyan(satRepo)}.`);
4650
+ console.log(
4651
+ ` Satellite repos are used when your project spans multiple repositories.`,
4652
+ );
4653
+ console.log(` ${bold("Fix:")} Verify the repo name and your access:`);
4654
+ console.log(` ${cyan(`gh repo view ${satRepo}`)}`);
4655
+ console.log(
4656
+ ` If the repo name is wrong, update ${cyan("repos.satellites")} in forge.yaml.`,
4657
+ );
4658
+ console.log(
4659
+ ` If it is a private repo you cannot access, run: ${cyan("gh auth login")}`,
4660
+ );
4661
+ continue;
3301
4662
  }
3302
- } else {
3303
- checks.push({
3304
- label: "Project board",
3305
- status: "warn",
3306
- note: "Not configured (optional)",
3307
- });
3308
- }
3309
4663
 
3310
- // 8. Satellite repo access
3311
- if (_sectionActive(content, "repos")) {
3312
- const satellites = _parseSatelliteRepos(content);
3313
- if (satellites.length === 0) {
3314
- checks.push({
3315
- label: "Satellite repos",
3316
- status: "warn",
3317
- note: "repos: section active but no satellites found",
3318
- });
3319
- } else {
3320
- for (const sat of satellites) {
3321
- try {
3322
- execFileSync("gh", ["repo", "view", sat, "--json", "name"], {
3323
- stdio: ["pipe", "pipe", "pipe"],
3324
- timeout: 10000,
3325
- });
3326
- checks.push({
3327
- label: `Satellite: ${sat}`,
3328
- status: "ok",
3329
- note: "Accessible",
3330
- });
3331
- } catch {
3332
- checks.push({
3333
- label: `Satellite: ${sat}`,
3334
- status: "warn",
3335
- note: "Cannot access — check repo name or gh auth",
3336
- });
3337
- }
3338
- }
4664
+ // ── 9. Satellite repos section misconfigured ─────────────────────────────
4665
+ if (label === "Satellite repos" && note.includes("no satellites found")) {
4666
+ console.log("");
4667
+ console.log(` ${yellow("!")} ${bold(label)}`);
4668
+ console.log(
4669
+ ` The ${cyan("repos:")} section in forge.yaml is active (uncommented) but`,
4670
+ );
4671
+ console.log(
4672
+ ` contains no satellite entries. Either add satellites or comment out the section.`,
4673
+ );
4674
+ console.log(
4675
+ ` ${bold("Fix:")} Add a satellite entry or comment out the repos: section.`,
4676
+ );
4677
+ continue;
4678
+ }
4679
+
4680
+ // ── Fallback: generic remediation for unexpected checks ──────────────────
4681
+ if (note && !note.endsWith("(optional)")) {
4682
+ console.log("");
4683
+ console.log(
4684
+ ` ${isError ? red("✗") : yellow("!")} ${bold(label)}: ${dim(note)}`,
4685
+ );
4686
+ console.log(
4687
+ ` Review the check above and update forge.yaml or run ${cyan("npx forgedock init")} to regenerate.`,
4688
+ );
3339
4689
  }
3340
- } else {
3341
- checks.push({
3342
- label: "Satellite repos",
3343
- status: "warn",
3344
- note: "Not configured (optional)",
3345
- });
3346
4690
  }
3347
4691
 
3348
- _renderValidationSummary(checks);
3349
- const hasError = checks.some((c) => c.status === "error");
3350
- return { passed: !hasError, checks };
4692
+ console.log("");
3351
4693
  }
3352
4694
 
3353
4695
  /**
@@ -3653,14 +4995,51 @@ async function docsInit() {
3653
4995
  console.log(` Files: ${templateFiles.length} seed files`);
3654
4996
  console.log("");
3655
4997
 
3656
- await mkdir(targetDir, { recursive: true });
4998
+ // Use resolvedTarget (canonical path) rather than the raw targetDir string. The
4999
+ // containment check above already resolved symlinks and validated the path; using the
5000
+ // canonical form here closes the TOCTOU window to the narrowest possible interval
5001
+ // and ensures mkdir does not follow a symlink that was NOT present when we checked.
5002
+ //
5003
+ // TOCTOU residual: a narrow window remains between the realpath check and this mkdir
5004
+ // on POSIX systems — a symlink planted at an intermediate path component during that
5005
+ // window could cause mkdir to create directories outside the project boundary.
5006
+ // Full elimination requires O_NOFOLLOW semantics (kernel-level, not available from
5007
+ // Node.js user-space). We mitigate with a post-create re-validation below (SEC-5).
5008
+ await mkdir(resolvedTarget, { recursive: true });
5009
+
5010
+ // SEC-5: Post-create re-validation — detect symlink planted during the TOCTOU window.
5011
+ // After mkdir succeeds, call realpath() on the now-existing directory to get its
5012
+ // canonical on-disk location. If that location lies outside the project root a
5013
+ // symlink was planted between the containment check and the mkdir call. In that case
5014
+ // we remove the directory tree that was created (wherever it landed) and abort.
5015
+ try {
5016
+ const postMkdirReal = await realpath(resolvedTarget);
5017
+ if (postMkdirReal !== realCwd && !postMkdirReal.startsWith(realCwd + sep)) {
5018
+ await rm(postMkdirReal, { recursive: true, force: true }).catch(() => {});
5019
+ console.log(
5020
+ `${RED}Error: devdocs directory escaped the project boundary after creation (TOCTOU).${RESET}`,
5021
+ );
5022
+ console.log(` Created at: ${postMkdirReal}`);
5023
+ console.log(` Project: ${realCwd}`);
5024
+ console.log(
5025
+ ` A symlink was likely planted at an intermediate path component between the`,
5026
+ );
5027
+ console.log(
5028
+ ` containment check and the mkdir call. The created directory has been removed.`,
5029
+ );
5030
+ process.exit(1);
5031
+ }
5032
+ } catch {
5033
+ // realpath() failure after a successful mkdir is unexpected; proceed — the directory
5034
+ // was created by us and the normal containment check already passed.
5035
+ }
3657
5036
 
3658
5037
  let copied = 0;
3659
5038
  let skipped = 0;
3660
5039
 
3661
5040
  for (const srcFile of templateFiles) {
3662
5041
  const rel = relative(templatesDir, srcFile);
3663
- const destFile = join(targetDir, rel);
5042
+ const destFile = join(resolvedTarget, rel);
3664
5043
  const destDir = dirname(destFile);
3665
5044
 
3666
5045
  await mkdir(destDir, { recursive: true });
@@ -3700,6 +5079,161 @@ async function docsInit() {
3700
5079
  console.log("");
3701
5080
  }
3702
5081
 
5082
+ // ---------------------------------------------------------------------------
5083
+ // enable / disable / status — per-directory state management
5084
+ // ---------------------------------------------------------------------------
5085
+
5086
+ /**
5087
+ * Enable ForgeDock for a directory.
5088
+ *
5089
+ * - Clears any existing opt-out entry in the registry (so the directory becomes
5090
+ * active again if it was previously disabled).
5091
+ * - Writes a lightweight `.forgedock` marker file if neither `forge.yaml` nor
5092
+ * `.forgedock` already exists — this marks the directory as ForgeDock-managed
5093
+ * before a full config is generated.
5094
+ *
5095
+ * @param {string} dir - Absolute path to the directory to enable.
5096
+ * @returns {Promise<void>}
5097
+ */
5098
+ async function enableCommand(dir) {
5099
+ const absDir = resolve(dir);
5100
+
5101
+ // Clear any opt-out entry so the directory becomes active
5102
+ await setOptOut(absDir, false);
5103
+
5104
+ // Write .forgedock marker only if the directory is not already managed
5105
+ const hasForgeYaml = existsSync(join(absDir, "forge.yaml"));
5106
+ const hasMarker = existsSync(join(absDir, ".forgedock"));
5107
+
5108
+ if (!hasForgeYaml && !hasMarker) {
5109
+ try {
5110
+ await writeFile(join(absDir, ".forgedock"), "", "utf-8");
5111
+ console.log(`${GREEN}✓${RESET} ForgeDock enabled for ${cyan(absDir)}`);
5112
+ console.log(
5113
+ ` ${dim(".forgedock marker written — run")} ${cyan("npx forgedock init")} ${dim("to generate forge.yaml")}`,
5114
+ );
5115
+ } catch (err) {
5116
+ console.log(
5117
+ `${YELLOW}!${RESET} Could not write .forgedock marker: ${err.message}`,
5118
+ );
5119
+ console.log(
5120
+ ` ${dim("Opt-out cleared — directory will be treated as active once a marker or forge.yaml is added.")}`,
5121
+ );
5122
+ }
5123
+ } else {
5124
+ console.log(`${GREEN}✓${RESET} ForgeDock enabled for ${cyan(absDir)}`);
5125
+ if (hasForgeYaml) {
5126
+ console.log(
5127
+ ` ${dim("forge.yaml already present — directory is managed.")}`,
5128
+ );
5129
+ } else {
5130
+ console.log(
5131
+ ` ${dim(".forgedock marker already present — directory is managed.")}`,
5132
+ );
5133
+ }
5134
+ }
5135
+ }
5136
+
5137
+ /**
5138
+ * Disable ForgeDock for a directory.
5139
+ *
5140
+ * Records an explicit opt-out in the central registry so the SessionStart hook
5141
+ * stays completely silent in this directory. The `.forgedock` marker (if present)
5142
+ * is left untouched — re-enabling simply clears the opt-out entry.
5143
+ *
5144
+ * @param {string} dir - Absolute path to the directory to disable.
5145
+ * @returns {Promise<void>}
5146
+ */
5147
+ async function disableCommand(dir) {
5148
+ const absDir = resolve(dir);
5149
+
5150
+ await setOptOut(absDir, true);
5151
+
5152
+ console.log(`${YELLOW}–${RESET} ForgeDock disabled for ${cyan(absDir)}`);
5153
+ console.log(
5154
+ ` ${dim("The SessionStart hook will be silent in this directory.")}`,
5155
+ );
5156
+ console.log(
5157
+ ` ${dim("Re-enable with:")} ${cyan(`npx forgedock enable "${absDir}"`)}`,
5158
+ );
5159
+ }
5160
+
5161
+ /**
5162
+ * Print the resolved ForgeDock state for a directory.
5163
+ *
5164
+ * Maps the three states returned by resolveState() to a human-readable
5165
+ * explanation of what ForgeDock will do in this directory.
5166
+ *
5167
+ * @param {string} dir - Absolute path to the directory to inspect.
5168
+ * @returns {Promise<void>}
5169
+ */
5170
+ async function statusCommand(dir) {
5171
+ const absDir = resolve(dir);
5172
+ const state = resolveState(absDir);
5173
+
5174
+ const hasForgeYaml = existsSync(join(absDir, "forge.yaml"));
5175
+ const hasMarker = existsSync(join(absDir, ".forgedock"));
5176
+
5177
+ console.log("");
5178
+ console.log(`${BOLD}ForgeDock status${RESET} — ${cyan(absDir)}`);
5179
+ console.log("");
5180
+
5181
+ switch (state) {
5182
+ case "managed-active":
5183
+ console.log(` ${GREEN}● Active${RESET}`);
5184
+ console.log(
5185
+ ` ${dim("ForgeDock is managed and active in this directory.")}`,
5186
+ );
5187
+ if (hasForgeYaml) {
5188
+ console.log(` ${dim("Managed via:")} forge.yaml`);
5189
+ } else if (hasMarker) {
5190
+ console.log(` ${dim("Managed via:")} .forgedock marker`);
5191
+ console.log(
5192
+ ` ${dim("Run")} ${cyan("npx forgedock init")} ${dim("to generate a full forge.yaml.")}`,
5193
+ );
5194
+ }
5195
+ console.log(
5196
+ ` ${dim("To disable:")} ${cyan(`npx forgedock disable "${absDir}"`)}`,
5197
+ );
5198
+ break;
5199
+
5200
+ case "managed-optedout":
5201
+ console.log(` ${YELLOW}○ Disabled${RESET}`);
5202
+ console.log(
5203
+ ` ${dim("This directory is managed (has a forge.yaml or .forgedock marker)")}`,
5204
+ );
5205
+ console.log(
5206
+ ` ${dim("but has been explicitly opted out — the SessionStart hook is silent here.")}`,
5207
+ );
5208
+ if (hasForgeYaml) {
5209
+ console.log(` ${dim("Managed via:")} forge.yaml`);
5210
+ } else if (hasMarker) {
5211
+ console.log(` ${dim("Managed via:")} .forgedock marker`);
5212
+ }
5213
+ console.log(
5214
+ ` ${dim("To re-enable:")} ${cyan(`npx forgedock enable "${absDir}"`)}`,
5215
+ );
5216
+ break;
5217
+
5218
+ case "unmanaged":
5219
+ console.log(` ${dim("◌ Unmanaged")}`);
5220
+ console.log(
5221
+ ` ${dim("No forge.yaml or .forgedock marker found in this directory.")}`,
5222
+ );
5223
+ console.log(` ${dim("ForgeDock is not active here.")}`);
5224
+ console.log(
5225
+ ` ${dim("To enable:")} ${cyan(`npx forgedock enable "${absDir}"`)}`,
5226
+ );
5227
+ break;
5228
+
5229
+ default:
5230
+ console.log(` ${dim("Unknown state:")} ${state}`);
5231
+ break;
5232
+ }
5233
+
5234
+ console.log("");
5235
+ }
5236
+
3703
5237
  function help() {
3704
5238
  console.log("");
3705
5239
  console.log(
@@ -3714,6 +5248,12 @@ function help() {
3714
5248
  console.log(
3715
5249
  ` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
3716
5250
  );
5251
+ console.log(
5252
+ ` ${CYAN}npx forgedock init --manual${RESET} Skip autopilot enrichment; review screen shows detection baseline only`,
5253
+ );
5254
+ console.log(
5255
+ ` ${CYAN}npx forgedock init --verbose${RESET} Show detection sources and confidence during init`,
5256
+ );
3717
5257
  console.log(
3718
5258
  ` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`,
3719
5259
  );
@@ -3741,6 +5281,15 @@ function help() {
3741
5281
  console.log(
3742
5282
  ` ${CYAN}npx forgedock docs init${RESET} Scaffold devdocs knowledge tree into current project`,
3743
5283
  );
5284
+ console.log(
5285
+ ` ${CYAN}npx forgedock enable [dir]${RESET} Enable ForgeDock for a directory (default: cwd)`,
5286
+ );
5287
+ console.log(
5288
+ ` ${CYAN}npx forgedock disable [dir]${RESET} Disable ForgeDock for a directory (default: cwd)`,
5289
+ );
5290
+ console.log(
5291
+ ` ${CYAN}npx forgedock status [dir]${RESET} Show ForgeDock state for a directory (default: cwd)`,
5292
+ );
3744
5293
  console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
3745
5294
  console.log("");
3746
5295
  }
@@ -4185,6 +5734,15 @@ if (!command) {
4185
5734
  }
4186
5735
  break;
4187
5736
  }
5737
+ case "enable":
5738
+ await enableCommand(args[1] || process.cwd());
5739
+ break;
5740
+ case "disable":
5741
+ await disableCommand(args[1] || process.cwd());
5742
+ break;
5743
+ case "status":
5744
+ await statusCommand(args[1] || process.cwd());
5745
+ break;
4188
5746
  case "help":
4189
5747
  case "--help":
4190
5748
  case "-h":