forgedock 1.0.22 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +217 -218
  2. package/bin/cinema.mjs +320 -0
  3. package/bin/engine/phases.mjs +148 -0
  4. package/bin/engine/projector.mjs +32 -0
  5. package/bin/engine/reconcile.mjs +11 -0
  6. package/bin/engine/runlog.mjs +79 -0
  7. package/bin/engine/state.mjs +27 -0
  8. package/bin/engine-cli.mjs +50 -0
  9. package/bin/engine.mjs +138 -0
  10. package/bin/forgedock.mjs +464 -1491
  11. package/bin/journey.mjs +783 -0
  12. package/bin/runner.mjs +80 -13
  13. package/bin/settings-hook.mjs +112 -0
  14. package/bin/tui.mjs +6 -3
  15. package/commands/autopilot.md +4 -4
  16. package/commands/deploy-info.md +5 -3
  17. package/commands/design-bench.md +1 -0
  18. package/commands/design-render-critique-loop.md +1 -0
  19. package/commands/design.md +1 -0
  20. package/commands/forge-stats.md +1 -0
  21. package/commands/issue.md +1 -1
  22. package/commands/orchestrate.md +27 -9
  23. package/commands/quality-gate.md +80 -34
  24. package/commands/review-pr-agents.md +7 -7
  25. package/commands/review-pr.md +21 -4
  26. package/commands/work-on/build/implement.md +16 -4
  27. package/commands/work-on/build/validate.md +38 -1
  28. package/commands/work-on/build.md +27 -9
  29. package/commands/work-on/close.md +2 -2
  30. package/commands/work-on/review.md +24 -0
  31. package/commands/work-on-monolithic.md +1 -0
  32. package/commands/work-on.md +45 -3
  33. package/docs/BRAND.md +6 -2
  34. package/docs/CONFIG.md +39 -34
  35. package/docs/site/.vitepress/config.mjs +85 -0
  36. package/docs/site/getting-started.md +15 -8
  37. package/docs/site/index.md +1 -1
  38. package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
  39. package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
  40. package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
  41. package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
  42. package/package.json +10 -4
  43. package/scripts/verify-env-vars.sh +18 -2
  44. package/scripts/verify-host-headers.sh +4 -4
  45. package/scripts/verify-route-registration.sh +2 -2
  46. package/scripts/worktree-lifecycle.sh +202 -0
package/bin/forgedock.mjs CHANGED
@@ -2,12 +2,11 @@
2
2
  // SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
- import { fileURLToPath, pathToFileURL } from "url";
6
- import { basename, dirname, join, relative, resolve } from "path";
7
- import { mkdir, symlink, readlink, lstat, readdir, stat, unlink } from "fs/promises";
5
+ import { fileURLToPath } from "url";
6
+ import { dirname, join, relative, resolve } from "path";
7
+ import { mkdir, lstat, readlink, readdir, unlink, readFile, writeFile } from "fs/promises";
8
8
  import {
9
9
  existsSync,
10
- appendFileSync,
11
10
  readFileSync,
12
11
  writeFileSync,
13
12
  renameSync,
@@ -15,17 +14,32 @@ import {
15
14
  } from "fs";
16
15
  import { execSync, execFileSync } from "child_process";
17
16
  import { homedir } from "os";
17
+ import {
18
+ makeCtx,
19
+ runJourney,
20
+ forge,
21
+ read,
22
+ review,
23
+ celebrate,
24
+ findMarkdownFiles,
25
+ parseInstallTier,
26
+ writeForgeYaml,
27
+ backupExisting,
28
+ manualLowConfidenceKeys,
29
+ } from "./journey.mjs";
30
+ import { removeSessionStartHook } from "./settings-hook.mjs";
31
+ import { resolveState, setOptOut, clearNudgeSeen } from "./registry.mjs";
32
+ import { renderMark, ember } from "./cinema.mjs";
18
33
  import {
19
34
  renderLogo,
20
- runSteps,
21
35
  box,
22
36
  table,
23
- bold,
37
+ input,
24
38
  dim,
25
39
  green,
26
- red,
27
40
  yellow,
28
41
  cyan,
42
+ createProgressBar,
29
43
  RESET,
30
44
  BOLD,
31
45
  GREEN,
@@ -40,24 +54,25 @@ const __dirname = dirname(__filename);
40
54
  const FORGE_HOME = dirname(__dirname);
41
55
  const COMMANDS_DIR = join(FORGE_HOME, "commands");
42
56
 
43
- // Resolve home directory cross-platform: HOME on Unix, USERPROFILE on Windows.
44
- // os.homedir() handles all supported platforms without a hard exit — see #744.
45
- const HOME = homedir();
57
+ // Resolve home cross-platform: HOME on Unix, USERPROFILE on Windows, with
58
+ // os.homedir() as the always-available fallback (no hard exit — see #744).
59
+ const HOME = process.env.HOME || process.env.USERPROFILE || homedir();
46
60
 
47
61
  const TARGET_DIR = join(HOME, ".claude", "commands");
62
+ const MANIFEST_PATH = join(HOME, ".claude", "forgedock", "copied-commands.json");
48
63
  const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
49
64
  const SCRIPTS_TARGET_DIR = join(HOME, ".claude", "scripts");
50
65
 
51
66
  /**
52
- * Allowlist of pipeline-agent scripts that get installed to ~/.claude/scripts/.
53
- * Only these files are symlinked (or copied on Windows) during install/update.
67
+ * Allowlist of pipeline-agent scripts that `uninstall` cleans up from
68
+ * ~/.claude/scripts/. The journey-based install no longer writes these
69
+ * itself — this set only identifies leftovers from legacy installs so
70
+ * `uninstall` can find and remove them.
54
71
  *
55
72
  * Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
56
- * installed — those scripts are invoked directly via $FORGE_HOME/scripts/ by
73
+ * covered here — those scripts are invoked directly via $FORGE_HOME/scripts/ by
57
74
  * review-pr.md and quality-gate.md and should not pollute the user's Claude
58
75
  * scripts namespace.
59
- *
60
- * When adding a new pipeline-agent script, add its filename here.
61
76
  */
62
77
  const PIPELINE_SCRIPTS = new Set([
63
78
  "classify-lane.sh",
@@ -65,8 +80,16 @@ const PIPELINE_SCRIPTS = new Set([
65
80
  "validate-pr-target.sh",
66
81
  ]);
67
82
 
68
- const args = process.argv.slice(2);
69
- const command = args[0] || "install";
83
+ // Journey flags are stripped from positionals and fed to makeCtx via ctx().
84
+ // --minimal is init-only. Subcommands with their own flag parsing (run,
85
+ // run-issue, demo) receive restArgs — everything after the command token.
86
+ const rawArgs = process.argv.slice(2);
87
+ const FLAGS = new Set(["--fast", "--manual", "--verbose", "--minimal"]);
88
+ const flags = rawArgs.filter((a) => FLAGS.has(a));
89
+ const positional = rawArgs.filter((a) => !FLAGS.has(a));
90
+ const command = positional[0] || "install";
91
+ const cmdIdx = rawArgs.findIndex((a) => !FLAGS.has(a));
92
+ const restArgs = cmdIdx === -1 ? [] : rawArgs.slice(cmdIdx + 1);
70
93
 
71
94
  // ---------------------------------------------------------------------------
72
95
  // SessionStart hook — settings.json helpers
@@ -78,71 +101,6 @@ const command = args[0] || "install";
78
101
  */
79
102
  const CLAUDE_SETTINGS_PATH = join(HOME, ".claude", "settings.json");
80
103
 
81
- /**
82
- * Timeout (in seconds) for the SessionStart hook entry written into
83
- * ~/.claude/settings.json. Claude Code hook timeouts are in seconds —
84
- * not milliseconds like the Node.js child_process timeouts elsewhere in
85
- * this file.
86
- */
87
- const SESSION_START_HOOK_TIMEOUT_SECONDS = 10;
88
-
89
- /**
90
- * Escape all shell metacharacters that receive special treatment inside
91
- * POSIX double-quoted strings. Apply this to any filesystem path before
92
- * embedding it in a double-quoted shell assignment or command argument.
93
- *
94
- * Characters escaped and why:
95
- * " → \" (closes the double-quoted context — ref: forge#451)
96
- * $ → \$ (variable expansion and $(...) command substitution — ref: forge#792)
97
- * ` → \` (legacy backtick command substitution — ref: forge#792)
98
- * ! → \! (bash history expansion — harmless in sh but risky in bash — ref: forge#792)
99
- *
100
- * Backslash (\) is intentionally NOT escaped here: path.join() backslashes
101
- * must be converted to forward slashes by the caller before this function
102
- * is applied (see sessionStartHookCommand below). On POSIX systems,
103
- * paths never contain backslashes, so this is a no-op for shell profiles.
104
- *
105
- * <!-- Added: forge#808 -->
106
- *
107
- * @param {string} path - Filesystem path to escape for shell double-quote context.
108
- * @returns {string} - Path safe for embedding inside a double-quoted shell string.
109
- */
110
- function shellEscapeDoubleQuotedPath(path) {
111
- return path
112
- .replace(/\\/g, '\\\\') // backslash — must be first to avoid double-escaping
113
- .replace(/"/g, '\\"') // double quote — breaks out of the argument
114
- .replace(/\$/g, '\\$') // dollar sign — variable/command substitution
115
- .replace(/`/g, '\\`') // backtick — legacy command substitution
116
- .replace(/!/g, '\\!'); // exclamation mark — bash history expansion
117
- }
118
-
119
- /**
120
- * The command value written into the SessionStart hook entry.
121
- * Uses forward slashes even on Windows — Node accepts them natively and
122
- * they avoid any backslash-related issues in the hook command string.
123
- * Backslashes emitted by path.join() on Windows are normalised to forward
124
- * slashes on line 101 before any escaping is applied.
125
- *
126
- * When Claude Code's hook runner passes the command string through a POSIX
127
- * shell (sh -c), the following characters receive special treatment inside
128
- * double-quoted strings and must be escaped — see shellEscapeDoubleQuotedPath().
129
- *
130
- * `node --` is used to terminate Node.js option parsing so that a path
131
- * component beginning with `-` cannot be misinterpreted as a CLI flag.
132
- *
133
- * @returns {string}
134
- */
135
- function sessionStartHookCommand() {
136
- const hookPath = join(
137
- FORGE_HOME,
138
- "bin",
139
- "hooks",
140
- "session-start.mjs",
141
- ).replace(/\\/g, "/");
142
- const safePath = shellEscapeDoubleQuotedPath(hookPath);
143
- return `node -- "${safePath}"`;
144
- }
145
-
146
104
  /**
147
105
  * Check whether a hook command string refers to the ForgeDock SessionStart hook.
148
106
  *
@@ -277,28 +235,6 @@ function readClaudeSettings() {
277
235
  }
278
236
  }
279
237
 
280
- /**
281
- * Atomically write an object to ~/.claude/settings.json.
282
- * Uses a .tmp sibling + renameSync to avoid partial writes.
283
- * Cleans up the tmp file on failure. (ref: forge#444)
284
- *
285
- * @param {object} settings
286
- */
287
- function writeClaudeSettings(settings) {
288
- const tmpPath = CLAUDE_SETTINGS_PATH + ".forgedock.tmp";
289
- try {
290
- writeFileSync(tmpPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
291
- renameSync(tmpPath, CLAUDE_SETTINGS_PATH);
292
- } catch (err) {
293
- try {
294
- unlinkSync(tmpPath);
295
- } catch {
296
- /* already gone or never created */
297
- }
298
- throw err;
299
- }
300
- }
301
-
302
238
  /**
303
239
  * Atomically write text content to a file.
304
240
  * Uses a .forgedock.tmp sibling + renameSync to avoid partial writes on
@@ -322,180 +258,6 @@ function atomicWriteFile(filePath, content) {
322
258
  }
323
259
  }
324
260
 
325
- /**
326
- * Idempotently write a `.symlink-source` sentinel file to TARGET_DIR
327
- * (~/.claude/commands/) so other installers can detect that ForgeDock
328
- * owns this namespace.
329
- *
330
- * The sentinel is a plain UTF-8 text file (not a symlink) containing
331
- * the source path, a reinstall hint, and the install timestamp. It is
332
- * updated on every install so the timestamp stays fresh. Write failures
333
- * are non-fatal — the function returns 'failed' and the caller logs a
334
- * warning; install continues regardless.
335
- *
336
- * Calling convention matches the other state-write helpers:
337
- * 'written' — sentinel was newly created or refreshed
338
- * 'failed' — an error occurred (non-fatal; install continues)
339
- *
340
- * <!-- Added: forge#1038 -->
341
- *
342
- * @returns {'written' | 'failed'}
343
- */
344
- function writeSymlinkSentinel() {
345
- const sentinelPath = join(TARGET_DIR, ".symlink-source");
346
- const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19);
347
- const content =
348
- `# ForgeDock command symlinks — DO NOT REPOINT\n` +
349
- `# Source: ${COMMANDS_DIR}\n` +
350
- // Machine-readable version line — read by install() on the next run to
351
- // detect first-time vs. update installs and render a version diff (#1146).
352
- `# Version: ${getVersion()}\n` +
353
- `#\n` +
354
- `# These symlinks are managed by ForgeDock (https://forgedock.com).\n` +
355
- `# ForgeDock owns the global ~/.claude/commands/ namespace.\n` +
356
- `# Project-specific commands should install to the project's\n` +
357
- `# .claude/commands/ directory instead (Claude Code merges both;\n` +
358
- `# project-local commands win on name collisions).\n` +
359
- `#\n` +
360
- `# Running another installer here will silently repoint these\n` +
361
- `# symlinks, breaking all ForgeDock commands globally.\n` +
362
- `#\n` +
363
- `# To reinstall ForgeDock: npx forgedock install\n` +
364
- `# Last installed: ${timestamp}\n`;
365
- try {
366
- atomicWriteFile(sentinelPath, content);
367
- return "written";
368
- } catch {
369
- return "failed";
370
- }
371
- }
372
-
373
- /**
374
- * Install the ForgeDock SessionStart hook into ~/.claude/settings.json
375
- * idempotently. Does not modify any existing hooks unrelated to ForgeDock.
376
- *
377
- * Returns:
378
- * 'installed' — hook was newly added
379
- * 'already-present' — hook was already there (idempotent)
380
- * 'failed' — an error occurred (non-fatal; install continues)
381
- *
382
- * @returns {Promise<'installed' | 'already-present' | 'failed'>}
383
- */
384
- async function installSessionStartHook() {
385
- try {
386
- const settings = readClaudeSettings();
387
-
388
- // Ensure the hooks section exists
389
- if (!settings.hooks || typeof settings.hooks !== "object") {
390
- settings.hooks = {};
391
- }
392
-
393
- // Ensure SessionStart array exists
394
- if (!Array.isArray(settings.hooks.SessionStart)) {
395
- settings.hooks.SessionStart = [];
396
- }
397
-
398
- const cmd = sessionStartHookCommand();
399
-
400
- // Check if already present — match by path suffix so uninstall/reinstall
401
- // across different FORGE_HOME paths still works correctly. (ref: forge#537)
402
- const alreadyPresent = settings.hooks.SessionStart.some((entry) => {
403
- if (!entry || typeof entry !== "object") return false;
404
- const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
405
- return hooks.some(
406
- (h) =>
407
- h &&
408
- typeof h.command === "string" &&
409
- isForgeSessionStartHook(h.command),
410
- );
411
- });
412
-
413
- if (alreadyPresent) return "already-present";
414
-
415
- // Append the new hook entry (matcher omitted — fires on every session start)
416
- settings.hooks.SessionStart.push({
417
- hooks: [
418
- {
419
- type: "command",
420
- command: cmd,
421
- timeout: SESSION_START_HOOK_TIMEOUT_SECONDS,
422
- },
423
- ],
424
- });
425
-
426
- writeClaudeSettings(settings);
427
- return "installed";
428
- } catch {
429
- return "failed";
430
- }
431
- }
432
-
433
- /**
434
- * Remove the ForgeDock SessionStart hook from ~/.claude/settings.json
435
- * idempotently. Only removes the entry written by installSessionStartHook().
436
- * Unrelated hooks are preserved.
437
- *
438
- * Returns:
439
- * 'removed' — hook was found and removed
440
- * 'not-present' — hook was not in settings.json (already clean)
441
- * 'failed' — an error occurred (non-fatal; uninstall continues)
442
- *
443
- * @returns {Promise<'removed' | 'not-present' | 'failed'>}
444
- */
445
- async function removeSessionStartHook() {
446
- try {
447
- const settings = readClaudeSettings();
448
-
449
- if (
450
- !settings.hooks ||
451
- !Array.isArray(settings.hooks.SessionStart) ||
452
- settings.hooks.SessionStart.length === 0
453
- ) {
454
- return "not-present";
455
- }
456
-
457
- const originalLength = settings.hooks.SessionStart.length;
458
-
459
- // Filter out the ForgeDock-managed entry, keep everything else
460
- settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
461
- (entry) => {
462
- if (!entry || typeof entry !== "object") return true;
463
- const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
464
- const isForgeEntry = hooks.some(
465
- (h) =>
466
- h &&
467
- typeof h.command === "string" &&
468
- isForgeSessionStartHook(h.command),
469
- );
470
- return !isForgeEntry;
471
- },
472
- );
473
-
474
- if (settings.hooks.SessionStart.length === originalLength) {
475
- return "not-present";
476
- }
477
-
478
- // Clean up empty SessionStart array to leave settings.json tidy
479
- if (settings.hooks.SessionStart.length === 0) {
480
- delete settings.hooks.SessionStart;
481
- }
482
-
483
- // Clean up empty hooks object
484
- if (
485
- settings.hooks &&
486
- typeof settings.hooks === "object" &&
487
- Object.keys(settings.hooks).length === 0
488
- ) {
489
- delete settings.hooks;
490
- }
491
-
492
- writeClaudeSettings(settings);
493
- return "removed";
494
- } catch {
495
- return "failed";
496
- }
497
- }
498
-
499
261
  // ---------------------------------------------------------------------------
500
262
  // Version + splash — read package.json version and render branded logo
501
263
  // ---------------------------------------------------------------------------
@@ -527,148 +289,6 @@ function splash() {
527
289
  const CLAUDE_BLOCK_BEGIN = "<!-- BEGIN FORGEDOCK -->";
528
290
  const CLAUDE_BLOCK_END = "<!-- END FORGEDOCK -->";
529
291
 
530
- /**
531
- * The behavioral rules block injected into the project's CLAUDE.md.
532
- * Kept ≤30 lines (per issue #607 requirement).
533
- * Content is fixed — this is NOT documentation, it is behavioral guidance.
534
- */
535
- const FORGEDOCK_MANAGED_BLOCK = `${CLAUDE_BLOCK_BEGIN}
536
- ## ForgeDock Pipeline Rules
537
-
538
- This project uses ForgeDock for structured development. Follow these rules:
539
-
540
- 1. **Issue-first**: When the user describes a bug, feature, or task conversationally — create a GitHub issue with \`/issue\` before writing any code. Never inline-fix without an issue number.
541
- 2. **Pipeline flow**: All implementation goes through \`/work-on <issue#>\`. This runs: investigate → architect → build → review → merge.
542
- 3. **Traceability**: Every code change must link to a GitHub issue. Use FORGE annotations to pass context between pipeline phases.
543
- 4. **Findings become issues**: When you discover bugs, inconsistencies, or improvements during other work — create a GitHub issue (\`gh issue create\`), don't fix inline.
544
- 5. **Available commands**: \`/work-on\`, \`/issue\`, \`/review-pr\`, \`/orchestrate\`, \`/quality-gate\`, \`/milestone\`, \`/autopilot\`
545
-
546
- For full documentation, see: [ForgeDock docs](https://forgedock.com/docs)
547
- ${CLAUDE_BLOCK_END}`;
548
-
549
- /**
550
- * Returns true if `dir` is inside a git work tree.
551
- * Used to guard CLAUDE.md injection against non-project directories.
552
- *
553
- * @param {string} dir
554
- * @returns {boolean}
555
- */
556
- function isGitWorkTree(dir) {
557
- try {
558
- const out = execSync("git rev-parse --is-inside-work-tree", {
559
- cwd: dir,
560
- stdio: ["ignore", "pipe", "ignore"],
561
- encoding: "utf-8",
562
- });
563
- return out.trim() === "true";
564
- } catch {
565
- return false;
566
- }
567
- }
568
-
569
- /**
570
- * Idempotently inject the ForgeDock managed block into a single file.
571
- *
572
- * Handles all corrupt marker states (ref: forge#269, forge#291):
573
- * - File missing → create file containing block only
574
- * - No markers → append block to existing content
575
- * - BEGIN + END (correct) → replace content between markers
576
- * - BEGIN only → strip orphaned BEGIN, re-append full block
577
- * - END only → strip orphaned END, re-append full block
578
- * - END before BEGIN → strip both + content between, re-append full block
579
- * - Block already current → return 'unchanged'
580
- *
581
- * Returns:
582
- * 'created' — file was created with block
583
- * 'updated' — existing block was replaced with new content
584
- * 'unchanged' — block was already present and current
585
- * 'appended' — block was appended (no prior markers)
586
- *
587
- * @param {string} filePath - Absolute path to CLAUDE.md or AGENTS.md
588
- * @returns {'created' | 'updated' | 'unchanged' | 'appended'}
589
- */
590
- function injectManagedBlock(filePath) {
591
- const managed = FORGEDOCK_MANAGED_BLOCK;
592
-
593
- // File does not exist — create it with the block
594
- if (!existsSync(filePath)) {
595
- atomicWriteFile(filePath, managed + "\n");
596
- return "created";
597
- }
598
-
599
- let existing = readFileSync(filePath, "utf-8");
600
-
601
- const hasBegin = existing.includes(CLAUDE_BLOCK_BEGIN);
602
- const hasEnd = existing.includes(CLAUDE_BLOCK_END);
603
-
604
- if (hasBegin && hasEnd) {
605
- // Determine marker order
606
- const beginIdx = existing.indexOf(CLAUDE_BLOCK_BEGIN);
607
- const endIdx = existing.indexOf(CLAUDE_BLOCK_END);
608
-
609
- if (beginIdx < endIdx) {
610
- // Normal order — replace content between markers (inclusive)
611
- // Non-greedy match so we don't over-consume if someone manually
612
- // duplicated the markers.
613
- const blockRegex = new RegExp(
614
- escapeRegExp(CLAUDE_BLOCK_BEGIN) +
615
- "[\\s\\S]*?" +
616
- escapeRegExp(CLAUDE_BLOCK_END),
617
- );
618
- if (!blockRegex.test(existing)) {
619
- // Should not happen: both markers were confirmed above via includes() and
620
- // indexOf() ordering. Guard against silent misclassification — if the regex
621
- // somehow fails to match, replaced === existing would incorrectly return
622
- // 'unchanged' instead of surfacing the broken state.
623
- throw new Error(
624
- `injectManagedBlock: regex failed to match markers in ${filePath}`,
625
- );
626
- }
627
- const replaced = existing.replace(blockRegex, managed);
628
- if (replaced === existing) {
629
- // Content between markers was already identical to the managed block
630
- return "unchanged";
631
- }
632
- atomicWriteFile(filePath, replaced);
633
- return "updated";
634
- } else {
635
- // Reversed markers (END before BEGIN) — strip both markers + everything
636
- // between them, then fall through to append. (ref: forge#291 BUG-2)
637
- const strippedReversed = existing
638
- .replace(
639
- new RegExp(
640
- escapeRegExp(CLAUDE_BLOCK_END) +
641
- "[\\s\\S]*?" +
642
- escapeRegExp(CLAUDE_BLOCK_BEGIN),
643
- ),
644
- "",
645
- )
646
- // Strip any leftover isolated markers
647
- .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
648
- .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "");
649
- existing = strippedReversed.trimEnd();
650
- // Fall through to append
651
- }
652
- } else if (hasBegin) {
653
- // Orphaned BEGIN — strip it, then fall through to append. (ref: forge#269)
654
- existing = existing
655
- .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_BEGIN), "g"), "")
656
- .trimEnd();
657
- // Fall through to append
658
- } else if (hasEnd) {
659
- // Orphaned END — strip it, then fall through to append. (ref: forge#291 BUG-1)
660
- existing = existing
661
- .replace(new RegExp(escapeRegExp(CLAUDE_BLOCK_END), "g"), "")
662
- .trimEnd();
663
- // Fall through to append
664
- }
665
-
666
- // No valid block present — append
667
- const separator = existing.length > 0 ? "\n\n" : "";
668
- atomicWriteFile(filePath, existing + separator + managed + "\n");
669
- return hasBegin || hasEnd ? "updated" : "appended";
670
- }
671
-
672
292
  /**
673
293
  * Remove the ForgeDock managed block from a single file.
674
294
  * Strips only the marker-bounded section; all surrounding content is preserved.
@@ -747,28 +367,16 @@ function escapeRegExp(str) {
747
367
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
748
368
  }
749
369
 
750
- async function findMarkdownFiles(dir) {
751
- const results = [];
752
- const entries = await readdir(dir, { withFileTypes: true });
753
- for (const entry of entries) {
754
- const full = join(dir, entry.name);
755
- if (entry.isDirectory()) {
756
- results.push(...(await findMarkdownFiles(full)));
757
- } else if (entry.name.endsWith(".md")) {
758
- results.push(full);
759
- }
760
- }
761
- return results.sort();
762
- }
763
-
764
370
  // ---------------------------------------------------------------------------
765
371
  // Shell profile FORGE_HOME removal
766
372
  // ---------------------------------------------------------------------------
767
373
 
768
374
  /**
769
- * The sentinel comment line written by install() immediately before the
770
- * `export FORGE_HOME=` line. This is the reliable anchor for removal —
771
- * it is never present in organic shell profiles.
375
+ * The sentinel comment line legacy installs wrote immediately before the
376
+ * `export FORGE_HOME=` line. The journey-based install no longer writes this
377
+ * block — this constant is used by `uninstall` cleanup for legacy installs
378
+ * that still have it in a shell profile. It is the reliable anchor for
379
+ * removal — never present in organic shell profiles.
772
380
  */
773
381
  const FORGE_HOME_COMMENT = "# ForgeDock — autonomous development pipeline";
774
382
 
@@ -809,13 +417,13 @@ function removeForgeHomeFromProfile(profilePath) {
809
417
  return "not-present";
810
418
  }
811
419
 
812
- // Step 1: Remove the 2-line ForgeDock block (comment + export), preceded
813
- // by an optional leading blank line that install() inserts.
814
- // The \r? handles profiles with CRLF line endings.
420
+ // Step 1: Remove the 2-line ForgeDock block (comment + export) that
421
+ // installs from older builds appended, preceded by an optional leading
422
+ // blank line. The \r? handles profiles with CRLF line endings.
815
423
  // The value pattern (?:[^"\\]|\\.)* matches a double-quoted shell string
816
424
  // that may contain backslash-escape sequences (e.g. \" or \\) written by
817
- // shellEscapeDoubleQuotedPath(). The simpler [^"]* would stop prematurely
818
- // at the escaped-quote character inside \", failing to match the full line.
425
+ // the old installer's shell escaping. The simpler [^"]* would stop
426
+ // prematurely at the escaped-quote character inside \".
819
427
  let cleaned = content.replace(
820
428
  /\r?\n[ \t]*# ForgeDock — autonomous development pipeline\r?\nexport FORGE_HOME="(?:[^"\\]|\\.)*"\r?\n/g,
821
429
  "\n",
@@ -851,93 +459,6 @@ function removeForgeHomeFromProfile(profilePath) {
851
459
  return "removed";
852
460
  }
853
461
 
854
- /**
855
- * Link (symlink) all commands from COMMANDS_DIR to TARGET_DIR.
856
- * Called inside a runSteps() step — uses step.progress() and step.note().
857
- *
858
- * @param {object} step - StepAPI from runSteps
859
- * @returns {Promise<{installed: number, updated: number, skipped: number}>}
860
- */
861
- async function linkCommands(step) {
862
- const files = await findMarkdownFiles(COMMANDS_DIR);
863
- const total = files.length;
864
- let installed = 0;
865
- let updated = 0;
866
- let skipped = 0;
867
-
868
- for (let i = 0; i < files.length; i++) {
869
- const file = files[i];
870
- const rel = relative(COMMANDS_DIR, file);
871
- const target = join(TARGET_DIR, rel);
872
- const targetDir = dirname(target);
873
-
874
- await mkdir(targetDir, { recursive: true });
875
- step.progress(i + 1, total);
876
-
877
- try {
878
- const stats = await lstat(target);
879
-
880
- if (stats.isSymbolicLink()) {
881
- const current = await readlink(target);
882
- if (current === file) {
883
- skipped++;
884
- } else {
885
- // Symlink points to a different source — warn if it appears to belong
886
- // to another Forge/ForgeDock installation (i.e. not a broken link that
887
- // already pointed here under a different absolute path).
888
- // Only emit the warning once per install to avoid log spam.
889
- // <!-- Added: forge#1038 -->
890
- if (!current.startsWith(COMMANDS_DIR)) {
891
- step.note(
892
- yellow(`collision: ${rel} was → ${current} — repointing to ForgeDock`),
893
- );
894
- }
895
- try {
896
- await symlink(file, target + ".tmp");
897
- const { rename } = await import("fs/promises");
898
- await rename(target + ".tmp", target);
899
- updated++;
900
- } catch (renameErr) {
901
- try {
902
- await unlink(target + ".tmp");
903
- } catch {
904
- /* .tmp already gone or was never created */
905
- }
906
- if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
907
- // Windows without Developer Mode: symlink() throws EPERM/EACCES.
908
- // Fall back to a direct copy (matches new-install fallback behaviour).
909
- const { copyFile } = await import("fs/promises");
910
- await copyFile(file, target);
911
- updated++;
912
- } else {
913
- throw renameErr;
914
- }
915
- }
916
- }
917
- } else {
918
- // Regular file — skip; user must remove manually
919
- skipped++;
920
- }
921
- } catch (err) {
922
- if (err.code !== "ENOENT") throw err;
923
- // Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
924
- try {
925
- await symlink(file, target);
926
- } catch (linkErr) {
927
- if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
928
- const { copyFile } = await import("fs/promises");
929
- await copyFile(file, target);
930
- } else {
931
- throw linkErr;
932
- }
933
- }
934
- installed++;
935
- }
936
- }
937
-
938
- return { installed, updated, skipped };
939
- }
940
-
941
462
  /**
942
463
  * Enumerate pipeline-agent scripts in SCRIPTS_DIR that should be installed
943
464
  * to ~/.claude/scripts/. Only files present in PIPELINE_SCRIPTS are returned.
@@ -963,362 +484,96 @@ async function findScriptFiles(dir) {
963
484
  return results.sort();
964
485
  }
965
486
 
966
- /**
967
- * Link or copy all scripts from SCRIPTS_DIR into SCRIPTS_TARGET_DIR (~/.claude/scripts/).
968
- * Mirrors linkCommands() behaviour: symlink where permitted, copy on EPERM/EACCES (Windows).
969
- * Called inside a runSteps() step — uses step.progress() and step.note().
970
- *
971
- * @param {object} step - StepAPI from runSteps
972
- * @returns {Promise<{installed: number, updated: number, skipped: number}>}
973
- */
974
- async function linkScripts(step) {
975
- const files = await findScriptFiles(SCRIPTS_DIR);
976
- const total = files.length;
977
-
978
- if (total === 0) {
979
- step.skip("no scripts to link");
980
- return { installed: 0, updated: 0, skipped: 0 };
981
- }
982
-
983
- await mkdir(SCRIPTS_TARGET_DIR, { recursive: true });
984
-
985
- let installed = 0;
986
- let updated = 0;
987
- let skipped = 0;
988
-
989
- for (let idx = 0; idx < files.length; idx++) {
990
- const file = files[idx];
991
- const rel = relative(SCRIPTS_DIR, file);
992
- const target = join(SCRIPTS_TARGET_DIR, rel);
993
-
994
- step.progress(idx + 1, total);
995
-
996
- try {
997
- const stats = await lstat(target);
998
-
999
- if (stats.isSymbolicLink()) {
1000
- const current = await readlink(target);
1001
- if (current === file) {
1002
- skipped++;
1003
- } else {
1004
- try {
1005
- await symlink(file, target + ".tmp");
1006
- const { rename } = await import("fs/promises");
1007
- await rename(target + ".tmp", target);
1008
- updated++;
1009
- } catch (renameErr) {
1010
- try {
1011
- await unlink(target + ".tmp");
1012
- } catch {
1013
- /* .tmp already gone or was never created */
1014
- }
1015
- if (renameErr.code === "EPERM" || renameErr.code === "EACCES") {
1016
- // Windows without Developer Mode: symlink() throws EPERM/EACCES.
1017
- // Fall back to a direct copy (matches new-install fallback behaviour).
1018
- const { copyFile } = await import("fs/promises");
1019
- await copyFile(file, target);
1020
- updated++;
1021
- } else {
1022
- throw renameErr;
1023
- }
1024
- }
1025
- }
1026
- } else {
1027
- // Existing regular file — refresh if contents differ.
1028
- if (readFileSync(file, "utf-8") === readFileSync(target, "utf-8")) {
1029
- skipped++;
1030
- } else {
1031
- const { copyFile } = await import("fs/promises");
1032
- await copyFile(file, target);
1033
- updated++;
1034
- }
1035
- }
1036
- } catch (err) {
1037
- if (err.code !== "ENOENT") throw err;
1038
- // Doesn't exist — create symlink; fall back to copy on Windows EPERM/EACCES.
1039
- try {
1040
- await symlink(file, target);
1041
- } catch (linkErr) {
1042
- if (linkErr.code === "EPERM" || linkErr.code === "EACCES") {
1043
- const { copyFile } = await import("fs/promises");
1044
- await copyFile(file, target);
1045
- } else {
1046
- throw linkErr;
1047
- }
1048
- }
1049
- installed++;
1050
- }
1051
- }
487
+ // ---------------------------------------------------------------------------
488
+ // Journey context + status screen + init flow (onboarding surface)
489
+ // ---------------------------------------------------------------------------
1052
490
 
1053
- return { installed, updated, skipped };
491
+ function ctx() {
492
+ const c = makeCtx({ argv: flags });
493
+ c.forgeHome = FORGE_HOME;
494
+ c.home = HOME;
495
+ return c;
1054
496
  }
1055
497
 
1056
- async function install() {
1057
- // Detect first-time vs. update install by inspecting the namespace sentinel
1058
- // BEFORE the install steps overwrite it. A missing sentinel means this is a
1059
- // first install; a present one carries the previously installed version (if
1060
- // it was written by a build that records `# Version:`). Reading is non-fatal:
1061
- // any error is treated as a first install so guidance still renders. (#1146)
1062
- const sentinelPath = join(TARGET_DIR, ".symlink-source");
1063
- let priorSentinel = null;
1064
- try {
1065
- priorSentinel = readFileSync(sentinelPath, "utf-8");
1066
- } catch {
1067
- priorSentinel = null;
498
+ /** Compact status screen for configured/managed directories. */
499
+ function statusScreen(c) {
500
+ const state = resolveState(c.cwd);
501
+ const mark = renderMark("compact", c.mode);
502
+ const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
503
+ c.stdout.write("\n" + mark[0] + "\n");
504
+ c.stdout.write(mark[1] + " " + ember("FORGEDOCK", c.mode) + " " + dim("status") + "\n");
505
+ c.stdout.write(mark[2] + "\n" + mark[3] + "\n\n");
506
+ const configured = existsSync(join(c.cwd, "forge.yaml"));
507
+ c.stdout.write(` directory ${state}\n`);
508
+ c.stdout.write(` forge.yaml ${configured ? "present" : "missing"}\n`);
509
+ c.stdout.write(` commands ${existsSync(TARGET_DIR) ? "installed at " + TARGET_DIR : "not installed"}\n`);
510
+ if (state === "managed-optedout") {
511
+ c.stdout.write(`\n ForgeDock is disabled (opted out) here. Re-enable: npx forgedock enable\n`);
512
+ } else if (state === "unmanaged") {
513
+ c.stdout.write(`\n ForgeDock is not active in this directory. Activate: npx forgedock enable\n`);
514
+ } else if (!configured) {
515
+ c.stdout.write(`\n Generate config: npx forgedock init\n`);
516
+ } else {
517
+ c.stdout.write(`\n Reconfigure: npx forgedock init · Refresh commands + hook: npx forgedock update\n`);
1068
518
  }
1069
- const isFirstInstall = priorSentinel === null;
1070
- const priorVersion = priorSentinel
1071
- ? priorSentinel.match(/^# Version:\s*(.+)$/m)?.[1]?.trim() || null
1072
- : null;
1073
- const currentVersion = getVersion();
1074
-
1075
- // Captured from the "Linking commands" step so the post-install summary can
1076
- // report how many commands were installed/changed without recounting.
1077
- let linkStats = { installed: 0, updated: 0, skipped: 0 };
1078
-
1079
- const result = await runSteps([
1080
- {
1081
- label: "Checking environment",
1082
- async run(step) {
1083
- // Worktree guard — refuse to install when FORGE_HOME is a git worktree.
1084
- // In a worktree, FORGE_HOME/.git is a regular file (not a directory).
1085
- // Installing from a worktree would bake an ephemeral path into the
1086
- // symlinks and SessionStart hook; the path breaks when the worktree
1087
- // is cleaned up, taking all Forge commands offline globally.
1088
- // <!-- Added: forge#1037 -->
1089
- const gitPath = join(FORGE_HOME, ".git");
1090
- let gitStat = null;
1091
- try {
1092
- gitStat = await lstat(gitPath);
1093
- } catch (err) {
1094
- if (err.code !== "ENOENT") throw err;
1095
- // .git absent — npm install or detached checkout; not a worktree.
1096
- }
1097
- if (gitStat !== null && gitStat.isFile()) {
1098
- throw new Error(
1099
- `install() is running from a git worktree (${FORGE_HOME}).\n` +
1100
- ` Installing from a worktree would repoint ~/.claude/commands/ symlinks\n` +
1101
- ` to an ephemeral path that breaks when the worktree is deleted.\n` +
1102
- ` Run \`npx forgedock install\` from the main repository clone instead.`,
1103
- );
1104
- }
1105
-
1106
- await mkdir(TARGET_DIR, { recursive: true });
1107
- step.note(cyan(TARGET_DIR));
1108
- },
1109
- },
1110
- {
1111
- label: "Linking commands",
1112
- async run(step) {
1113
- const { installed, updated, skipped } = await linkCommands(step);
1114
- const total = installed + updated + skipped;
1115
- // Hoist for the post-install summary box (#1146).
1116
- linkStats = { installed, updated, skipped };
1117
- step.note(
1118
- `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped (${total} commands total)`,
1119
- );
1120
- },
1121
- },
1122
- {
1123
- label: "Writing namespace sentinel",
1124
- async run(step) {
1125
- // Write .symlink-source to ~/.claude/commands/ so other installers
1126
- // can detect that ForgeDock owns this namespace. Non-fatal on failure.
1127
- // <!-- Added: forge#1038 -->
1128
- const result = writeSymlinkSentinel();
1129
- if (result === "failed") {
1130
- step.note("could not write .symlink-source — run npx forgedock install again to retry");
1131
- } else {
1132
- step.note(cyan(join(TARGET_DIR, ".symlink-source")));
1133
- }
1134
- },
1135
- },
1136
- {
1137
- label: "Linking scripts",
1138
- async run(step) {
1139
- const { installed, updated, skipped } = await linkScripts(step);
1140
- if (installed + updated + skipped > 0) {
1141
- step.note(
1142
- `${green(String(installed))} installed, ${updated} updated, ${dim(String(skipped))} skipped`,
1143
- );
1144
- }
1145
- },
1146
- },
1147
- {
1148
- label: "Configuring shell profile",
1149
- async run(step) {
1150
- // Set FORGE_HOME in shell profiles
1151
- let profileUpdated = false;
1152
- for (const profile of [
1153
- join(HOME, ".bashrc"),
1154
- join(HOME, ".zshrc"),
1155
- ]) {
1156
- if (existsSync(profile)) {
1157
- const content = readFileSync(profile, "utf-8");
1158
- // Escape shell metacharacters in FORGE_HOME before embedding it in
1159
- // a double-quoted shell assignment. A crafted install path
1160
- // containing $(...), backticks, or " characters would otherwise
1161
- // inject arbitrary shell commands executed on the user's next login.
1162
- // shellEscapeDoubleQuotedPath() applies the same escaping used for
1163
- // the SessionStart hook command (ref: forge#808, forge#792, forge#451).
1164
- const safeForgeHome = shellEscapeDoubleQuotedPath(FORGE_HOME);
1165
- const exactExport = `export FORGE_HOME="${safeForgeHome}"`;
1166
- if (content.includes(exactExport)) {
1167
- // Already set to the current path — no update needed.
1168
- } else {
1169
- // Either not present, or set to a stale path — refresh it.
1170
- if (content.includes("FORGE_HOME")) {
1171
- // Strip the old block before appending the updated one.
1172
- const removeResult = removeForgeHomeFromProfile(profile);
1173
- if (removeResult === "failed") {
1174
- // Removal failed — old export still present. Skip append to
1175
- // avoid writing a duplicate FORGE_HOME export. (ref: forge#846)
1176
- continue;
1177
- }
1178
- }
1179
- appendFileSync(
1180
- profile,
1181
- `\n# ForgeDock — autonomous development pipeline\nexport FORGE_HOME="${safeForgeHome}"\n`,
1182
- );
1183
- profileUpdated = true;
1184
- }
1185
- }
1186
- }
1187
- if (!profileUpdated) {
1188
- step.skip("FORGE_HOME already set");
1189
- }
1190
- },
1191
- },
1192
- {
1193
- label: "Registering SessionStart hook",
1194
- async run(step) {
1195
- const hookResult = await installSessionStartHook();
1196
- if (hookResult === "already-present") {
1197
- step.skip("already present");
1198
- } else if (hookResult === "failed") {
1199
- // Non-fatal — warn but don't throw
1200
- step.note("could not write — run npx forgedock install again to retry");
1201
- }
1202
- },
1203
- },
1204
- {
1205
- label: "Generating forge.yaml",
1206
- async run(step) {
1207
- // Auto-generate forge.yaml if missing — no second command needed.
1208
- // Only do this inside a real git project (ref: forge#585).
1209
- const cwd = process.cwd();
1210
- const forgeYamlPath = join(cwd, "forge.yaml");
1211
- if (existsSync(forgeYamlPath)) {
1212
- step.skip("forge.yaml already exists");
1213
- return;
1214
- }
1215
- if (!isGitWorkTree(cwd)) {
1216
- step.skip("not a git project — run npx forgedock init inside your project");
1217
- return;
1218
- }
1219
- await init(true);
1220
- },
1221
- },
1222
- {
1223
- label: "Updating CLAUDE.md",
1224
- async run(step) {
1225
- // Inject behavioral rules into CLAUDE.md (and AGENTS.md if present).
1226
- // Only inside a git project — guards against non-project cwd (ref: forge#585).
1227
- const installCwd = process.cwd();
1228
- if (!isGitWorkTree(installCwd)) {
1229
- step.skip("not a git project");
1230
- return;
1231
- }
1232
-
1233
- const claudeMdPath = join(installCwd, "CLAUDE.md");
1234
- const agentsMdPath = join(installCwd, "AGENTS.md");
1235
-
1236
- const claudeResult = injectManagedBlock(claudeMdPath);
1237
- let note = "";
1238
- if (claudeResult === "created") {
1239
- note = "CLAUDE.md created";
1240
- } else if (claudeResult === "updated") {
1241
- note = "CLAUDE.md block refreshed";
1242
- } else if (claudeResult === "appended") {
1243
- note = "CLAUDE.md rules appended";
1244
- } else {
1245
- note = "CLAUDE.md already current";
1246
- }
1247
-
1248
- // Mirror to AGENTS.md only if it already exists (never create it)
1249
- if (existsSync(agentsMdPath)) {
1250
- const agentsResult = injectManagedBlock(agentsMdPath);
1251
- if (agentsResult === "updated" || agentsResult === "appended") {
1252
- note += " · AGENTS.md mirrored";
1253
- }
1254
- }
519
+ c.stdout.write("\n");
520
+ }
1255
521
 
1256
- step.note(note);
1257
- },
1258
- },
1259
- ]);
1260
-
1261
- if (result.ok) {
1262
- const totalCommands =
1263
- linkStats.installed + linkStats.updated + linkStats.skipped;
1264
- const changedCommands = linkStats.installed + linkStats.updated;
1265
- const versionLabel = currentVersion ? `v${currentVersion}` : "";
1266
-
1267
- if (isFirstInstall) {
1268
- // First-time install — show the full guided next-steps box so the user
1269
- // has a prioritized path from "installed" to "using it". (#1146)
1270
- const cmd = (text) => cyan(text.padEnd(22));
1271
- process.stderr.write(
1272
- box(
1273
- [
1274
- `${green("✔")} ${bold(`ForgeDock ${versionLabel}`.trim())} installed`,
1275
- `${green("✔")} ${totalCommands} commands → ${cyan(TARGET_DIR)}`,
1276
- "",
1277
- bold("What's next?"),
1278
- "",
1279
- dim("First time?"),
1280
- ` ${cmd("npx forgedock demo")}${dim("Try the pipeline risk-free")}`,
1281
- "",
1282
- dim("Setting up your repo?"),
1283
- ` ${cmd("npx forgedock init")}${dim("Configure forge.yaml")}`,
1284
- ` ${cmd("npx forgedock doctor")}${dim("Verify your setup")}`,
1285
- "",
1286
- dim("Ready to go?"),
1287
- ` ${cyan("/work-on #N".padEnd(22))}${dim("Run it in Claude Code")}`,
1288
- ],
1289
- { title: "ForgeDock Installed" },
1290
- ) + "\n",
1291
- );
1292
- } else {
1293
- // Update install — show a compact version diff and what changed, with a
1294
- // changelog link instead of the full first-run guidance. (#1146)
1295
- let headline;
1296
- if (priorVersion && currentVersion && priorVersion !== currentVersion) {
1297
- headline = `Updated from ${bold(`v${priorVersion}`)} → ${bold(`v${currentVersion}`)}.`;
1298
- } else if (currentVersion && priorVersion === currentVersion) {
1299
- headline = `Reinstalled ${bold(`v${currentVersion}`)}.`;
1300
- } else if (currentVersion) {
1301
- headline = `Updated to ${bold(`v${currentVersion}`)}.`;
1302
- } else {
1303
- headline = "Update complete.";
1304
- }
1305
- const changeNote =
1306
- changedCommands > 0
1307
- ? ` ${changedCommands} new/changed command${changedCommands === 1 ? "" : "s"}.`
1308
- : " Commands already up to date.";
1309
- process.stderr.write(
1310
- box(
1311
- [
1312
- `${green("✔")} ${headline}${changeNote}`,
1313
- "",
1314
- `Changelog: ${cyan("https://github.com/RapierCraftStudios/ForgeDock/releases")}`,
1315
- `Verify: ${cyan("npx forgedock doctor")}`,
1316
- ],
1317
- { title: "ForgeDock Updated" },
1318
- ) + "\n",
1319
- );
1320
- }
1321
- }
522
+ async function initFlow(c) {
523
+ const outputPath = join(c.cwd, "forge.yaml");
524
+ if (existsSync(outputPath) && process.stdin.isTTY !== true) {
525
+ const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
526
+ c.stdout.write("\n forge.yaml already exists — non-interactive run, aborting to protect it.\n");
527
+ c.stdout.write(" " + dim("Run interactively (or delete forge.yaml) to regenerate.") + "\n");
528
+ return 1;
529
+ }
530
+
531
+ if (flags.includes("--minimal")) {
532
+ // Staging semantics preserved (#1148): detection → minimal template with
533
+ // only the three required sections. No review screen — this is the
534
+ // power-user escape hatch for a ~20-line forge.yaml.
535
+ const { draft, description } = await read(c);
536
+ const backup = backupExisting(outputPath);
537
+ if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
538
+ const content = buildMinimalForgeYaml({
539
+ projectName: draft.project.name.value,
540
+ owner: draft.project.owner.value,
541
+ repo: draft.project.repo.value,
542
+ description: description.value,
543
+ root: draft.paths.root.value,
544
+ worktreeBase: draft.paths.worktreeBase.value,
545
+ defaultBranch: draft.branches.default.value,
546
+ stagingBranch: draft.branches.staging.value,
547
+ });
548
+ atomicWriteFile(outputPath, content);
549
+ celebrate(c, { written: true, todoCount: 0 });
550
+ return 0;
551
+ }
552
+
553
+ if (flags.includes("--manual")) {
554
+ // Manual escape hatch: plain prompts, detection values as defaults.
555
+ const { draft, description } = await read(c);
556
+ const v = {
557
+ owner: await input("GitHub owner", draft.project.owner.value),
558
+ repo: await input("Repository", draft.project.repo.value),
559
+ name: await input("Project name", draft.project.name.value),
560
+ description: await input("Description", description.value),
561
+ root: await input("Repo root", draft.paths.root.value),
562
+ worktreeBase: await input("Worktree base", draft.paths.worktreeBase.value),
563
+ defaultBranch: await input("Default branch", draft.branches.default.value),
564
+ stagingBranch: await input("Staging branch", draft.branches.staging.value),
565
+ };
566
+ const backup = backupExisting(outputPath);
567
+ if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
568
+ const lowKeys = manualLowConfidenceKeys(draft, description, v);
569
+ const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
570
+ celebrate(c, { written: true, todoCount });
571
+ return 0;
572
+ }
573
+ const { draft, description } = await read(c);
574
+ const reviewed = await review(c, draft, description);
575
+ celebrate(c, reviewed);
576
+ return reviewed.aborted ? 1 : 0;
1322
577
  }
1323
578
 
1324
579
  async function uninstall() {
@@ -1329,6 +584,22 @@ async function uninstall() {
1329
584
  const files = await findMarkdownFiles(COMMANDS_DIR);
1330
585
  let removed = 0;
1331
586
 
587
+ // Ownership manifest for copy-installed commands (Windows without Developer
588
+ // Mode, or a symlink→copy fallback from a previous run). Loaded up front:
589
+ // manifest-tracked copies are removed via the manifest loop below; the
590
+ // content-match branch in the main loop only covers copies written by older
591
+ // installs that predate the manifest.
592
+ let manifest = null;
593
+ try {
594
+ manifest = JSON.parse(await readFile(MANIFEST_PATH, "utf-8"));
595
+ } catch {
596
+ manifest = null;
597
+ }
598
+ const manifestFiles =
599
+ manifest && manifest.files && typeof manifest.files === "object"
600
+ ? manifest.files
601
+ : {};
602
+
1332
603
  for (const file of files) {
1333
604
  const rel = relative(COMMANDS_DIR, file);
1334
605
  const target = join(TARGET_DIR, rel);
@@ -1338,26 +609,65 @@ async function uninstall() {
1338
609
  if (stats.isSymbolicLink()) {
1339
610
  const current = await readlink(target);
1340
611
  if (current === file) {
1341
- const { unlink } = await import("fs/promises");
1342
612
  await unlink(target);
1343
613
  console.log(` ${RED}Removed${RESET}: ${rel}`);
1344
614
  removed++;
1345
615
  }
1346
616
  } else if (
617
+ stats.isFile() &&
618
+ !manifestFiles[rel] &&
1347
619
  readFileSync(file, "utf-8") === readFileSync(target, "utf-8")
1348
620
  ) {
1349
- // Regular file installed by ForgeDock in copy mode — content matches.
1350
- const { unlink } = await import("fs/promises");
621
+ // Regular file installed by an older copy-mode build (no manifest
622
+ // entry) — content matches the source, so it is ours to remove.
1351
623
  await unlink(target);
1352
624
  console.log(` ${RED}Removed${RESET}: ${rel}`);
1353
625
  removed++;
1354
626
  }
1355
627
  } catch (err) {
1356
- if (err.code !== "ENOENT") throw err;
628
+ if (err.code !== "ENOENT") {
629
+ console.error(
630
+ ` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
631
+ );
632
+ throw err;
633
+ }
1357
634
  // Doesn't exist — nothing to do
1358
635
  }
1359
636
  }
1360
637
 
638
+ // Remove copy-installed commands tracked in the ownership manifest. These
639
+ // are regular files, not symlinks, so the symlink branch above never touches
640
+ // them — the manifest is the only record of ForgeDock's ownership over them.
641
+ const manifestRels = Object.keys(manifestFiles);
642
+ for (const rel of manifestRels) {
643
+ const target = join(TARGET_DIR, rel);
644
+ try {
645
+ const stats = await lstat(target);
646
+ if (stats.isFile() && !stats.isSymbolicLink()) {
647
+ await unlink(target);
648
+ console.log(` ${RED}Removed${RESET}: ${rel} ${YELLOW}(copied)${RESET}`);
649
+ removed++;
650
+ }
651
+ } catch (err) {
652
+ if (err.code !== "ENOENT") {
653
+ console.error(
654
+ ` ${RED}Error${RESET}: Cannot access ${rel} — ${err.code ?? err.message}`,
655
+ );
656
+ throw err;
657
+ }
658
+ // Already gone — nothing to do
659
+ }
660
+ }
661
+ if (manifest && manifestRels.length > 0) {
662
+ manifest.files = {};
663
+ try {
664
+ await mkdir(dirname(MANIFEST_PATH), { recursive: true });
665
+ await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
666
+ } catch {
667
+ // Best-effort — a failed manifest write is non-fatal
668
+ }
669
+ }
670
+
1361
671
  console.log("");
1362
672
  console.log(`Done. Removed: ${removed} commands.`);
1363
673
  console.log("");
@@ -1372,7 +682,6 @@ async function uninstall() {
1372
682
 
1373
683
  try {
1374
684
  const stats = await lstat(target);
1375
- const { unlink } = await import("fs/promises");
1376
685
  if (stats.isSymbolicLink()) {
1377
686
  const current = await readlink(target);
1378
687
  if (current === file) {
@@ -1400,13 +709,16 @@ async function uninstall() {
1400
709
  console.log("");
1401
710
  }
1402
711
 
1403
- // Remove the SessionStart hook from ~/.claude/settings.json
1404
- const hookRemoveResult = await removeSessionStartHook();
712
+ // Remove the SessionStart hook from ~/.claude/settings.json via the
713
+ // hardened settings-hook module (never writes through malformed JSON).
714
+ const { status: hookRemoveResult } = removeSessionStartHook(
715
+ join(HOME, ".claude", "settings.json"),
716
+ );
1405
717
  if (hookRemoveResult === "removed") {
1406
718
  console.log(
1407
719
  ` ${GREEN}✔${RESET} Removed SessionStart hook from ${CYAN}~/.claude/settings.json${RESET}`,
1408
720
  );
1409
- } else if (hookRemoveResult === "not-present") {
721
+ } else if (hookRemoveResult === "absent") {
1410
722
  console.log(
1411
723
  ` ✔ SessionStart hook already absent from ~/.claude/settings.json`,
1412
724
  );
@@ -1417,7 +729,9 @@ async function uninstall() {
1417
729
  }
1418
730
  console.log("");
1419
731
 
1420
- // Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present)
732
+ // Remove ForgeDock managed block from CLAUDE.md and AGENTS.md (if present).
733
+ // The journey install no longer writes these, but installs from older builds
734
+ // did — uninstall stays responsible for cleaning them up.
1421
735
  const uninstallCwd = process.cwd();
1422
736
  const claudeMdPath = join(uninstallCwd, "CLAUDE.md");
1423
737
  const agentsMdPath = join(uninstallCwd, "AGENTS.md");
@@ -1443,7 +757,8 @@ async function uninstall() {
1443
757
  }
1444
758
  console.log("");
1445
759
 
1446
- // Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc)
760
+ // Remove FORGE_HOME export from shell profiles (.bashrc, .zshrc) — written
761
+ // by installs from older builds; harmless no-op when absent.
1447
762
  for (const profileName of [".bashrc", ".zshrc"]) {
1448
763
  const profilePath = join(HOME, profileName);
1449
764
  const profileResult = removeForgeHomeFromProfile(profilePath);
@@ -1481,9 +796,6 @@ async function uninstall() {
1481
796
 
1482
797
  // Clean up registry nudgeSeen entry for cwd (written by the session-start hook)
1483
798
  try {
1484
- const { clearNudgeSeen } = await import(
1485
- pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1486
- );
1487
799
  await clearNudgeSeen(uninstallCwd);
1488
800
  console.log(
1489
801
  ` ${GREEN}✔${RESET} Cleaned registry entries for ${CYAN}${uninstallCwd}${RESET}`,
@@ -1501,6 +813,20 @@ async function uninstall() {
1501
813
  console.log("");
1502
814
  }
1503
815
 
816
+ // Reinstall = relink commands + re-register the hook. Never the full
817
+ // journey: update must not reach read/review, which could overwrite a
818
+ // curated forge.yaml (and re-runs AI enrichment on every update). Also
819
+ // idempotent, so it's the repair path for a configured repo whose
820
+ // symlinks or hook registration got out of sync.
821
+ async function relinkAndHint() {
822
+ const c = ctx();
823
+ await forge(c);
824
+ if (!existsSync(join(c.cwd, "forge.yaml"))) {
825
+ const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
826
+ c.stdout.write(" " + dim("Configure this repo: npx forgedock init") + "\n");
827
+ }
828
+ }
829
+
1504
830
  async function update() {
1505
831
  console.log("");
1506
832
  console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
@@ -1536,8 +862,8 @@ async function update() {
1536
862
  console.log(` Already up to date.`);
1537
863
  } else {
1538
864
  console.log(` ${GREEN}Updated to latest.${RESET}`);
1539
- await install();
1540
865
  }
866
+ await relinkAndHint();
1541
867
  } catch (err) {
1542
868
  console.log(
1543
869
  ` ${YELLOW}Cannot fast-forward — local changes exist. Skipping.${RESET}`,
@@ -1547,583 +873,162 @@ async function update() {
1547
873
  console.log(
1548
874
  ` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
1549
875
  );
876
+ await relinkAndHint();
1550
877
  }
1551
878
  console.log("");
1552
879
  }
1553
880
 
1554
- async function init(fromInstall = false, minimal = false) {
1555
- // When called from install() via runSteps(), suppress stdout to prevent
1556
- // interleaving with the TUI spinner on stderr. runSteps() provides the
1557
- // "Generating forge.yaml" step label as visual feedback. (#812)
1558
- const log = fromInstall ? () => {} : console.log;
1559
- log("");
1560
- log(
1561
- `${BOLD}ForgeDock${RESET} — Generating forge.yaml${minimal ? " (minimal)" : ""}`,
1562
- );
1563
- log("");
1564
-
1565
- const cwd = process.cwd();
1566
- const worktreeBase = join(cwd, ".claude", "worktrees");
1567
- const outputPath = join(cwd, "forge.yaml");
1568
-
1569
- // --- Detect git remote URL and parse owner/repo ---
1570
- let owner = "your-github-org";
1571
- let repo = "your-repo-name";
1572
- let remoteDetected = false;
1573
-
1574
- try {
1575
- const remoteUrl = execSync("git remote get-url origin", {
1576
- cwd,
1577
- encoding: "utf-8",
1578
- stdio: ["pipe", "pipe", "pipe"],
1579
- }).trim();
1580
-
1581
- // SSH: git@github.com:owner/repo.git
1582
- const sshMatch = remoteUrl.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
1583
- // HTTPS: https://github.com/owner/repo.git
1584
- const httpsMatch = remoteUrl.match(
1585
- /^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/,
1586
- );
1587
-
1588
- if (sshMatch) {
1589
- owner = sshMatch[1];
1590
- repo = sshMatch[2];
1591
- remoteDetected = true;
1592
- } else if (httpsMatch) {
1593
- owner = httpsMatch[1];
1594
- repo = httpsMatch[2];
1595
- remoteDetected = true;
1596
- } else {
1597
- log(
1598
- ` ${YELLOW}Warning${RESET}: Could not parse git remote URL — using placeholder values`,
1599
- );
1600
- }
1601
- } catch {
1602
- log(
1603
- ` ${YELLOW}Warning${RESET}: No git remote found — using placeholder values`,
1604
- );
1605
- }
1606
-
1607
- if (remoteDetected) {
1608
- log(` Detected repo: ${CYAN}${owner}/${repo}${RESET}`);
1609
- }
1610
-
1611
- // --- Detect default branch ---
1612
- let defaultBranch = "main";
1613
- try {
1614
- const headRef = execSync("git symbolic-ref refs/remotes/origin/HEAD", {
1615
- cwd,
1616
- encoding: "utf-8",
1617
- stdio: ["pipe", "pipe", "pipe"],
1618
- }).trim();
1619
- // refs/remotes/origin/main → main
1620
- defaultBranch = headRef.replace(/^refs\/remotes\/origin\//, "");
1621
- log(` Default branch: ${CYAN}${defaultBranch}${RESET}`);
1622
- } catch {
1623
- // Fallback: try git rev-parse
1624
- try {
1625
- defaultBranch = execSync("git rev-parse --abbrev-ref HEAD", {
1626
- cwd,
1627
- encoding: "utf-8",
1628
- stdio: ["pipe", "pipe", "pipe"],
1629
- }).trim();
1630
- if (defaultBranch === "HEAD") defaultBranch = "main";
1631
- log(
1632
- ` Default branch: ${CYAN}${defaultBranch}${RESET} (from current branch)`,
1633
- );
1634
- } catch {
1635
- log(
1636
- ` ${YELLOW}Warning${RESET}: Could not detect default branch — defaulting to "main"`,
1637
- );
1638
- }
1639
- }
1640
-
1641
- // --- Detect staging branch ---
1642
- let stagingBranch = "staging";
1643
- try {
1644
- const remoteBranches = execSync("git branch -r", {
1645
- cwd,
1646
- encoding: "utf-8",
1647
- stdio: ["pipe", "pipe", "pipe"],
1648
- });
1649
- if (remoteBranches.includes("origin/staging")) {
1650
- stagingBranch = "staging";
1651
- log(` Staging branch: ${CYAN}staging${RESET} (detected)`);
1652
- } else {
1653
- stagingBranch = defaultBranch;
1654
- log(
1655
- ` Staging branch: ${CYAN}${defaultBranch}${RESET} (no staging branch found — using default)`,
1656
- );
1657
- }
1658
- } catch {
1659
- log(
1660
- ` ${YELLOW}Warning${RESET}: Could not read remote branches — defaulting staging to "${defaultBranch}"`,
1661
- );
1662
- stagingBranch = defaultBranch;
1663
- }
1664
-
1665
- // --- Auto-detect project description from README.md ---
1666
- let description = "";
1667
- try {
1668
- const readmePath = join(cwd, "README.md");
1669
- if (existsSync(readmePath)) {
1670
- const readmeContent = readFileSync(readmePath, "utf-8").slice(0, 2048);
1671
- // Skip the first heading line (# Title), grab the first non-empty paragraph
1672
- const lines = readmeContent.split("\n");
1673
- let inFirstParagraph = false;
1674
- const paragraphLines = [];
1675
- for (const line of lines) {
1676
- // Skip heading lines at the top
1677
- if (!inFirstParagraph && line.match(/^#/)) continue;
1678
- // Skip blank lines before paragraph starts
1679
- if (!inFirstParagraph && line.trim() === "") continue;
1680
- // Skip lines that are just badges, HTML, code fences, horizontal rules, or tables
1681
- if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
1682
- if (!inFirstParagraph && line.match(/^---/)) continue;
1683
- // Start collecting
1684
- if (!inFirstParagraph) {
1685
- inFirstParagraph = true;
1686
- }
1687
- // Stop at blank line (end of paragraph)
1688
- if (line.trim() === "") break;
1689
- paragraphLines.push(line.trim());
1690
- }
1691
- if (paragraphLines.length > 0) {
1692
- // Flatten to single line, strip markdown links/bold/inline code
1693
- description = paragraphLines
1694
- .join(" ")
1695
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1696
- .replace(/\*\*([^*]+)\*\*/g, "$1")
1697
- .replace(/`([^`]+)`/g, "$1")
1698
- .slice(0, 200)
1699
- .trim();
1700
- }
1701
- }
1702
- } catch {
1703
- // Best-effort only — silently fall back to empty description
1704
- }
1705
-
1706
- if (description) {
1707
- log(
1708
- ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from README.md)`,
1709
- );
1710
- }
1711
-
1712
- // --- Auto-detect project description from CLAUDE.md (fallback) ---
1713
- if (!description) {
1714
- try {
1715
- const claudePath = join(cwd, "CLAUDE.md");
1716
- if (existsSync(claudePath)) {
1717
- const claudeContent = readFileSync(claudePath, "utf-8").slice(0, 2048);
1718
- const lines = claudeContent.split("\n");
1719
- let inFirstParagraph = false;
1720
- const paragraphLines = [];
1721
- for (const line of lines) {
1722
- // Skip heading lines at the top
1723
- if (!inFirstParagraph && line.match(/^#/)) continue;
1724
- // Skip blank lines before paragraph starts
1725
- if (!inFirstParagraph && line.trim() === "") continue;
1726
- // Skip lines that are just badges, HTML comments, code fences, horizontal rules, or tables
1727
- if (!inFirstParagraph && line.match(/^[!<\[`|]/)) continue;
1728
- if (!inFirstParagraph && line.match(/^---/)) continue;
1729
- // Start collecting
1730
- if (!inFirstParagraph) {
1731
- inFirstParagraph = true;
1732
- }
1733
- // Stop at blank line (end of paragraph)
1734
- if (line.trim() === "") break;
1735
- paragraphLines.push(line.trim());
1736
- }
1737
- if (paragraphLines.length > 0) {
1738
- // Flatten to single line, strip markdown links/bold/inline code
1739
- description = paragraphLines
1740
- .join(" ")
1741
- .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
1742
- .replace(/\*\*([^*]+)\*\*/g, "$1")
1743
- .replace(/`([^`]+)`/g, "$1")
1744
- .slice(0, 200)
1745
- .trim();
1746
- }
1747
- }
1748
- } catch {
1749
- // Best-effort only — silently fall back to empty description
1750
- }
1751
-
1752
- if (description) {
1753
- log(
1754
- ` Description: ${CYAN}${description.slice(0, 60)}${description.length > 60 ? "…" : ""}${RESET} (from CLAUDE.md)`,
1755
- );
1756
- }
1757
- }
1758
-
1759
- // --- Handle existing forge.yaml ---
1760
- if (existsSync(outputPath)) {
1761
- const baseBak = join(cwd, "forge.yaml.bak");
1762
- const backupPath = existsSync(baseBak)
1763
- ? join(
1764
- cwd,
1765
- `forge.yaml.bak.${new Date().toISOString().replace(/[:.]/g, "-")}`,
1766
- )
1767
- : baseBak;
1768
- const backupName = basename(backupPath);
1769
- renameSync(outputPath, backupPath);
1770
- log(` ${YELLOW}Backed up${RESET}: forge.yaml → ${backupName}`);
1771
- }
1772
-
1773
- // --- Generate forge.yaml content ---
1774
- const projectName = repo
1775
- .split(/[-_]/)
1776
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
1777
- .join(" ");
1778
-
1779
- // Pre-escape values for embedding in YAML double-quoted strings.
1780
- // Backslash must be escaped first to avoid double-escaping.
1781
- const safeOwner = owner.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1782
- const safeRepo = repo.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1783
- const safeProjectName = projectName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1784
- const safeDefaultBranch = defaultBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1785
- const safeStagingBranch = stagingBranch.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1786
-
1787
- const fullContent = `# forge.yaml — ForgeDock Configuration
1788
- #
1789
- # Auto-generated by: npx forgedock init
1790
- # Edit this file with your project details.
1791
- #
1792
- # Required sections: project, paths, branches
1793
- # Optional sections: repos, project_board, services, review, verification
1794
- #
1795
- # See docs/CONFIG.md for full reference.
1796
-
1797
- # =============================================================================
1798
- # PROJECT (REQUIRED)
1799
- # =============================================================================
1800
-
1801
- project:
1802
- name: "${safeProjectName}"
1803
- owner: "${safeOwner}"
1804
- repo: "${safeRepo}"
1805
- description: "${description.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1806
-
1807
- # =============================================================================
1808
- # PATHS (REQUIRED)
1809
- # =============================================================================
1810
-
1811
- paths:
1812
- root: "${cwd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1813
- worktree_base: "${worktreeBase.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1814
-
1815
- # =============================================================================
1816
- # BRANCHES (REQUIRED)
1817
- # =============================================================================
1818
-
1819
- branches:
1820
- default: "${safeDefaultBranch}"
1821
- staging: "${safeStagingBranch}"
1822
- feature_pattern: "milestone/{slug}"
1823
-
1824
- # =============================================================================
1825
- # REPOS (OPTIONAL)
1826
- # Multi-repo configuration. Remove the # to enable.
1827
- # =============================================================================
1828
-
1829
- # repos:
1830
- # default:
1831
- # repo: "${safeOwner}/${safeRepo}"
1832
- # staging_branch: "${safeStagingBranch}"
1833
- # satellites:
1834
- # - prefix: "mcp"
1835
- # repo: "${safeOwner}/your-satellite-repo"
1836
- # staging_branch: "main"
1837
- # local_path: "${join(cwd, "..", "your-satellite-repo").replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"
1838
-
1839
- # =============================================================================
1840
- # PROJECT BOARD (OPTIONAL)
1841
- # GitHub Projects v2 integration.
1842
- # To find IDs: gh project list --owner ${safeOwner}
1843
- # =============================================================================
1844
-
1845
- # project_board:
1846
- # owner: "${safeOwner}"
1847
- # project_number: 1
1848
- # project_id: "PVT_kwHOxxxxxxxxxxxxxxxx"
1849
- # field_ids:
1850
- # status: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1851
- # lane: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1852
- # component: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1853
- # priority: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1854
- # workflow: "PVTSSF_xxxxxxxxxxxxxxxxxxxxxxxx"
1855
-
1856
- # =============================================================================
1857
- # REVIEW (OPTIONAL)
1858
- # Context injected into review agent prompts.
1859
- # =============================================================================
1860
-
1861
- # review:
1862
- # tech_stack: "Node.js, TypeScript, PostgreSQL"
1863
- # context: |
1864
- # Describe your repo structure and any unusual conventions here.
1865
-
1866
- # =============================================================================
1867
- # VERIFICATION (OPTIONAL)
1868
- # Health-check patterns for quality gate and validate commands.
1869
- # =============================================================================
1870
-
1871
- # verification:
1872
- # health_endpoint: "https://api.${safeRepo}.io/health"
1873
- # health_patterns:
1874
- # - '"status": "ok"'
1875
- #
1876
- # # Integration test suites run by /test-gate against the provisioned cluster.
1877
- # # Each entry specifies a logical cluster (matched to test_services), the shell
1878
- # # command to execute, and the working directory (relative to project root).
1879
- # # Used by: test-gate (Phase 3 Provision + Phase 4 Fan out)
1880
- # #
1881
- # # integration_tests:
1882
- # # - cluster: "api"
1883
- # # command: "pytest tests/integration/ -q --tb=short"
1884
- # # working_dir: "."
1885
- #
1886
- # # Maps logical cluster names (from integration_tests) to running container
1887
- # # names. /test-gate checks these containers are live before running tests.
1888
- # # Omit entries for clusters that do not use Docker.
1889
- # # Used by: test-gate (Phase 3 Provision)
1890
- # #
1891
- # # test_services:
1892
- # # api: "${safeRepo}-api-blue"
1893
- # # worker: "${safeRepo}-worker-blue"
1894
- #
1895
- # # Controls /test-gate posture at the staging-to-main deploy boundary.
1896
- # # posture: blocking (default) or advisory.
1897
- # # override_phrase: exact comment text an operator posts to bypass a BLOCK.
1898
- # # Used by: test-gate (Phase 7 Verdict), review-pr-staging (Phase 6.5)
1899
- # #
1900
- # # test_gate:
1901
- # # posture: "blocking"
1902
- # # override_phrase: "OVERRIDE: shipping with test failures \u2014"
1903
- `;
1904
-
1905
- // When --minimal is requested, emit only the three required sections
1906
- // (project, paths, branches) — no commented optional blocks. The minimal
1907
- // output still passes `forgedock doctor` and drives `/work-on`, while staying
1908
- // ~20 lines instead of ~200. (#1148)
1909
- const content = minimal
1910
- ? buildMinimalForgeYaml({
1911
- projectName,
1912
- owner,
1913
- repo,
1914
- description,
1915
- root: cwd,
1916
- worktreeBase,
1917
- defaultBranch,
1918
- stagingBranch,
1919
- })
1920
- : fullContent;
1921
-
1922
- atomicWriteFile(outputPath, content);
1923
-
1924
- log(` ${GREEN}Created${RESET}: forge.yaml${minimal ? " (minimal)" : ""}`);
1925
- log("");
1926
-
1927
- if (fromInstall) {
1928
- // Called automatically from install() — only print what still needs attention.
1929
- // NOTE: log() is a no-op when fromInstall=true (stdout suppressed to avoid
1930
- // interleaving with TUI spinner on stderr). If git remote was not detected,
1931
- // the user will see the incomplete placeholder values in forge.yaml directly. (#812)
1932
- if (!remoteDetected) {
1933
- log(`${YELLOW}Action required:${RESET}`);
1934
- log(
1935
- ` Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET} (git remote not detected)`,
1936
- );
1937
- log("");
1938
- }
1939
- } else {
1940
- // Called explicitly via `npx forgedock init` — print full next steps
1941
- log(`${BOLD}Next steps:${RESET}`);
1942
- if (!remoteDetected) {
1943
- log(
1944
- ` 1. Edit ${CYAN}forge.yaml${RESET} — fill in ${CYAN}project.owner${RESET} and ${CYAN}project.repo${RESET}`,
1945
- );
1946
- } else {
1947
- log(
1948
- ` 1. Review ${CYAN}forge.yaml${RESET} — all required fields were auto-detected`,
1949
- );
1950
- }
1951
- log(
1952
- ` 2. Add ${CYAN}forge.yaml${RESET} to ${CYAN}.gitignore${RESET} if it contains sensitive paths`,
1953
- );
1954
- if (minimal) {
1955
- log(
1956
- ` 3. Need more? Add optional sections from ${CYAN}forge.yaml.example${RESET} (see ${CYAN}docs/CONFIG.md${RESET})`,
1957
- );
1958
- } else {
1959
- log(
1960
- ` 3. Run ${CYAN}/forgedock-init${RESET} inside Claude Code for guided AI-powered setup`,
1961
- );
1962
- }
1963
- log("");
1964
- }
1965
- }
881
+ // ---------------------------------------------------------------------------
882
+ // Labels Bootstrap — idempotently create/update all ForgeDock-managed labels
883
+ // ---------------------------------------------------------------------------
1966
884
 
1967
885
  /**
1968
- * Mark a directory as ForgeDock-managed by removing it from the opt-out
1969
- * registry and creating a .forgedock marker file.
886
+ * Resolve the target repo for the labels command.
887
+ *
888
+ * Priority order:
889
+ * 1. --repo <owner/repo> passed on the CLI
890
+ * 2. forge.yaml → project.owner + project.repo in the current working directory
891
+ * 3. Returns null if neither is available (caller prints an error)
1970
892
  *
1971
- * @param {string} [dir] - Directory to enable (default: process.cwd()).
893
+ * @param {string[]} subArgs - CLI args after "labels [setup]"
894
+ * @returns {string|null} "owner/repo" string or null
1972
895
  */
1973
- async function enable(dir) {
1974
- const targetDir = resolve(dir || process.cwd());
1975
-
1976
- let setOptOut;
1977
- try {
1978
- ({ setOptOut } = await import(
1979
- pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
1980
- ));
1981
- } catch (err) {
1982
- console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
1983
- process.exit(1);
1984
- }
1985
-
1986
- // Remove from opt-out registry
1987
- try {
1988
- await setOptOut(targetDir, false);
1989
- } catch (err) {
1990
- console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
1991
- process.exit(1);
896
+ function resolveLabelsRepo(subArgs) {
897
+ // 1. Explicit --repo flag
898
+ const repoFlagIdx = subArgs.indexOf("--repo");
899
+ if (repoFlagIdx !== -1 && subArgs[repoFlagIdx + 1]) {
900
+ return subArgs[repoFlagIdx + 1];
1992
901
  }
1993
902
 
1994
- // Create .forgedock marker file so the directory is treated as managed
1995
- // even without a forge.yaml (user can run `npx forgedock init` next)
1996
- const markerPath = join(targetDir, ".forgedock");
1997
- if (!existsSync(markerPath)) {
903
+ // 2. forge.yaml in cwd
904
+ const forgeYamlPath = join(process.cwd(), "forge.yaml");
905
+ if (existsSync(forgeYamlPath)) {
1998
906
  try {
1999
- writeFileSync(markerPath, "", "utf-8");
2000
- console.log(` ${GREEN}Created${RESET}: .forgedock marker in ${targetDir}`);
2001
- } catch (err) {
2002
- console.log(` ${YELLOW}Warning${RESET}: could not create .forgedock marker: ${err.message}`);
907
+ const raw = readFileSync(forgeYamlPath, "utf-8");
908
+ const ownerMatch = raw.match(/^\s*owner:\s*["']?([^\s"'#]+)["']?/m);
909
+ const repoMatch = raw.match(/^\s*repo:\s*["']?([^\s"'#]+)["']?/m);
910
+ if (ownerMatch && repoMatch) {
911
+ return `${ownerMatch[1]}/${repoMatch[1]}`;
912
+ }
913
+ } catch {
914
+ // fall through
2003
915
  }
2004
- } else {
2005
- console.log(` ✔ .forgedock marker already present in ${targetDir}`);
2006
916
  }
2007
917
 
2008
- console.log(` ${GREEN}✔${RESET} ForgeDock ${GREEN}enabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
2009
- console.log(` Removed from opt-out registry. Run ${CYAN}npx forgedock init${RESET} to generate forge.yaml.`);
2010
- console.log("");
918
+ return null;
2011
919
  }
2012
920
 
2013
921
  /**
2014
- * Opt a directory out of ForgeDock by adding it to the opt-out registry.
2015
- * The session-start hook will be silent for this directory in future sessions.
922
+ * Idempotently create or update all ForgeDock-managed labels on a GitHub repo.
923
+ *
924
+ * Reads the canonical manifest from bin/labels.json (co-located with this
925
+ * script). For each label entry, calls:
926
+ *
927
+ * gh label create <name> --color <hex> --description <desc> --force --repo <repo>
2016
928
  *
2017
- * @param {string} [dir] - Directory to disable (default: process.cwd()).
929
+ * --force makes the call idempotent: it creates the label if absent, or updates
930
+ * its color and description if it already exists. Safe to re-run at any time.
931
+ *
932
+ * @param {string} repo - "owner/repo" string
933
+ * @returns {{ created: number, failed: string[] }}
2018
934
  */
2019
- async function disable(dir) {
2020
- const targetDir = resolve(dir || process.cwd());
935
+ async function labelsSetup(repo) {
936
+ const manifestPath = join(__dirname, "labels.json");
2021
937
 
2022
- let setOptOut;
2023
- try {
2024
- ({ setOptOut } = await import(
2025
- pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
2026
- ));
2027
- } catch (err) {
2028
- console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
938
+ if (!existsSync(manifestPath)) {
939
+ console.log(`${RED}Label manifest not found: ${manifestPath}${RESET}`);
2029
940
  process.exit(1);
2030
941
  }
2031
942
 
943
+ /** @type {Array<{name: string, color: string, description: string}>} */
944
+ let labels;
2032
945
  try {
2033
- await setOptOut(targetDir, true);
946
+ labels = JSON.parse(readFileSync(manifestPath, "utf-8"));
2034
947
  } catch (err) {
2035
- console.error(`${RED}Error updating registry: ${err.message}${RESET}`);
948
+ console.log(`${RED}Failed to parse labels.json: ${err.message}${RESET}`);
2036
949
  process.exit(1);
2037
950
  }
2038
951
 
2039
- console.log(` ${GREEN}✔${RESET} ForgeDock ${YELLOW}disabled${RESET} in: ${CYAN}${targetDir}${RESET}`);
2040
- console.log(` Added to opt-out registry. The session-start hook will be silent here.`);
2041
- console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
2042
952
  console.log("");
2043
- }
953
+ console.log(`${BOLD}ForgeDock Label Bootstrap${RESET}`);
954
+ console.log(` Repository: ${cyan(repo)}`);
955
+ console.log(` Labels: ${labels.length} managed labels`);
956
+ console.log("");
2044
957
 
2045
- /**
2046
- * Show the resolved ForgeDock state for a directory.
2047
- * Prints human-readable state (managed-active, managed-optedout, unmanaged)
2048
- * with relevant detail about what markers or registry entries were found.
2049
- *
2050
- * @param {string} [dir] - Directory to inspect (default: process.cwd()).
2051
- */
2052
- async function status(dir) {
2053
- const targetDir = resolve(dir || process.cwd());
958
+ const bar = createProgressBar(labels.length, {
959
+ label: " Bootstrapping labels...",
960
+ });
2054
961
 
2055
- let resolveState;
2056
- try {
2057
- ({ resolveState } = await import(
2058
- pathToFileURL(join(FORGE_HOME, "bin", "registry.mjs")).href
2059
- ));
2060
- } catch (err) {
2061
- console.error(`${RED}Error: could not load registry module: ${err.message}${RESET}`);
2062
- process.exit(1);
2063
- }
962
+ let created = 0;
963
+ const failed = [];
2064
964
 
2065
- let state;
2066
- try {
2067
- state = resolveState(targetDir);
2068
- } catch (err) {
2069
- console.error(`${RED}Error resolving state: ${err.message}${RESET}`);
2070
- process.exit(1);
965
+ for (const { name, color, description } of labels) {
966
+ bar.tick(1, dim(name));
967
+ try {
968
+ execFileSync(
969
+ "gh",
970
+ [
971
+ "label",
972
+ "create",
973
+ name,
974
+ "--color",
975
+ color,
976
+ "--description",
977
+ description,
978
+ "--force",
979
+ "--repo",
980
+ repo,
981
+ ],
982
+ { stdio: ["pipe", "pipe", "pipe"] },
983
+ );
984
+ created++;
985
+ } catch {
986
+ failed.push(name);
987
+ // Continue — don't abort the whole bootstrap for one failure
988
+ }
2071
989
  }
2072
990
 
2073
- const hasForgeYaml = existsSync(join(targetDir, "forge.yaml"));
2074
- const hasMarker = existsSync(join(targetDir, ".forgedock"));
2075
-
2076
- console.log("");
2077
- console.log(`${BOLD}ForgeDock Status${RESET}`);
2078
- console.log(` Directory: ${CYAN}${targetDir}${RESET}`);
2079
- console.log("");
991
+ bar.done(
992
+ failed.length === 0
993
+ ? `${green("✔")} Bootstrapped ${created}/${labels.length} labels on ${cyan(repo)}`
994
+ : `${yellow("⚠")} Bootstrapped ${created}/${labels.length} labels — ${failed.length} failed`,
995
+ );
2080
996
 
2081
- switch (state) {
2082
- case "managed-active":
2083
- console.log(` State: ${GREEN}managed-active${RESET}`);
2084
- console.log(` ForgeDock is active in this directory.`);
2085
- if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
2086
- if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
2087
- console.log(` The session-start hook will inject context on session start.`);
2088
- console.log(` To disable: ${CYAN}npx forgedock disable${RESET}`);
2089
- break;
2090
-
2091
- case "managed-optedout":
2092
- console.log(` State: ${YELLOW}managed-optedout${RESET}`);
2093
- console.log(` ForgeDock is installed but opted out for this directory.`);
2094
- if (hasForgeYaml) console.log(` ${GREEN}✔${RESET} forge.yaml found`);
2095
- if (hasMarker) console.log(` ${GREEN}✔${RESET} .forgedock marker found`);
2096
- console.log(` The session-start hook is silent here (opt-out registry entry present).`);
2097
- console.log(` To re-enable: ${CYAN}npx forgedock enable${RESET}`);
2098
- break;
2099
-
2100
- case "unmanaged":
2101
- default:
2102
- console.log(` State: ${RED}unmanaged${RESET}`);
2103
- console.log(` ForgeDock is not active in this directory.`);
2104
- console.log(` No forge.yaml or .forgedock marker found.`);
2105
- console.log(` To enable: ${CYAN}npx forgedock enable${RESET}`);
2106
- console.log(` To set up: ${CYAN}npx forgedock init${RESET}`);
2107
- break;
997
+ if (failed.length > 0) {
998
+ console.log("");
999
+ console.log(` ${RED}Failed labels:${RESET}`);
1000
+ for (const name of failed) {
1001
+ console.log(` ${dim("•")} ${name}`);
1002
+ }
1003
+ console.log(
1004
+ `\n Run ${cyan("gh auth status")} to verify GitHub authentication.`,
1005
+ );
2108
1006
  }
1007
+
2109
1008
  console.log("");
1009
+ return { created, failed };
2110
1010
  }
2111
1011
 
2112
1012
  /**
2113
1013
  * Run installation health checks and report pass/fail for each.
2114
1014
  *
2115
1015
  * Checks (in order):
2116
- * 1. Command symlinks — TARGET_DIR entries point to correct COMMANDS_DIR files
2117
- * 2. gh CLI installed and authenticated
2118
- * 3. git configured (user.name + user.email)
2119
- * 4. forge.yaml exists and has required keys
2120
- * 5. SessionStart hook registered in ~/.claude/settings.json
2121
- * 6. CLAUDE.md has the ForgeDock behavioral block (cwd)
2122
- * 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
2123
- * 8. FORGE_HOME environment variable is set
2124
- * 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
1016
+ * 1. Command symlinks — TARGET_DIR entries point to correct COMMANDS_DIR files
1017
+ * 2. gh CLI installed and authenticated
1018
+ * 3. git configured (user.name + user.email)
1019
+ * 4. forge.yaml exists and has required keys
1020
+ * 5. SessionStart hook registered in ~/.claude/settings.json
1021
+ * 6. CLAUDE.md legacy managed block (cwd) — informational only; the
1022
+ * journey install never injects one, session context comes from the
1023
+ * SessionStart hook
1024
+ * 7. Required workflow labels exist on the GitHub repo (needs forge.yaml + gh auth)
1025
+ * 8. FORGE_HOME environment variable — informational only; not required
1026
+ * by the journey install
1027
+ * 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
1028
+ * 10. yq installed (hard dependency for forge.yaml parsing)
1029
+ * 11. Claude Code installed and version compatible (advisory warn if not on PATH)
2125
1030
  *
2126
- * Exits with code 0 if all checks pass, code 1 if any fail.
1031
+ * Returns 0 if all checks pass (warnings allowed), 1 if any hard check fails.
2127
1032
  */
2128
1033
  async function doctor() {
2129
1034
  console.log("");
@@ -2173,27 +1078,11 @@ async function doctor() {
2173
1078
  const brokenLinks = [];
2174
1079
 
2175
1080
  try {
2176
- // Collect all .md files from COMMANDS_DIR recursively
2177
- const collectMd = async (dir) => {
2178
- const results = [];
2179
- let entries;
2180
- try {
2181
- entries = await readdir(dir, { withFileTypes: true });
2182
- } catch {
2183
- return results;
2184
- }
2185
- for (const entry of entries) {
2186
- const full = join(dir, entry.name);
2187
- if (entry.isDirectory()) {
2188
- results.push(...(await collectMd(full)));
2189
- } else if (entry.name.endsWith(".md")) {
2190
- results.push(full);
2191
- }
2192
- }
2193
- return results;
2194
- };
2195
-
2196
- const sourceFiles = await collectMd(COMMANDS_DIR);
1081
+ // Collect installable .md files from COMMANDS_DIR using the same tier filter
1082
+ // applied by forge() / findMarkdownFiles() — doctor() must validate the filtered
1083
+ // install surface, not the raw directory walk, or it would report false failures
1084
+ // for 'internal' specs that were deliberately excluded from the install.
1085
+ const sourceFiles = await findMarkdownFiles(COMMANDS_DIR);
2197
1086
 
2198
1087
  for (const src of sourceFiles) {
2199
1088
  const rel = relative(COMMANDS_DIR, src);
@@ -2210,7 +1099,7 @@ async function doctor() {
2210
1099
  }
2211
1100
  } else {
2212
1101
  // Regular file — copy-mode install (Windows without Developer Mode).
2213
- // linkCommands() falls back to copyFile() on EPERM/EACCES, so a regular
1102
+ // forge() falls back to copyFile() on EPERM/EACCES, so a regular
2214
1103
  // file is valid as long as its content matches the source. <!-- Added: forge#1174 -->
2215
1104
  try {
2216
1105
  const srcContent = readFileSync(src, "utf-8");
@@ -2388,13 +1277,18 @@ async function doctor() {
2388
1277
  }
2389
1278
  }
2390
1279
 
2391
- // ── Check 6: CLAUDE.md has ForgeDock block (cwd) ──────────────────────────
1280
+ // ── Check 6: CLAUDE.md legacy managed block (cwd, informational) ──────────
1281
+ // The journey-based install never injects a CLAUDE.md block — session
1282
+ // context comes from the SessionStart hook (see Check 5). A block's absence
1283
+ // is therefore normal and PASSes. Its presence means a legacy block from an
1284
+ // older install is still around; that's informational, not a failure —
1285
+ // `npx forgedock uninstall` removes it.
2392
1286
  {
2393
1287
  const claudeMdPath = join(process.cwd(), "CLAUDE.md");
2394
1288
  if (!existsSync(claudeMdPath)) {
2395
- warn(
2396
- "CLAUDE.md behavioral block",
2397
- `No CLAUDE.md found in ${process.cwd()}. Run: npx forgedock install (from your project directory)`,
1289
+ pass(
1290
+ "CLAUDE.md legacy block",
1291
+ "no CLAUDE.md found — session context comes from the SessionStart hook",
2398
1292
  );
2399
1293
  } else {
2400
1294
  try {
@@ -2403,15 +1297,18 @@ async function doctor() {
2403
1297
  content.includes(CLAUDE_BLOCK_BEGIN) &&
2404
1298
  content.includes(CLAUDE_BLOCK_END)
2405
1299
  ) {
2406
- pass("CLAUDE.md behavioral block", `found in ${claudeMdPath}`);
1300
+ warn(
1301
+ "CLAUDE.md legacy block",
1302
+ `legacy ForgeDock block found in ${claudeMdPath} — uninstall removes it; the SessionStart hook has replaced it`,
1303
+ );
2407
1304
  } else {
2408
- fail(
2409
- "CLAUDE.md behavioral block",
2410
- `Run: npx forgedock install (from ${process.cwd()}) to inject the ForgeDock pipeline rules block`,
1305
+ pass(
1306
+ "CLAUDE.md legacy block",
1307
+ "no legacy ForgeDock block — session context comes from the SessionStart hook",
2411
1308
  );
2412
1309
  }
2413
1310
  } catch (err) {
2414
- fail("CLAUDE.md behavioral block", `Cannot read CLAUDE.md: ${err.message}`);
1311
+ fail("CLAUDE.md legacy block", `Cannot read CLAUDE.md: ${err.message}`);
2415
1312
  }
2416
1313
  }
2417
1314
  }
@@ -2478,7 +1375,7 @@ async function doctor() {
2478
1375
  } else {
2479
1376
  fail(
2480
1377
  "GitHub workflow labels",
2481
- `Run: npx forgedock labels setup (missing: ${missingLabels.join(", ")})`,
1378
+ `Create the missing labels manually, e.g.: gh label create "<name>" --color <hex> --description "<desc>" -R ${forgeOwner}/${forgeRepo} (see bin/labels.json for definitions; missing: ${missingLabels.join(", ")})`,
2482
1379
  );
2483
1380
  }
2484
1381
  }
@@ -2486,15 +1383,19 @@ async function doctor() {
2486
1383
  }
2487
1384
  }
2488
1385
 
2489
- // ── Check 8: FORGE_HOME environment variable ───────────────────────────────
1386
+ // ── Check 8: FORGE_HOME environment variable (informational) ──────────────
1387
+ // The journey-based install never exports FORGE_HOME — it isn't required
1388
+ // for the pipeline to run. It only affects orchestrate's legacy
1389
+ // classify-lane script fallback (tracked as a follow-up), so a missing
1390
+ // value is a warning, not a failure.
2490
1391
  {
2491
1392
  const envForgeHome = process.env.FORGE_HOME;
2492
1393
  if (envForgeHome) {
2493
1394
  pass("FORGE_HOME env var", `set to "${envForgeHome}"`);
2494
1395
  } else {
2495
- fail(
1396
+ warn(
2496
1397
  "FORGE_HOME env var",
2497
- `Add to your shell profile: export FORGE_HOME="${FORGE_HOME}" then restart your shell (or run: source ~/.bashrc)`,
1398
+ "not exported — only affects orchestrate's legacy classify-lane script fallback (tracked as a follow-up). Not required for normal use.",
2498
1399
  );
2499
1400
  }
2500
1401
  }
@@ -2668,33 +1569,44 @@ async function doctor() {
2668
1569
  }
2669
1570
  console.log("");
2670
1571
 
2671
- if (failures > 0) {
2672
- process.exit(1);
2673
- }
1572
+ return failures > 0 ? 1 : 0;
2674
1573
  }
2675
1574
 
2676
1575
  function help() {
2677
- // renderLogo already shown by splash() above — show the command table
1576
+ // splash() already rendered the logo to stderr — the command table itself
1577
+ // goes to stdout (help output is the requested artifact).
2678
1578
  const commands = [
2679
1579
  ["Command", "Description"],
2680
- ["npx forgedock", "Install commands (default)"],
1580
+ ["npx forgedock", "Guided setup: install commands + configure repo (default)"],
2681
1581
  ["npx forgedock install", "Install commands"],
2682
1582
  ["npx forgedock init", "Generate forge.yaml config for your project"],
2683
1583
  ["npx forgedock init --minimal", "Generate a minimal forge.yaml (required sections only)"],
2684
- ["npx forgedock uninstall", "Remove commands"],
2685
- ["npx forgedock update", "Pull latest & reinstall"],
1584
+ ["npx forgedock enable [dir]", "Activate ForgeDock in a directory"],
1585
+ ["npx forgedock disable [dir]", "Opt a directory out of ForgeDock"],
1586
+ ["npx forgedock status [dir]", "Show ForgeDock state for a directory"],
2686
1587
  ["npx forgedock run <cmd> [args]", "Run a command headlessly via the Anthropic API"],
1588
+ ["npx forgedock run-issue <issue>", "Drive one issue through the durable engine"],
2687
1589
  ["npx forgedock demo", "Set up a risk-free demo repo and print next steps"],
2688
- ["npx forgedock enable [dir]", "Mark directory as ForgeDock-managed"],
2689
- ["npx forgedock disable [dir]", "Opt directory out of ForgeDock"],
2690
- ["npx forgedock status [dir]", "Show resolved state for a directory"],
1590
+ ["npx forgedock labels [setup] [--repo owner/repo]", "Bootstrap ForgeDock-managed labels on a GitHub repo (idempotent)"],
2691
1591
  ["npx forgedock doctor", "Check installation health"],
1592
+ ["npx forgedock update", "Pull latest & reinstall"],
1593
+ ["npx forgedock uninstall", "Remove commands"],
2692
1594
  ["npx forgedock help", "Show this help"],
2693
1595
  ];
1596
+ const flagRows = [
1597
+ ["Flag", "Description"],
1598
+ ["--fast", "Skip animation/motion"],
1599
+ ["--manual", "Plain text prompts instead of the review screen (init)"],
1600
+ ["--verbose", "Show detection sources for every field (init)"],
1601
+ ["--minimal", "Generate a minimal forge.yaml with required sections only (init)"],
1602
+ ];
2694
1603
 
2695
- process.stderr.write(
1604
+ process.stdout.write(
2696
1605
  box(table(commands, { header: true }), { title: "Usage" }) + "\n",
2697
1606
  );
1607
+ process.stdout.write(
1608
+ box(table(flagRows, { header: true }), { title: "Flags" }) + "\n",
1609
+ );
2698
1610
  }
2699
1611
 
2700
1612
  /**
@@ -2716,7 +1628,7 @@ function help() {
2716
1628
  * falling back to the platform default shell otherwise.
2717
1629
  */
2718
1630
  async function run() {
2719
- const runArgs = args.slice(1);
1631
+ const runArgs = restArgs;
2720
1632
  let dryRun = false;
2721
1633
  let model;
2722
1634
  let maxIterations;
@@ -2789,7 +1701,7 @@ async function demo() {
2789
1701
  try {
2790
1702
  const result = await runDemo({
2791
1703
  forgeHome: FORGE_HOME,
2792
- args: args.slice(1),
1704
+ args: restArgs,
2793
1705
  cwd: process.cwd(),
2794
1706
  });
2795
1707
  if (result && result.status === "error") {
@@ -2801,15 +1713,53 @@ async function demo() {
2801
1713
  }
2802
1714
  }
2803
1715
 
2804
- splash();
1716
+ // The journey-routed commands render their own branded marks (hero/compact);
1717
+ // splash() would double-brand them. Engine-surface commands, help, and
1718
+ // unknown-command output keep the logo.
1719
+ const SPLASH_COMMANDS = new Set(["run", "run-issue", "demo", "doctor", "help", "--help", "-h"]);
1720
+ const KNOWN_COMMANDS = new Set([
1721
+ "install", "init", "enable", "disable", "status", "uninstall", "update",
1722
+ "run", "run-issue", "demo", "doctor", "help", "--help", "-h",
1723
+ ]);
1724
+ if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash();
2805
1725
 
1726
+ let exitCode = 0;
2806
1727
  switch (command) {
2807
- case "install":
2808
- await install();
1728
+ case "install": {
1729
+ const c = ctx();
1730
+ if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
1731
+ statusScreen(c);
1732
+ } else {
1733
+ exitCode = await runJourney(c);
1734
+ }
2809
1735
  break;
1736
+ }
2810
1737
  case "init":
2811
- await init(false, args.includes("--minimal"));
1738
+ exitCode = await initFlow(ctx());
2812
1739
  break;
1740
+ case "enable": {
1741
+ const c = ctx();
1742
+ const dir = positional[1] ? resolve(positional[1]) : c.cwd;
1743
+ await setOptOut(dir, false);
1744
+ if (!existsSync(join(dir, "forge.yaml")) && !existsSync(join(dir, ".forgedock"))) {
1745
+ writeFileSync(join(dir, ".forgedock"), "", "utf-8");
1746
+ }
1747
+ c.stdout.write(`\n ForgeDock enabled in ${dir}.\n\n`);
1748
+ break;
1749
+ }
1750
+ case "disable": {
1751
+ const c = ctx();
1752
+ const dir = positional[1] ? resolve(positional[1]) : c.cwd;
1753
+ await setOptOut(dir, true);
1754
+ c.stdout.write(`\n ForgeDock disabled in ${dir}. Re-enable: npx forgedock enable\n\n`);
1755
+ break;
1756
+ }
1757
+ case "status": {
1758
+ const c = ctx();
1759
+ if (positional[1]) c.cwd = resolve(positional[1]);
1760
+ statusScreen(c);
1761
+ break;
1762
+ }
2813
1763
  case "uninstall":
2814
1764
  await uninstall();
2815
1765
  break;
@@ -2819,28 +1769,51 @@ switch (command) {
2819
1769
  case "run":
2820
1770
  await run();
2821
1771
  break;
1772
+ case "run-issue": {
1773
+ const { runFromCli } = await import("./engine-cli.mjs");
1774
+ await runFromCli(restArgs);
1775
+ break;
1776
+ }
2822
1777
  case "demo":
2823
1778
  await demo();
2824
1779
  break;
2825
- case "enable":
2826
- await enable(args[1]);
2827
- break;
2828
- case "disable":
2829
- await disable(args[1]);
2830
- break;
2831
- case "status":
2832
- await status(args[1]);
2833
- break;
2834
1780
  case "doctor":
2835
- await doctor();
1781
+ exitCode = await doctor();
1782
+ break;
1783
+ case "labels": {
1784
+ const subcommand = positional[1];
1785
+ if (!subcommand || subcommand === "setup" || subcommand.startsWith("--")) {
1786
+ const repo = resolveLabelsRepo(restArgs);
1787
+ if (!repo) {
1788
+ console.log(
1789
+ `${RED}No repository specified.${RESET}\n` +
1790
+ ` Pass ${cyan("--repo owner/repo")} or run from a directory with ${cyan("forge.yaml")}.`,
1791
+ );
1792
+ exitCode = 1;
1793
+ } else {
1794
+ await labelsSetup(repo);
1795
+ }
1796
+ } else {
1797
+ console.log(`${RED}Unknown labels subcommand: ${subcommand}${RESET}`);
1798
+ console.log(
1799
+ `Usage: ${CYAN}npx forgedock labels [setup] [--repo owner/repo]${RESET}`,
1800
+ );
1801
+ exitCode = 1;
1802
+ }
2836
1803
  break;
1804
+ }
2837
1805
  case "help":
2838
1806
  case "--help":
2839
1807
  case "-h":
2840
1808
  help();
2841
1809
  break;
2842
1810
  default:
2843
- process.stderr.write(`${RED}Unknown command: ${command}${RESET}\n`);
1811
+ console.log(`${RED}Unknown command: ${command}${RESET}`);
2844
1812
  help();
2845
- process.exit(1);
1813
+ exitCode = 1;
2846
1814
  }
1815
+ // Natural exit: set the code and let the event loop drain so stdout is never
1816
+ // truncated (spinner timers are unref'd; SIGINT listeners are removed).
1817
+ // Subcommands (run/demo) may set process.exitCode themselves — never clobber
1818
+ // a failure they recorded with a success code from the router.
1819
+ if (exitCode !== 0) process.exitCode = exitCode;