forgedock 1.0.8 → 1.0.10

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
@@ -54,7 +54,14 @@ import {
54
54
  input,
55
55
  createProgressBar,
56
56
  spinner,
57
+ annotatedReviewScreen,
57
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";
58
65
 
59
66
  const __filename = fileURLToPath(import.meta.url);
60
67
  const __dirname = dirname(__filename);
@@ -69,6 +76,11 @@ const TARGET_DIR = join(HOME, ".claude", "commands");
69
76
  const args = process.argv.slice(2);
70
77
  const command = args[0];
71
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");
72
84
 
73
85
  // ---------------------------------------------------------------------------
74
86
  // Version — read dynamically from package.json
@@ -133,6 +145,295 @@ async function removeInstallModeMarker() {
133
145
  }
134
146
  }
135
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
+
136
437
  // ---------------------------------------------------------------------------
137
438
  // Install state detection
138
439
  // ---------------------------------------------------------------------------
@@ -538,10 +839,14 @@ async function runPrerequisiteChecklist() {
538
839
  // Try to get primary org from gh api
539
840
  let orgLabel = "";
540
841
  try {
541
- const orgsJson = execSync("gh api /user/orgs --jq '.[0].login'", {
542
- encoding: "utf-8",
543
- stdio: ["pipe", "pipe", "pipe"],
544
- }).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();
545
850
  if (orgsJson && orgsJson !== "null" && orgsJson !== "") {
546
851
  orgLabel = ` (${orgsJson})`;
547
852
  }
@@ -906,7 +1211,11 @@ async function install() {
906
1211
  ...conflicts.map((c) => ` ${red("✖")} ${c}`),
907
1212
  "",
908
1213
  ` ${dim("To let ForgeDock manage these, remove or rename each file:")}`,
909
- ...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
+ ),
910
1219
  "",
911
1220
  ];
912
1221
  process.stdout.write(
@@ -922,27 +1231,57 @@ async function install() {
922
1231
 
923
1232
  console.log("");
924
1233
  let forgeHomeSet = false;
925
- for (const profile of [join(HOME, ".bashrc"), join(HOME, ".zshrc")]) {
926
- if (existsSync(profile)) {
927
- const content = readFileSync(profile, "utf-8");
928
- if (!content.includes("FORGE_HOME")) {
929
- appendFileSync(
930
- profile,
931
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${FORGE_HOME}"\n`,
932
- );
933
- const profileShort = profile.replace(HOME, "~");
934
- console.log(
935
- ` ${green("✔")} ${bold("FORGE_HOME")} set in ${cyan(profileShort)}`,
936
- );
937
- 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
+ }
938
1277
  }
939
1278
  }
940
- }
941
1279
 
942
- if (!forgeHomeSet) {
943
- console.log(
944
- ` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
945
- );
1280
+ if (!forgeHomeSet) {
1281
+ console.log(
1282
+ ` ${dim("✔")} ${dim("FORGE_HOME already set in shell profile")}`,
1283
+ );
1284
+ }
946
1285
  }
947
1286
 
948
1287
  console.log("");
@@ -951,6 +1290,31 @@ async function install() {
951
1290
  );
952
1291
  console.log("");
953
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
+
954
1318
  // forge.yaml advisory — guide users to run init if config is missing
955
1319
  const forgeYamlPath = join(process.cwd(), "forge.yaml");
956
1320
  if (!existsSync(forgeYamlPath)) {
@@ -1012,10 +1376,39 @@ async function uninstall() {
1012
1376
  }
1013
1377
  });
1014
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
+
1015
1387
  // Check for forge.yaml in cwd
1016
1388
  const forgeYamlPath = join(process.cwd(), "forge.yaml");
1017
1389
  const hasForgeYaml = existsSync(forgeYamlPath);
1018
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
+
1019
1412
  // -------------------------------------------------------------------------
1020
1413
  // Phase 2: Pre-removal summary
1021
1414
  // -------------------------------------------------------------------------
@@ -1023,7 +1416,9 @@ async function uninstall() {
1023
1416
  if (
1024
1417
  toRemove.length === 0 &&
1025
1418
  profilesWithForgeHome.length === 0 &&
1026
- !hasForgeYaml
1419
+ !hasWindowsForgeHome &&
1420
+ !hasForgeYaml &&
1421
+ !hasSessionStartHook
1027
1422
  ) {
1028
1423
  console.log(
1029
1424
  ` ${dim("Nothing to remove — ForgeDock does not appear to be installed.")}`,
@@ -1046,6 +1441,16 @@ async function uninstall() {
1046
1441
  ` ${yellow("Profiles")}: FORGE_HOME export in ${bold(profilesWithForgeHome.map((p) => p.replace(HOME, "~")).join(", "))}`,
1047
1442
  );
1048
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
+ }
1049
1454
  if (hasForgeYaml) {
1050
1455
  summaryLines.push(` ${cyan("forge.yaml")}: present in current directory`);
1051
1456
  }
@@ -1063,7 +1468,9 @@ async function uninstall() {
1063
1468
  const profileLabel =
1064
1469
  profilesWithForgeHome.length > 0
1065
1470
  ? ` and FORGE_HOME from ${profilesWithForgeHome.length === 1 ? "shell profile" : "shell profiles"}`
1066
- : "";
1471
+ : hasWindowsForgeHome
1472
+ ? " and FORGE_HOME from Windows user environment"
1473
+ : "";
1067
1474
 
1068
1475
  let confirmed = forceYes;
1069
1476
  if (!confirmed) {
@@ -1110,6 +1517,42 @@ async function uninstall() {
1110
1517
  // Phase 5: FORGE_HOME cleanup (only if detected)
1111
1518
  // -------------------------------------------------------------------------
1112
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
+
1113
1556
  if (profilesWithForgeHome.length > 0) {
1114
1557
  console.log("");
1115
1558
  const cleanProfiles = forceYes
@@ -1160,7 +1603,42 @@ async function uninstall() {
1160
1603
  }
1161
1604
 
1162
1605
  // -------------------------------------------------------------------------
1163
- // 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)
1164
1642
  // -------------------------------------------------------------------------
1165
1643
 
1166
1644
  if (hasForgeYaml) {
@@ -1184,18 +1662,23 @@ async function uninstall() {
1184
1662
  }
1185
1663
 
1186
1664
  // -------------------------------------------------------------------------
1187
- // Phase 7: Post-removal summary
1665
+ // Phase 8: Post-removal summary
1188
1666
  // -------------------------------------------------------------------------
1189
1667
 
1190
1668
  console.log("");
1191
1669
  console.log(
1192
1670
  `${green("Uninstall complete.")} ForgeDock commands have been removed.`,
1193
1671
  );
1194
- if (profilesWithForgeHome.length > 0) {
1672
+ if (profilesWithForgeHome.length > 0 && process.platform !== "win32") {
1195
1673
  console.log(
1196
1674
  ` ${dim("Restart your shell or run")} ${cyan("source ~/.bashrc")} ${dim("(or")} ${cyan("~/.zshrc")}${dim(")")} ${dim("to apply profile changes.")}`,
1197
1675
  );
1198
1676
  }
1677
+ if (hasWindowsForgeHome) {
1678
+ console.log(
1679
+ ` ${dim("Restart your terminal to apply environment changes.")}`,
1680
+ );
1681
+ }
1199
1682
  console.log("");
1200
1683
  }
1201
1684
 
@@ -1216,6 +1699,9 @@ function queryNpmRegistry(pkg) {
1216
1699
  encoding: "utf-8",
1217
1700
  stdio: ["pipe", "pipe", "pipe"],
1218
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",
1219
1705
  });
1220
1706
  return result.trim() || null;
1221
1707
  } catch {
@@ -1557,12 +2043,16 @@ async function update() {
1557
2043
  // Show changelog between current HEAD and remote HEAD
1558
2044
  let changelogLines = [];
1559
2045
  try {
1560
- const log = execSync(`git log --oneline ${before}..origin/main`, {
1561
- cwd: FORGE_HOME,
1562
- encoding: "utf-8",
1563
- stdio: ["pipe", "pipe", "pipe"],
1564
- timeout: 5000,
1565
- }).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();
1566
2056
  if (log) {
1567
2057
  changelogLines = log.split("\n").map((l) => ` ${dim(l)}`);
1568
2058
  }
@@ -1740,278 +2230,325 @@ async function update() {
1740
2230
  }
1741
2231
  }
1742
2232
 
1743
- async function init() {
1744
- // init() generates forge.yaml from prompts and git remote detection.
1745
- // It does NOT require gh CLI or gh auth for core operation (project board
1746
- // auto-discovery is optional and guarded separately). Only warn if Claude
1747
- // Code is missing — it's the primary consumer of the generated config.
1748
- try {
1749
- execSync("claude --version", { stdio: ["pipe", "pipe", "pipe"] });
1750
- } catch {
1751
- console.log(` ${YELLOW}!${RESET} Claude Code CLI not found on PATH.`);
1752
- console.log(
1753
- ` Install: ${CYAN}https://docs.anthropic.com/en/docs/claude-code${RESET}`,
1754
- );
1755
- 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";
1756
2253
  }
1757
2254
 
1758
- console.log("");
1759
- console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
1760
- console.log("");
2255
+ // API backend: BYO key present.
2256
+ if (process.env.ANTHROPIC_API_KEY) {
2257
+ return "api";
2258
+ }
1761
2259
 
1762
- const cwd = process.cwd();
1763
- const outputPath = join(cwd, "forge.yaml");
1764
-
1765
- // ---------------------------------------------------------------------------
1766
- // Auto-detect defaults (silent — used as pre-fill values for prompts)
1767
- // ---------------------------------------------------------------------------
1768
-
1769
- // Detect git remote URL and parse owner/repo
1770
- let detectedOwner = "your-github-org";
1771
- let detectedRepo = "your-repo-name";
1772
- let remoteDetected = false;
2260
+ return "none";
2261
+ }
1773
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) {
1774
2277
  try {
1775
- const remoteUrl = execSync("git remote get-url origin", {
2278
+ const draftJson = JSON.stringify(draft);
2279
+ const output = execFileSync("claude", ["-p"], {
1776
2280
  cwd,
1777
2281
  encoding: "utf-8",
2282
+ timeout: 120000, // 2 minutes — enrichment may take a while for large repos
2283
+ input: `/forgedock-init ${draftJson}`,
1778
2284
  stdio: ["pipe", "pipe", "pipe"],
1779
- }).trim();
1780
-
1781
- // SSH: git@github.com:owner/repo.git
1782
- const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
1783
- // HTTPS: https://github.com/owner/repo.git
1784
- const httpsMatch = remoteUrl.match(
1785
- /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
1786
- );
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
+ });
1787
2291
 
1788
- if (sshMatch) {
1789
- detectedOwner = sshMatch[1];
1790
- detectedRepo = sshMatch[2];
1791
- remoteDetected = true;
1792
- } else if (httpsMatch) {
1793
- detectedOwner = httpsMatch[1];
1794
- detectedRepo = httpsMatch[2];
1795
- 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
+ );
1796
2299
  }
1797
- } catch {
1798
- // No remote — use placeholders
2300
+ return draft;
1799
2301
  }
2302
+ }
1800
2303
 
1801
- // Detect default branch
1802
- let detectedDefault = "main";
1803
- try {
1804
- const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1805
- cwd,
1806
- encoding: "utf-8",
1807
- stdio: ["pipe", "pipe", "pipe"],
1808
- }).trim();
1809
- detectedDefault = headRef.replace(/^refs\/remotes\/origin\//, "");
1810
- } catch {
1811
- try {
1812
- const cur = execSync("git rev-parse --abbrev-ref HEAD", {
1813
- cwd,
1814
- encoding: "utf-8",
1815
- stdio: ["pipe", "pipe", "pipe"],
1816
- }).trim();
1817
- if (cur && cur !== "HEAD") detectedDefault = cur;
1818
- } catch {
1819
- // Keep "main"
1820
- }
1821
- }
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 = {};
1822
2314
 
1823
- // Detect staging branch
1824
- let detectedStaging = detectedDefault;
1825
- try {
1826
- const remoteBranches = execSync("git branch -r", {
1827
- cwd,
1828
- encoding: "utf-8",
1829
- stdio: ["pipe", "pipe", "pipe"],
1830
- });
1831
- if (remoteBranches.includes("origin/staging")) {
1832
- detectedStaging = "staging";
1833
- }
1834
- } catch {
1835
- // 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
+ };
1836
2335
  }
1837
2336
 
1838
- // Derive project name from repo slug
1839
- const detectedName = detectedRepo
1840
- .split(/[-_]/)
1841
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1842
- .join(" ");
1843
-
1844
- // Non-TTY: skip interactive prompts and use detected values directly
1845
- if (!process.stdin.isTTY) {
1846
- if (!remoteDetected) {
1847
- console.log(
1848
- ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1849
- );
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
+ };
1850
2353
  }
1851
- // Silent write — same as pre-interactive behavior
1852
- _writeForgeYaml({
1853
- outputPath,
1854
- cwd,
1855
- owner: detectedOwner,
1856
- repo: detectedRepo,
1857
- projectName: detectedName,
1858
- description: "",
1859
- root: cwd,
1860
- worktreeBase: join(cwd, ".claude", "worktrees"),
1861
- defaultBranch: detectedDefault,
1862
- stagingBranch: detectedStaging,
1863
- });
1864
- console.log(` ${GREEN}Created${RESET}: forge.yaml`);
1865
- console.log("");
1866
- await injectClaudeMd(cwd);
1867
- console.log("");
1868
- _printNextSteps({ remoteDetected });
1869
- await validate(outputPath);
1870
- return;
1871
2354
  }
1872
2355
 
1873
- // ---------------------------------------------------------------------------
1874
- // Interactive flow — prompt for each required section
1875
- // ---------------------------------------------------------------------------
1876
-
1877
- // Loop until user confirms the preview
1878
- let confirmed = false;
1879
-
1880
- while (!confirmed) {
1881
- console.log(
1882
- dim(
1883
- " Auto-detected values are shown as defaults. Press Enter to accept.",
1884
- ),
1885
- );
1886
- 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
+ }
1887
2369
 
1888
- // --- Project section ---
1889
- 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
+ }
1890
2382
 
1891
- const ownerInput = await input(
1892
- " GitHub owner (org or user)",
1893
- detectedOwner,
1894
- );
1895
- const repoInput = await input(" Repository name", detectedRepo);
1896
- const nameInput = await input(
1897
- " Project name",
1898
- detectedName !== "Your-repo-name"
1899
- ? detectedName
1900
- : repoInput
1901
- .split(/[-_]/)
1902
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1903
- .join(" "),
1904
- );
1905
- const descInput = await input(" Brief description", "");
2383
+ return sections;
2384
+ }
1906
2385
 
1907
- // --- Paths section ---
1908
- console.log("");
1909
- 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
+ }
1910
2398
 
1911
- const rootInput = await input(" Repository root (absolute path)", cwd);
1912
- const worktreeInput = await input(
1913
- " Worktree base (for git worktrees)",
1914
- join(rootInput || cwd, ".claude", "worktrees"),
1915
- );
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
+ ];
1916
2423
 
1917
- // --- Branches section ---
1918
- console.log("");
1919
- 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
+ };
1920
2434
 
1921
- const defaultBranchInput = await input(" Default branch", detectedDefault);
1922
- const stagingBranchInput = await input(
1923
- " Staging branch (PR target for fast-lane changes)",
1924
- 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`,
1925
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
+ }
1926
2474
 
1927
- // --- Preview ---
1928
- console.log("");
1929
-
1930
- const previewLines = buildForgeYamlContent({
1931
- owner: ownerInput,
1932
- repo: repoInput,
1933
- projectName: nameInput,
1934
- description: descInput,
1935
- root: rootInput || cwd,
1936
- worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
1937
- defaultBranch: defaultBranchInput,
1938
- stagingBranch: stagingBranchInput,
1939
- });
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
+ }
1940
2522
 
1941
- // Show preview in a box
1942
- const previewDisplay = previewLines
1943
- .split("\n")
1944
- .slice(0, 30) // show first 30 lines of required sections only
1945
- .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);
1946
2526
 
1947
- process.stdout.write(
1948
- box(previewDisplay, {
1949
- title: "forge.yaml preview (required sections)",
1950
- padding: 1,
1951
- }),
2527
+ const remainingSectionChoices = OPTIONAL_SECTION_CHOICES.filter(
2528
+ (c) => !alreadyConfigured.has(c.value),
1952
2529
  );
1953
2530
 
1954
- confirmed = await confirm(" Write this forge.yaml?", true);
1955
-
1956
- if (!confirmed) {
1957
- console.log("");
1958
- console.log(dim(" Starting over — re-enter values below."));
1959
- console.log("");
1960
- } else {
1961
- // -----------------------------------------------------------------------
1962
- // Optional sections — multi-select, then guided prompts per section
1963
- // -----------------------------------------------------------------------
2531
+ if (remainingSectionChoices.length > 0) {
1964
2532
  console.log("");
1965
- console.log(bold(" Optional Sections"));
2533
+ console.log(bold(" Additional Optional Sections"));
1966
2534
  console.log(
1967
2535
  dim(
1968
- " Select sections to configure now. Unselected sections are written as",
1969
- ),
1970
- );
1971
- console.log(
1972
- dim(
1973
- " 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.",
1974
2537
  ),
1975
2538
  );
1976
2539
  console.log("");
1977
2540
 
1978
- const OPTIONAL_SECTION_CHOICES = [
1979
- {
1980
- label:
1981
- "Project Board — GitHub Projects v2 integration for workflow tracking",
1982
- value: "projectBoard",
1983
- },
1984
- {
1985
- label: "Multi-Repo — Satellite repos for cross-repo milestones",
1986
- value: "multiRepo",
1987
- },
1988
- {
1989
- label:
1990
- "Review Context — Tech stack and conventions for PR review agents",
1991
- value: "review",
1992
- },
1993
- {
1994
- label:
1995
- "Verification — Health check endpoints and response patterns",
1996
- value: "verification",
1997
- },
1998
- ];
1999
-
2000
2541
  const selectedSections = await multiSelect(
2001
- " Which optional sections would you like to configure?",
2002
- OPTIONAL_SECTION_CHOICES,
2542
+ " Which additional sections would you like to configure?",
2543
+ remainingSectionChoices,
2003
2544
  );
2004
2545
 
2005
- /** @type {Record<string, object>} */
2006
- const optionalSections = {};
2007
-
2008
- // --- Project Board prompts ---
2546
+ // --- Project Board prompts (manual, only when enrichment didn't find it) ---
2009
2547
  if (selectedSections.includes("projectBoard")) {
2010
2548
  const discovered = await discoverProjectBoard(
2011
2549
  ownerInput || detectedOwner,
2012
2550
  );
2013
2551
  if (discovered) {
2014
- // Auto-discovery succeeded — use resolved IDs
2015
2552
  optionalSections.projectBoard = {
2016
2553
  projectNumber: discovered.projectNumber,
2017
2554
  projectId: discovered.projectId,
@@ -2019,7 +2556,6 @@ async function init() {
2019
2556
  optionIds: discovered.optionIds,
2020
2557
  };
2021
2558
  } else {
2022
- // Fallback: manual entry (no regression from previous behaviour)
2023
2559
  console.log("");
2024
2560
  console.log(bold(" Project Board (manual)"));
2025
2561
  console.log(
@@ -2100,75 +2636,346 @@ async function init() {
2100
2636
  );
2101
2637
  optionalSections.verification = { healthEndpoint };
2102
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("");
2103
2655
 
2104
- // -----------------------------------------------------------------------
2105
- // Handle existing forge.yaml with confirmation
2106
- // -----------------------------------------------------------------------
2107
- 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 {
2108
2674
  console.log("");
2109
- console.log(` ${YELLOW}forge.yaml already exists.${RESET}`);
2110
- const shouldOverwrite = await confirm(
2111
- " Back up existing forge.yaml and overwrite?",
2112
- 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
+ ),
2681
+ );
2682
+ const projectNumber = await input(
2683
+ " GitHub Projects v2 project number",
2684
+ "1",
2685
+ );
2686
+ optionalSections.projectBoard = {
2687
+ projectNumber: parseInt(projectNumber, 10) || 1,
2688
+ };
2689
+ console.log(
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
+ ),
2113
2701
  );
2114
- if (!shouldOverwrite) {
2115
- console.log(` ${dim("Cancelled.")} forge.yaml was not changed.`);
2116
- console.log("");
2117
- return;
2118
- }
2119
-
2120
- // Backup with timestamped name if .bak already exists (fix from #36)
2121
- const baseBak = join(cwd, "forge.yaml.bak");
2122
- const backupPath = existsSync(baseBak)
2123
- ? join(
2124
- cwd,
2125
- `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
2126
- )
2127
- : baseBak;
2128
- const backupName = basename(backupPath);
2129
- renameSync(outputPath, backupPath);
2130
- console.log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
2131
2702
  }
2703
+ }
2132
2704
 
2133
- // Write the file
2134
- _writeForgeYaml({
2135
- outputPath,
2136
- cwd,
2137
- owner: ownerInput,
2138
- repo: repoInput,
2139
- projectName: nameInput,
2140
- description: descInput,
2141
- root: rootInput || cwd,
2142
- worktreeBase: worktreeInput || join(cwd, ".claude", "worktrees"),
2143
- defaultBranch: defaultBranchInput,
2144
- stagingBranch: stagingBranchInput,
2145
- optionalSections,
2146
- });
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
+ }
2147
2726
 
2727
+ // --- Review Context prompts ---
2728
+ if (selectedSections.includes("review")) {
2148
2729
  console.log("");
2149
- console.log(` ${GREEN}Created${RESET}: forge.yaml`);
2150
- if (selectedSections.length > 0) {
2151
- console.log(
2152
- ` ${GREEN}Configured${RESET}: ${selectedSections
2153
- .map(
2154
- (s) =>
2155
- ({
2156
- projectBoard: "project_board",
2157
- multiRepo: "repos",
2158
- review: "review",
2159
- verification: "verification",
2160
- })[s],
2161
- )
2162
- .join(", ")}`,
2163
- );
2164
- }
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")) {
2165
2744
  console.log("");
2166
- await injectClaudeMd(cwd);
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 };
2751
+ }
2752
+ }
2753
+
2754
+ return optionalSections;
2755
+ }
2756
+
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
+ }
2777
+
2778
+ console.log("");
2779
+ console.log(`${BOLD}ForgeDock${RESET} — Generate forge.yaml`);
2780
+ console.log("");
2781
+
2782
+ const cwd = process.cwd();
2783
+ const outputPath = join(cwd, "forge.yaml");
2784
+
2785
+ // ---------------------------------------------------------------------------
2786
+ // Step 1: Auto-detect defaults via init-detect module.
2787
+ // Returns a ConfigDraft with per-field { value, confidence, source, why }.
2788
+ // ---------------------------------------------------------------------------
2789
+
2790
+ const baseDraft = await detectConfig(cwd);
2791
+ const remoteDetected = baseDraft.meta.remoteDetected;
2792
+
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.`);
2167
2914
  console.log("");
2168
- _printNextSteps({ remoteDetected: ownerInput !== "your-github-org" });
2169
- await validate(outputPath);
2915
+ return;
2170
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}`);
2171
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);
2172
2979
  }
2173
2980
 
2174
2981
  // ---------------------------------------------------------------------------
@@ -2380,7 +3187,9 @@ async function injectClaudeMd(cwd) {
2380
3187
  /**
2381
3188
  * Sanitize a string value for safe insertion into a YAML double-quoted scalar.
2382
3189
  * Escapes backslashes (so a trailing \ does not corrupt the closing quote),
2383
- * 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.
2384
3193
  * Backslash escaping MUST come first — before any other replacement.
2385
3194
  *
2386
3195
  * @param {string} value
@@ -2389,6 +3198,7 @@ async function injectClaudeMd(cwd) {
2389
3198
  function _sanitizeYamlValue(value) {
2390
3199
  return String(value)
2391
3200
  .replace(/\\/g, "\\\\")
3201
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, "")
2392
3202
  .replace(/"/g, "")
2393
3203
  .replace(/[\r\n]/g, " ")
2394
3204
  .trim();
@@ -2409,6 +3219,30 @@ function _sanitizePathValue(value) {
2409
3219
  .trim();
2410
3220
  }
2411
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
+
2412
3246
  /**
2413
3247
  * Attempt to auto-discover GitHub Projects v2 configuration for the given owner.
2414
3248
  *
@@ -2761,10 +3595,15 @@ function buildForgeYamlContent({
2761
3595
  techStack: _sanitizeYamlValue(
2762
3596
  review.techStack || "Node.js, TypeScript, PostgreSQL",
2763
3597
  ),
2764
- // 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.
2765
3601
  context: (
2766
3602
  review.context || "Add architecture notes and conventions here."
2767
- ).replace(/"/g, ""),
3603
+ )
3604
+ .replace(/\r\n|\r/g, "\n")
3605
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, "")
3606
+ .replace(/"/g, ""),
2768
3607
  }
2769
3608
  : null;
2770
3609
 
@@ -2803,6 +3642,26 @@ function buildForgeYamlContent({
2803
3642
  const resolvedFieldIds = projectBoard?.fieldIds ?? {};
2804
3643
  const resolvedOptionIds = projectBoard?.optionIds ?? {};
2805
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
+
2806
3665
  /**
2807
3666
  * Build a YAML block for option_ids given a nested map of { fieldKey: { optionKey: id } }.
2808
3667
  * Only writes fields that have at least one mapped option.
@@ -2814,9 +3673,11 @@ function buildForgeYamlContent({
2814
3673
  if (entries.length === 0) return "";
2815
3674
  const lines = [" option_ids:"];
2816
3675
  for (const [fieldKey, opts] of entries) {
2817
- lines.push(` ${fieldKey}:`);
3676
+ lines.push(` ${_sanitizeYamlKey(fieldKey)}:`);
2818
3677
  for (const [optKey, optId] of Object.entries(opts)) {
2819
- lines.push(` ${optKey}: "${optId}"`);
3678
+ lines.push(
3679
+ ` ${_sanitizeYamlKey(optKey)}: "${_sanitizeYamlValue(String(optId))}"`,
3680
+ );
2820
3681
  }
2821
3682
  }
2822
3683
  return "\n" + lines.join("\n");
@@ -2824,20 +3685,20 @@ function buildForgeYamlContent({
2824
3685
 
2825
3686
  const fieldIdsBlock = FIELD_KEYS.map(
2826
3687
  (k) =>
2827
- ` ${k}: "${resolvedFieldIds[k] ?? "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
3688
+ ` ${k}: "${safeProjectBoard?.fieldIds[k] || "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"}"`,
2828
3689
  ).join("\n");
2829
3690
 
2830
3691
  const optionIdsBlock = projectBoard?.optionIds
2831
3692
  ? _buildOptionIdsBlock(resolvedOptionIds)
2832
3693
  : "";
2833
3694
 
2834
- const hasDiscoveredId = !!projectBoard?.projectId;
3695
+ const hasDiscoveredId = !!safeProjectBoard?.projectId;
2835
3696
 
2836
3697
  const projectBoardSection = projectBoard
2837
3698
  ? `project_board:
2838
3699
  owner: "${owner}"
2839
3700
  project_number: ${projectBoard.projectNumber || 1}
2840
- project_id: "${hasDiscoveredId ? projectBoard.projectId : "PVT_kwHOxxxxxxxxxxxxxxxx"}"
3701
+ project_id: "${hasDiscoveredId ? safeProjectBoard.projectId : "PVT_kwHOxxxxxxxxxxxxxxxx"}"
2841
3702
  field_ids:
2842
3703
  ${fieldIdsBlock}${optionIdsBlock}${!hasDiscoveredId ? `\n# To find IDs: gh project field-list ${projectBoard.projectNumber || 1} --owner ${owner}` : ""}`
2843
3704
  : `# project_board:
@@ -2941,10 +3802,120 @@ ${verificationSection}
2941
3802
  `;
2942
3803
  }
2943
3804
 
2944
- /** 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
+ */
2945
3897
  function _writeForgeYaml(opts) {
2946
- const content = buildForgeYamlContent(opts);
2947
- 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
+ }
2948
3919
  }
2949
3920
 
2950
3921
  /** Print next-steps guidance after forge.yaml is written. */
@@ -3077,6 +4048,7 @@ async function validate(forgeYamlPath) {
3077
4048
  note: `Not found: ${forgeYamlPath}`,
3078
4049
  });
3079
4050
  _renderValidationSummary(checks);
4051
+ await _offerRemediations(checks, forgeYamlPath);
3080
4052
  return { passed: false, checks };
3081
4053
  }
3082
4054
 
@@ -3090,6 +4062,7 @@ async function validate(forgeYamlPath) {
3090
4062
  note: `Cannot read: ${err.message}`,
3091
4063
  });
3092
4064
  _renderValidationSummary(checks);
4065
+ await _offerRemediations(checks, forgeYamlPath);
3093
4066
  return { passed: false, checks };
3094
4067
  }
3095
4068
 
@@ -3278,77 +4251,445 @@ async function validate(forgeYamlPath) {
3278
4251
  timeout: 10000,
3279
4252
  },
3280
4253
  );
3281
- const parsed = JSON.parse(result);
3282
- if (parsed?.data?.node?.id) {
3283
- checks.push({
3284
- label: "Project board configured",
3285
- status: "ok",
3286
- note: `${projectId.slice(0, 16)}... resolves`,
3287
- });
3288
- } else {
3289
- checks.push({
3290
- label: "Project board configured",
3291
- status: "warn",
3292
- note: "project_id may be invalid — verify with: gh project list",
3293
- });
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
+ }
3294
4534
  }
3295
- } catch {
3296
- checks.push({
3297
- label: "Project board configured",
3298
- status: "warn",
3299
- note: "Cannot verify project_id — gh error",
3300
- });
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
+ );
3301
4550
  }
4551
+ continue;
3302
4552
  }
3303
- } else {
3304
- checks.push({
3305
- label: "Project board",
3306
- status: "warn",
3307
- note: "Not configured (optional)",
3308
- });
3309
- }
3310
4553
 
3311
- // 8. Satellite repo access
3312
- if (_sectionActive(content, "repos")) {
3313
- const satellites = _parseSatelliteRepos(content);
3314
- if (satellites.length === 0) {
3315
- checks.push({
3316
- label: "Satellite repos",
3317
- status: "warn",
3318
- note: "repos: section active but no satellites found",
3319
- });
3320
- } else {
3321
- for (const sat of satellites) {
3322
- try {
3323
- execFileSync("gh", ["repo", "view", sat, "--json", "name"], {
3324
- stdio: ["pipe", "pipe", "pipe"],
3325
- timeout: 10000,
3326
- });
3327
- checks.push({
3328
- label: `Satellite: ${sat}`,
3329
- status: "ok",
3330
- note: "Accessible",
3331
- });
3332
- } catch {
3333
- checks.push({
3334
- label: `Satellite: ${sat}`,
3335
- status: "warn",
3336
- note: "Cannot access — check repo name or gh auth",
3337
- });
3338
- }
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
+ );
3339
4640
  }
4641
+ continue;
4642
+ }
4643
+
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;
4662
+ }
4663
+
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
+ );
3340
4689
  }
3341
- } else {
3342
- checks.push({
3343
- label: "Satellite repos",
3344
- status: "warn",
3345
- note: "Not configured (optional)",
3346
- });
3347
4690
  }
3348
4691
 
3349
- _renderValidationSummary(checks);
3350
- const hasError = checks.some((c) => c.status === "error");
3351
- return { passed: !hasError, checks };
4692
+ console.log("");
3352
4693
  }
3353
4694
 
3354
4695
  /**
@@ -3738,6 +5079,161 @@ async function docsInit() {
3738
5079
  console.log("");
3739
5080
  }
3740
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
+
3741
5237
  function help() {
3742
5238
  console.log("");
3743
5239
  console.log(
@@ -3752,6 +5248,12 @@ function help() {
3752
5248
  console.log(
3753
5249
  ` ${CYAN}npx forgedock init${RESET} Generate forge.yaml config for your project`,
3754
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
+ );
3755
5257
  console.log(
3756
5258
  ` ${CYAN}npx forgedock validate${RESET} Validate forge.yaml configuration`,
3757
5259
  );
@@ -3779,6 +5281,15 @@ function help() {
3779
5281
  console.log(
3780
5282
  ` ${CYAN}npx forgedock docs init${RESET} Scaffold devdocs knowledge tree into current project`,
3781
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
+ );
3782
5293
  console.log(` ${CYAN}npx forgedock help${RESET} Show this help`);
3783
5294
  console.log("");
3784
5295
  }
@@ -4223,6 +5734,15 @@ if (!command) {
4223
5734
  }
4224
5735
  break;
4225
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;
4226
5746
  case "help":
4227
5747
  case "--help":
4228
5748
  case "-h":