forgedock 1.1.7 → 1.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine/invariants.mjs +361 -0
  4. package/bin/engine-cli.mjs +137 -11
  5. package/bin/env-detect.mjs +249 -0
  6. package/bin/forge-utils.mjs +364 -4
  7. package/bin/forgedock.mjs +1064 -135
  8. package/bin/hooks/pre-tool-use.mjs +182 -0
  9. package/bin/hooks/session-start.mjs +68 -3
  10. package/bin/init-detect.mjs +101 -12
  11. package/bin/journey.mjs +917 -16
  12. package/bin/labels.json +10 -0
  13. package/bin/recall.mjs +557 -0
  14. package/bin/registry.mjs +91 -3
  15. package/bin/runner.mjs +38 -2
  16. package/bin/tui.mjs +49 -3
  17. package/commands/adopt.md +1 -1
  18. package/commands/analytics.md +8 -8
  19. package/commands/audit.md +1 -1
  20. package/commands/autopilot.md +958 -77
  21. package/commands/ci-audit.md +1 -1
  22. package/commands/cleanup.md +139 -55
  23. package/commands/compat-audit.md +1 -1
  24. package/commands/deploy-info.md +1 -1
  25. package/commands/deploy-pr.md +1 -1
  26. package/commands/diagnose.md +1 -1
  27. package/commands/explain.md +1 -1
  28. package/commands/fix-ci.md +1 -1
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/incident-response.md +3 -3
  31. package/commands/issue.md +1 -1
  32. package/commands/optimize.md +586 -1
  33. package/commands/orchestrate/config.md +13 -3
  34. package/commands/orchestrate/phase-1-resolve.md +36 -26
  35. package/commands/orchestrate/phase-2-triage.md +1 -1
  36. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  37. package/commands/orchestrate/phase-3-dependency.md +587 -48
  38. package/commands/orchestrate/phase-4-execution.md +1031 -57
  39. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  40. package/commands/orchestrate/phase-6-report.md +248 -11
  41. package/commands/orchestrate/safety.md +2 -2
  42. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  43. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  44. package/commands/qa-sweep.md +1 -1
  45. package/commands/quality-gate.md +418 -1
  46. package/commands/recover-orphans.md +5 -5
  47. package/commands/replay.md +1 -1
  48. package/commands/review-pr-agents/api.md +2 -2
  49. package/commands/review-pr-agents/auth.md +2 -2
  50. package/commands/review-pr-agents/billing.md +2 -2
  51. package/commands/review-pr-agents/concurrency.md +2 -2
  52. package/commands/review-pr-agents/database.md +2 -2
  53. package/commands/review-pr-agents/frontend.md +2 -2
  54. package/commands/review-pr-agents/infra.md +2 -2
  55. package/commands/review-pr-agents/protocols.md +1 -1
  56. package/commands/review-pr-agents/scraper.md +2 -2
  57. package/commands/review-pr-agents/security.md +2 -2
  58. package/commands/review-pr-staging.md +41 -3
  59. package/commands/review-pr.md +462 -19
  60. package/commands/rollback.md +1 -1
  61. package/commands/scope.md +1 -1
  62. package/commands/security-audit.md +1 -1
  63. package/commands/spec-doctor.md +429 -0
  64. package/commands/test-gate.md +1 -1
  65. package/commands/validate.md +53 -1
  66. package/commands/work-on/build/architect.md +78 -1
  67. package/commands/work-on/build/context.md +360 -22
  68. package/commands/work-on/build/implement.md +3 -1
  69. package/commands/work-on/build/validate.md +29 -1
  70. package/commands/work-on/build.md +75 -6
  71. package/commands/work-on/close.md +624 -10
  72. package/commands/work-on/decompose.md +1 -1
  73. package/commands/work-on/investigate.md +235 -5
  74. package/commands/work-on/remediate.md +331 -0
  75. package/commands/work-on/review.md +1 -1
  76. package/commands/work-on-monolithic.md +1 -1
  77. package/commands/work-on.md +205 -66
  78. package/docs/CI.md +2 -2
  79. package/docs/CODEX.md +1 -1
  80. package/docs/CONFIG.md +186 -0
  81. package/docs/FORGE-PROTOCOL.md +101 -0
  82. package/docs/site/command-reference.md +31 -1
  83. package/docs/site/getting-started.md +9 -34
  84. package/docs/site/index.md +1 -1
  85. package/docs/spec/forge-protocol-v1.md +97 -5
  86. package/docs/spec/label-state-machine.md +6 -1
  87. package/package.json +3 -2
  88. package/packages/protocol/src/cli.js +374 -84
  89. package/packages/protocol/src/types.js +75 -1
  90. package/scripts/build-knowledge-index.mjs +1761 -0
  91. package/scripts/calibration.mjs +996 -0
  92. package/scripts/check-command-docs-drift.sh +4 -2
  93. package/scripts/danger-zones.mjs +789 -0
  94. package/scripts/doctor-pipeline-state.sh +88 -2
  95. package/scripts/select-fix-targets.sh +234 -0
  96. package/scripts/transition-label.sh +245 -6
  97. package/scripts/refresh-bot-token.sh +0 -97
package/bin/forgedock.mjs CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "fs";
16
16
  import { execSync, execFileSync } from "child_process";
17
17
  import { homedir } from "os";
18
+ import https from "https";
18
19
  import {
19
20
  makeCtx,
20
21
  runJourney,
@@ -22,11 +23,16 @@ import {
22
23
  read,
23
24
  review,
24
25
  celebrate,
26
+ maybeOfferDemo,
25
27
  findMarkdownFiles,
26
28
  parseInstallTier,
27
29
  writeForgeYaml,
28
30
  backupExisting,
29
31
  manualLowConfidenceKeys,
32
+ isEphemeralCachePath,
33
+ writeInstallReceipt,
34
+ persistHome,
35
+ PIPELINE_SCRIPTS,
30
36
  } from "./journey.mjs";
31
37
  import {
32
38
  removeSessionStartHook,
@@ -41,13 +47,17 @@ import {
41
47
  detectClaudeVersion,
42
48
  loadBreakpoints,
43
49
  hasFeature,
50
+ getPersistedHomeState,
51
+ setPersistedHomeState,
44
52
  } from "./registry.mjs";
45
53
  import { renderMark, ember } from "./cinema.mjs";
46
54
  import {
47
55
  renderLogo,
56
+ getLogoTagline,
48
57
  box,
49
58
  table,
50
59
  input,
60
+ confirm,
51
61
  dim,
52
62
  green,
53
63
  yellow,
@@ -61,6 +71,15 @@ import {
61
71
  CYAN,
62
72
  } from "./tui.mjs";
63
73
  import { buildMinimalForgeYaml } from "./init-detect.mjs";
74
+ import {
75
+ parseNameStatusDiff,
76
+ classifyCommandChanges,
77
+ countBreakingCommits,
78
+ parseGitHubOwnerRepo,
79
+ classifyConventionalCommitLines,
80
+ formatUpdateChangelogSummary,
81
+ formatVersionAvailableSummary,
82
+ } from "./forge-utils.mjs";
64
83
 
65
84
  const __filename = fileURLToPath(import.meta.url);
66
85
  const __dirname = dirname(__filename);
@@ -177,22 +196,17 @@ function detectInstallPaths() {
177
196
 
178
197
  const SCRIPTS_DIR = join(FORGE_HOME, "scripts");
179
198
 
180
- /**
181
- * Allowlist of pipeline-agent scripts that `uninstall` cleans up from
182
- * ~/.claude/scripts/. The journey-based install no longer writes these
183
- * itself — this set only identifies leftovers from legacy installs so
184
- * `uninstall` can find and remove them.
185
- *
186
- * Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
187
- * covered here — those scripts are invoked directly via $FORGE_HOME/scripts/ by
188
- * review-pr.md and quality-gate.md and should not pollute the user's Claude
189
- * scripts namespace.
190
- */
191
- const PIPELINE_SCRIPTS = new Set([
192
- "classify-lane.sh",
193
- "transition-label.sh",
194
- "validate-pr-target.sh",
195
- ]);
199
+ // PIPELINE_SCRIPTS (the allowlist of universal pipeline-agent scripts linked
200
+ // to ~/.claude/scripts/) is defined in journey.mjs — forge()'s
201
+ // linkPipelineScripts() step is what writes them there (forge#1885,
202
+ // restoring the linkScripts() step dropped from the legacy install()/
203
+ // update() flow). Imported here so uninstall() removes exactly what forge()
204
+ // installs, off a single source of truth.
205
+ //
206
+ // Internal tooling (gen-logo.mjs, verify-*.sh) lives in scripts/ but is NOT
207
+ // covered here — those scripts are invoked directly via $FORGE_HOME/scripts/ by
208
+ // review-pr.md and quality-gate.md and should not pollute the user's Claude
209
+ // scripts namespace (forge#715).
196
210
 
197
211
  // Journey flags are stripped from positionals and fed to makeCtx via ctx().
198
212
  // --minimal is init-only. Subcommands with their own flag parsing (run,
@@ -390,9 +404,197 @@ function getVersion() {
390
404
  }
391
405
  }
392
406
 
393
- function splash() {
407
+ /**
408
+ * Compare two dotted version strings component-wise (numeric, not
409
+ * lexicographic — "1.9.0" must compare as older than "1.10.0").
410
+ * Each component's leading numeric prefix is used (e.g. a prerelease-suffixed
411
+ * component like "3-beta" compares as 3, not 0 — parseInt reads the leading
412
+ * digits and stops at the first non-digit character, unlike Number() which
413
+ * would reject the whole component as NaN). Components with no leading
414
+ * numeric prefix, or missing components, are treated as 0.
415
+ *
416
+ * Note: `parseInt(n, 10)` is used deliberately, not `Number(n)`. This means
417
+ * component parsing only targets plain decimal digit runs — it does not
418
+ * interpret hex (`"0x10"`) or exponential (`"1e3"`) numeric-literal forms
419
+ * the way `Number()` would. That's intentional: real semver/npm version
420
+ * segments are never hex- or exponential-shaped, and swapping back to
421
+ * `Number()` to "fix" that mismatch would reintroduce the prerelease-suffix
422
+ * coercion bug this function was fixed for (see the note above).
423
+ *
424
+ * @param {string} a
425
+ * @param {string} b
426
+ * @returns {number} negative if a < b, positive if a > b, 0 if equal
427
+ */
428
+ function compareVersions(a, b) {
429
+ const pa = String(a)
430
+ .split(".")
431
+ .map((n) => parseInt(n, 10) || 0);
432
+ const pb = String(b)
433
+ .split(".")
434
+ .map((n) => parseInt(n, 10) || 0);
435
+ const len = Math.max(pa.length, pb.length);
436
+ for (let i = 0; i < len; i++) {
437
+ const na = pa[i] || 0;
438
+ const nb = pb[i] || 0;
439
+ if (na !== nb) return na - nb;
440
+ }
441
+ return 0;
442
+ }
443
+
444
+ /**
445
+ * Fetch the latest published version of the `forgedock` package straight
446
+ * from the npm registry over HTTPS.
447
+ *
448
+ * Deliberately does NOT shell out to `npm view` — on some Windows setups
449
+ * `execSync("npm view ...")` reliably hangs until the timeout kills it
450
+ * (npm.cmd spawned via cmd.exe), even though the same command runs
451
+ * instantly from an interactive shell. A direct registry request has no
452
+ * such dependency on npm being on PATH or well-behaved under a subshell,
453
+ * and matches what npm's own update-notifier does internally.
454
+ *
455
+ * Resolves to "" (never rejects) on any failure — offline, DNS failure,
456
+ * non-200 response, oversized response body, malformed JSON, or timeout —
457
+ * so callers can treat "" as "could not determine" without their own
458
+ * try/catch.
459
+ *
460
+ * @returns {Promise<string>} latest version string, or "" if unknown
461
+ */
462
+ function fetchLatestVersion() {
463
+ return new Promise((resolve) => {
464
+ let settled = false;
465
+ const done = (value) => {
466
+ if (settled) return;
467
+ settled = true;
468
+ resolve(value);
469
+ };
470
+
471
+ // Real payload is ~1-2 KB; this is generous headroom against an
472
+ // unbounded response body buffering before JSON.parse (issue #1931).
473
+ const MAX_RESPONSE_BYTES = 65536;
474
+
475
+ try {
476
+ const req = https.get(
477
+ "https://registry.npmjs.org/forgedock/latest",
478
+ { timeout: 5000, headers: { Accept: "application/json" } },
479
+ (res) => {
480
+ if (res.statusCode !== 200) {
481
+ res.resume();
482
+ return done("");
483
+ }
484
+ let data = "";
485
+ res.on("data", (chunk) => {
486
+ if (settled) return;
487
+ data += chunk;
488
+ if (data.length > MAX_RESPONSE_BYTES) {
489
+ res.destroy();
490
+ done("");
491
+ }
492
+ });
493
+ res.on("end", () => {
494
+ try {
495
+ const json = JSON.parse(data);
496
+ done(typeof json.version === "string" ? json.version : "");
497
+ } catch {
498
+ done("");
499
+ }
500
+ });
501
+ res.on("error", () => done(""));
502
+ },
503
+ );
504
+ req.on("timeout", () => {
505
+ req.destroy();
506
+ done("");
507
+ });
508
+ req.on("error", () => done(""));
509
+ } catch {
510
+ done("");
511
+ }
512
+ });
513
+ }
514
+
515
+ /**
516
+ * Fetch the release notes ("What's Changed" body + html_url) for a tagged
517
+ * GitHub release, used to build the diff-aware changelog summary
518
+ * (forge#1947) when a newer version is available in npm/npx mode.
519
+ *
520
+ * Mirrors fetchLatestVersion()'s defensive contract: resolves to `null`
521
+ * (never rejects) on any failure — offline, DNS failure, non-200 response
522
+ * (including 404 for an unpublished tag or 403 for rate-limiting), oversized
523
+ * response body, malformed JSON, or timeout. Callers treat `null` as
524
+ * "changelog unavailable" and skip the summary silently — this must never
525
+ * block or fail the update itself.
526
+ *
527
+ * @param {string} owner
528
+ * @param {string} repo
529
+ * @param {string} tag - Release tag, e.g. "v1.1.9".
530
+ * @returns {Promise<{body: string, html_url: string}|null>}
531
+ */
532
+ function fetchGitHubReleaseNotes(owner, repo, tag) {
533
+ return new Promise((resolve) => {
534
+ let settled = false;
535
+ const done = (value) => {
536
+ if (settled) return;
537
+ settled = true;
538
+ resolve(value);
539
+ };
540
+
541
+ // Release bodies can be a few KB for a busy release — generous headroom
542
+ // against an unbounded response body buffering before JSON.parse.
543
+ const MAX_RESPONSE_BYTES = 262144;
544
+
545
+ try {
546
+ const req = https.get(
547
+ `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases/tags/${encodeURIComponent(tag)}`,
548
+ {
549
+ timeout: 5000,
550
+ headers: {
551
+ Accept: "application/vnd.github+json",
552
+ "User-Agent": "forgedock-cli",
553
+ },
554
+ },
555
+ (res) => {
556
+ if (res.statusCode !== 200) {
557
+ res.resume();
558
+ return done(null);
559
+ }
560
+ let data = "";
561
+ res.on("data", (chunk) => {
562
+ if (settled) return;
563
+ data += chunk;
564
+ if (data.length > MAX_RESPONSE_BYTES) {
565
+ res.destroy();
566
+ done(null);
567
+ }
568
+ });
569
+ res.on("end", () => {
570
+ try {
571
+ const json = JSON.parse(data);
572
+ if (typeof json.body !== "string") return done(null);
573
+ done({
574
+ body: json.body,
575
+ html_url: typeof json.html_url === "string" ? json.html_url : "",
576
+ });
577
+ } catch {
578
+ done(null);
579
+ }
580
+ });
581
+ res.on("error", () => done(null));
582
+ },
583
+ );
584
+ req.on("timeout", () => {
585
+ req.destroy();
586
+ done(null);
587
+ });
588
+ req.on("error", () => done(null));
589
+ } catch {
590
+ done(null);
591
+ }
592
+ });
593
+ }
594
+
595
+ function splash(context = "") {
394
596
  const version = getVersion();
395
- process.stderr.write(renderLogo({ version }) + "\n");
597
+ process.stderr.write(renderLogo({ version, context }) + "\n");
396
598
  }
397
599
 
398
600
  // ---------------------------------------------------------------------------
@@ -617,19 +819,125 @@ function ctx() {
617
819
  return c;
618
820
  }
619
821
 
822
+ /**
823
+ * Extract `project.owner`/`project.repo` and `branches.staging` from a
824
+ * forge.yaml's raw text, using the same tolerant, no-YAML-parser regex style
825
+ * as resolveLabelsRepo()/doctor's Check 4 (quoted or unquoted scalar, ignores
826
+ * inline comments). Deliberately separate from resolveLabelsRepo() — that
827
+ * function also consults a CLI `--repo` flag, which doesn't apply here.
828
+ * @param {string} raw - forge.yaml file contents
829
+ * @returns {{repo: string|null, staging: string|null}}
830
+ */
831
+ function extractRepoAndStaging(raw) {
832
+ const ownerMatch = raw.match(/^\s*owner:\s*["']?([^\s"'#]+)["']?/m);
833
+ const repoMatch = raw.match(/^\s*repo:\s*["']?([^\s"'#]+)["']?/m);
834
+ const stagingMatch = raw.match(/^\s*staging:\s*["']?([^\s"'#]+)["']?/m);
835
+ return {
836
+ repo: ownerMatch && repoMatch ? `${ownerMatch[1]}/${repoMatch[1]}` : null,
837
+ staging: stagingMatch ? stagingMatch[1] : null,
838
+ };
839
+ }
840
+
841
+ /**
842
+ * Best-effort re-entry dashboard data (issue #1945): staging PR count and
843
+ * engine in-flight/stalled counts. Every source is independently try/caught —
844
+ * one failing (no gh auth, no forge.yaml, empty ~/.forge/runs) degrades that
845
+ * row to "unknown"/"none" rather than blocking the others or crashing the
846
+ * whole status screen. `engine-cli.mjs` is dynamically imported (matching the
847
+ * existing `run-issue`/`resume-stalled` call sites in this file) so `status`
848
+ * doesn't pay the engine module's load cost unless it's actually reachable.
849
+ * @param {string} cwd
850
+ * @returns {Promise<{repo: string|null, staging: string|null,
851
+ * prCount: number|null, engine: {total:number,inFlight:number,stalled:number}|null,
852
+ * lastRun: {issue:number,terminal:boolean,terminalReason:string|null}|null}>}
853
+ */
854
+ async function gatherDashboardData(cwd) {
855
+ const result = { repo: null, staging: null, prCount: null, engine: null, lastRun: null };
856
+
857
+ const forgeYamlPath = join(cwd, "forge.yaml");
858
+ if (existsSync(forgeYamlPath)) {
859
+ try {
860
+ const raw = readFileSync(forgeYamlPath, "utf-8");
861
+ const { repo, staging } = extractRepoAndStaging(raw);
862
+ result.repo = repo;
863
+ result.staging = staging;
864
+ } catch {
865
+ // leave repo/staging null — dashboard rows degrade below
866
+ }
867
+ }
868
+
869
+ let engineCli = null;
870
+ try {
871
+ engineCli = await import("./engine-cli.mjs");
872
+ } catch {
873
+ // engine module unavailable — engine/lastRun rows stay null
874
+ }
875
+
876
+ if (result.repo) {
877
+ try {
878
+ const args = ["pr", "list", "-R", result.repo, "--state", "open", "--json", "number"];
879
+ if (result.staging) args.push("--base", result.staging);
880
+ const out = execFileSync("gh", args, {
881
+ stdio: ["ignore", "pipe", "ignore"],
882
+ encoding: "utf-8",
883
+ timeout: 10000,
884
+ });
885
+ result.prCount = JSON.parse(out).length;
886
+ } catch {
887
+ // gh not authenticated, not installed, or timed out — leave prCount null ("unknown")
888
+ }
889
+ }
890
+
891
+ if (engineCli) {
892
+ try {
893
+ const io = engineCli.makeIo();
894
+ result.engine = await engineCli.countEngineActivity(io, result.repo, Date.now());
895
+ } catch {
896
+ // leave engine null ("unknown")
897
+ }
898
+ try {
899
+ result.lastRun = engineCli.lastLocalRun(engineCli.runDir());
900
+ } catch {
901
+ // leave lastRun null ("none")
902
+ }
903
+ }
904
+
905
+ return result;
906
+ }
907
+
620
908
  /** Compact status screen for configured/managed directories. */
621
- function statusScreen(c) {
909
+ async function statusScreen(c) {
622
910
  const state = resolveState(c.cwd);
623
911
  const mark = renderMark("compact", c.mode);
624
912
  const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
625
913
  c.stdout.write("\n" + mark[0] + "\n");
626
914
  c.stdout.write(mark[1] + " " + ember("FORGEDOCK", c.mode) + " " + dim("status") + "\n");
627
- c.stdout.write(mark[2] + "\n" + mark[3] + "\n\n");
915
+ c.stdout.write(mark[2] + "\n" + mark[3] + "\n");
916
+ c.stdout.write(" " + dim(getLogoTagline("status")) + "\n\n");
628
917
  const configured = existsSync(join(c.cwd, "forge.yaml"));
629
918
  c.stdout.write(` directory ${state}\n`);
630
919
  c.stdout.write(` forge.yaml ${configured ? "present" : "missing"}\n`);
631
920
  const { targetDir: statusTargetDir } = detectInstallPaths();
632
921
  c.stdout.write(` commands ${existsSync(statusTargetDir) ? "installed at " + statusTargetDir : "not installed"}\n`);
922
+
923
+ // Re-entry mini-dashboard (#1945) — only meaningful once the repo is
924
+ // actually configured; an unconfigured/unmanaged directory has nothing to
925
+ // report yet and would otherwise render a wall of "unknown" rows.
926
+ if (configured && (state === "managed-active")) {
927
+ const data = await gatherDashboardData(c.cwd);
928
+ c.stdout.write("\n");
929
+ c.stdout.write(
930
+ ` staging PRs ${data.prCount === null ? dim("unknown") : `${data.prCount} open${data.staging ? " → " + data.staging : ""}`}\n`,
931
+ );
932
+ c.stdout.write(
933
+ ` engine ${data.engine === null ? dim("unknown") : (data.engine.total === 0 ? "none in-flight" : `${data.engine.inFlight} in-flight, ${data.engine.stalled} stalled`)}\n`,
934
+ );
935
+ c.stdout.write(` bot token ${dim("not available — see docs/CONFIG.md (GitHub App Install)")}\n`);
936
+ c.stdout.write(
937
+ ` last run ${data.lastRun === null ? dim("none") : `#${data.lastRun.issue} — ${data.lastRun.terminal ? (data.lastRun.terminalReason || "done") : "in progress"}`}\n`,
938
+ );
939
+ }
940
+
633
941
  if (state === "managed-optedout") {
634
942
  c.stdout.write(`\n ForgeDock is disabled (opted out) here. Re-enable: npx forgedock enable\n`);
635
943
  } else if (state === "unmanaged") {
@@ -644,18 +952,44 @@ function statusScreen(c) {
644
952
 
645
953
  async function initFlow(c) {
646
954
  const outputPath = join(c.cwd, "forge.yaml");
647
- if (existsSync(outputPath) && process.stdin.isTTY !== true) {
955
+ const hasExisting = existsSync(outputPath);
956
+ if (hasExisting && process.stdin.isTTY !== true) {
648
957
  const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
649
958
  c.stdout.write("\n forge.yaml already exists — non-interactive run, aborting to protect it.\n");
650
959
  c.stdout.write(" " + dim("Run interactively (or delete forge.yaml) to regenerate.") + "\n");
651
960
  return 1;
652
961
  }
653
962
 
963
+ // Restore the #578 overwrite-confirmation gate for the --minimal/--manual
964
+ // escape hatches (forge#1850): these branches previously went straight to
965
+ // backupExisting() + write with ZERO on-screen warning in TTY mode — worse
966
+ // than the default review() flow below, which at least shows an
967
+ // "Overwrite Mode" banner. The default flow's own gate lives in
968
+ // journey.mjs review() (ctx.confirmFn).
969
+ if (hasExisting && (flags.includes("--minimal") || flags.includes("--manual"))) {
970
+ const confirmed = await confirm(
971
+ "forge.yaml already exists. Overwrite it? A backup will be created.",
972
+ false,
973
+ );
974
+ if (!confirmed) {
975
+ c.stdout.write("\n Overwrite cancelled — forge.yaml left untouched.\n");
976
+ return 1;
977
+ }
978
+ }
979
+
654
980
  if (flags.includes("--minimal")) {
655
981
  // Staging semantics preserved (#1148): detection → minimal template with
656
982
  // only the three required sections. No review screen — this is the
657
983
  // power-user escape hatch for a ~20-line forge.yaml.
658
984
  const { draft, description } = await read(c);
985
+ // Hard-fail on unresolved identity placeholders when overwriting an
986
+ // existing config (forge#1850 spec item 4) — never destroy a working
987
+ // forge.yaml in favor of one that can't address a repo.
988
+ if (hasExisting && (draft.project.owner.value === "your-github-org" || draft.project.repo.value === "your-repo-name")) {
989
+ c.stdout.write("\n Could not determine the GitHub owner/repo for this project.\n");
990
+ c.stdout.write(" Run this inside the target git repository, or use interactive init (npx forgedock init) to edit those fields.\n");
991
+ return 1;
992
+ }
659
993
  const backup = backupExisting(outputPath);
660
994
  if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
661
995
  const content = buildMinimalForgeYaml({
@@ -670,6 +1004,7 @@ async function initFlow(c) {
670
1004
  });
671
1005
  atomicWriteFile(outputPath, content);
672
1006
  celebrate(c, { written: true, todoCount: 0, isMinimal: true });
1007
+ await maybeOfferDemo(c);
673
1008
  return 0;
674
1009
  }
675
1010
 
@@ -686,16 +1021,25 @@ async function initFlow(c) {
686
1021
  defaultBranch: await input("Default branch", draft.branches.default.value),
687
1022
  stagingBranch: await input("Staging branch", draft.branches.staging.value),
688
1023
  };
1024
+ // Checked on the FINAL accepted values (post-prompt), not the draft —
1025
+ // the user may have typed over a placeholder default.
1026
+ if (hasExisting && (v.owner === "your-github-org" || v.repo === "your-repo-name")) {
1027
+ c.stdout.write("\n Could not determine the GitHub owner/repo for this project.\n");
1028
+ c.stdout.write(" Enter real values, or run this inside the target git repository.\n");
1029
+ return 1;
1030
+ }
689
1031
  const backup = backupExisting(outputPath);
690
1032
  if (backup) c.stdout.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
691
1033
  const lowKeys = manualLowConfidenceKeys(draft, description, v);
692
1034
  const { todoCount } = writeForgeYaml(v, lowKeys, outputPath);
693
1035
  celebrate(c, { written: true, todoCount });
1036
+ await maybeOfferDemo(c);
694
1037
  return 0;
695
1038
  }
696
1039
  const { draft, description } = await read(c);
697
1040
  const reviewed = await review(c, draft, description);
698
1041
  celebrate(c, reviewed);
1042
+ if (!reviewed.aborted) await maybeOfferDemo(c);
699
1043
  return reviewed.aborted ? 1 : 0;
700
1044
  }
701
1045
 
@@ -706,6 +1050,7 @@ async function uninstall() {
706
1050
 
707
1051
  const { targetDir, scriptsTargetDir, manifestPath } = detectInstallPaths();
708
1052
  console.log(` Mode: global (~/.claude)`);
1053
+ console.log(` ${dim(getLogoTagline("uninstall"))}`);
709
1054
  console.log("");
710
1055
 
711
1056
  const files = await findMarkdownFiles(COMMANDS_DIR);
@@ -965,19 +1310,162 @@ async function uninstall() {
965
1310
  // symlinks or hook registration got out of sync.
966
1311
  async function relinkAndHint() {
967
1312
  const c = ctx();
968
- await forge(c);
1313
+ const forged = await forge(c);
1314
+ // Refresh the install receipt (#1946) — relinkAndHint() is called from
1315
+ // BOTH update() branches (the git-clone fast-forward path and the npm
1316
+ // version-check path), so wiring it here covers "refreshed after every
1317
+ // successful update" without duplicating the call at each branch. Note:
1318
+ // this is NOT reached by install's already-managed-active short-circuit
1319
+ // (that path calls statusScreen(), never relinkAndHint()) — see the
1320
+ // writeInstallReceipt() JSDoc in journey.mjs for the full picture.
1321
+ await writeInstallReceipt(c, { forged });
969
1322
  if (!existsSync(join(c.cwd, "forge.yaml"))) {
970
1323
  const dim = (s) => (c.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
971
1324
  c.stdout.write(" " + dim("Configure this repo: npx forgedock init") + "\n");
972
1325
  }
973
1326
  }
974
1327
 
1328
+ /**
1329
+ * Print a condensed, diff-aware changelog summary after a successful
1330
+ * git-clone-mode fast-forward update (forge#1947).
1331
+ *
1332
+ * Sourced entirely from local git history — no network call needed here,
1333
+ * since the git-clone install already has the full commit range available.
1334
+ * Reads old/new package.json versions from the before/after commits, diffs
1335
+ * `commands/` + `bin/engine/` for added/updated/removed files, scans commit
1336
+ * subjects for breaking-change markers, and derives a compare-URL from the
1337
+ * `origin` remote (omitted if the remote isn't a recognizable GitHub URL).
1338
+ *
1339
+ * Best-effort and read-only: any failure (git command error, JSON parse
1340
+ * error, unparseable remote) is swallowed silently so it can never block or
1341
+ * fail the update itself — the caller has already printed "Updated to
1342
+ * latest." by the time this runs.
1343
+ *
1344
+ * All git subprocess calls use execFileSync with an argument array (never a
1345
+ * template-literal string passed to execSync) — see forge#1703/forge#413:
1346
+ * interpolating a variable into an execSync string spawns `/bin/sh -c` and
1347
+ * shell-parses it.
1348
+ *
1349
+ * @param {string} before - HEAD SHA before the merge.
1350
+ * @param {string} after - HEAD SHA after the merge.
1351
+ */
1352
+ function printGitCloneChangelogSummary(before, after) {
1353
+ try {
1354
+ let fromVersion = "";
1355
+ try {
1356
+ const beforePkgRaw = execFileSync("git", ["show", `${before}:package.json`], {
1357
+ cwd: FORGE_HOME,
1358
+ encoding: "utf-8",
1359
+ });
1360
+ fromVersion = JSON.parse(beforePkgRaw).version || "";
1361
+ } catch {
1362
+ // package.json may not have existed at `before` (fresh install range)
1363
+ // or failed to parse — the summary still works without a from-version.
1364
+ }
1365
+ const toVersion = getVersion();
1366
+
1367
+ const diffOutput = execFileSync(
1368
+ "git",
1369
+ ["diff", "--name-status", before, after, "--", "commands/", "bin/engine/"],
1370
+ { cwd: FORGE_HOME, encoding: "utf-8" },
1371
+ );
1372
+ const { commandsAdded, commandsUpdated, commandsRemoved, engineChanged } =
1373
+ classifyCommandChanges(parseNameStatusDiff(diffOutput));
1374
+
1375
+ const subjectsOutput = execFileSync(
1376
+ "git",
1377
+ ["log", "--pretty=%s", `${before}..${after}`],
1378
+ { cwd: FORGE_HOME, encoding: "utf-8" },
1379
+ );
1380
+ const breakingCount = countBreakingCommits(subjectsOutput.split(/\r?\n/));
1381
+
1382
+ let compareUrl;
1383
+ try {
1384
+ const remoteUrl = execFileSync("git", ["remote", "get-url", "origin"], {
1385
+ cwd: FORGE_HOME,
1386
+ encoding: "utf-8",
1387
+ }).trim();
1388
+ const ownerRepo = parseGitHubOwnerRepo(remoteUrl);
1389
+ if (ownerRepo && fromVersion && toVersion) {
1390
+ compareUrl = `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/compare/v${fromVersion}...v${toVersion}`;
1391
+ }
1392
+ } catch {
1393
+ // No `origin` remote, or an unparseable/non-GitHub URL — the summary
1394
+ // still prints, just without a compare link.
1395
+ }
1396
+
1397
+ const summary = formatUpdateChangelogSummary({
1398
+ fromVersion,
1399
+ toVersion,
1400
+ commandsAdded,
1401
+ commandsUpdated,
1402
+ commandsRemoved,
1403
+ engineChanged,
1404
+ breakingCount,
1405
+ compareUrl,
1406
+ });
1407
+ for (const line of summary.split("\n")) {
1408
+ console.log(` ${dim(line)}`);
1409
+ }
1410
+ } catch {
1411
+ // Changelog summary is best-effort only — never block or fail the
1412
+ // update itself over a diff/parse error.
1413
+ }
1414
+ }
1415
+
1416
+ /**
1417
+ * Best-effort fetch + print of a diff-aware changelog summary when a newer
1418
+ * version is available in npm/npx mode (forge#1947).
1419
+ *
1420
+ * npm/npx mode never runs the actual update itself (the user is told to run
1421
+ * `npx forgedock@latest`), so there is no local before/after diff to source
1422
+ * from. Instead, this fetches the newest published GitHub release's notes
1423
+ * (hardcoded canonical repo — npm-installed forgedock always originates
1424
+ * there, there is no local git remote to inspect) and condenses its
1425
+ * auto-generated "What's Changed" bullet list into conventional-commit-type
1426
+ * counts.
1427
+ *
1428
+ * Any failure (network down, rate-limited, unpublished tag) resolves to
1429
+ * `null` from fetchGitHubReleaseNotes() and is silently skipped — the
1430
+ * existing "New version available" message has already been printed by the
1431
+ * caller regardless.
1432
+ *
1433
+ * @param {string} localVersion
1434
+ * @param {string} latestVersion
1435
+ */
1436
+ async function printVersionAvailableChangelog(localVersion, latestVersion) {
1437
+ try {
1438
+ const notes = await fetchGitHubReleaseNotes(
1439
+ "RapierCraftStudios",
1440
+ "ForgeDock",
1441
+ `v${latestVersion}`,
1442
+ );
1443
+ if (!notes) return;
1444
+
1445
+ const { counts, breakingCount } = classifyConventionalCommitLines(notes.body);
1446
+ const summary = formatVersionAvailableSummary({
1447
+ currentVersion: localVersion,
1448
+ latestVersion,
1449
+ typeCounts: counts,
1450
+ breakingCount,
1451
+ releaseUrl: notes.html_url,
1452
+ });
1453
+ for (const line of summary.split("\n")) {
1454
+ console.log(` ${dim(line)}`);
1455
+ }
1456
+ } catch {
1457
+ // Best-effort only — a failure here must never block the update advice
1458
+ // already printed by the caller.
1459
+ }
1460
+ }
1461
+
975
1462
  async function update() {
976
1463
  console.log("");
977
1464
  console.log(`${BOLD}ForgeDock${RESET} — Checking for updates`);
978
1465
  console.log("");
979
1466
 
980
1467
  console.log(` Mode: global (~/.claude)`);
1468
+ console.log(` ${dim(getLogoTagline("update"))}`);
981
1469
 
982
1470
  // Check if installed via npm (no .git directory) or via git clone
983
1471
  const gitDir = join(FORGE_HOME, ".git");
@@ -1023,6 +1511,7 @@ async function update() {
1023
1511
  console.log(` Already up to date.`);
1024
1512
  } else {
1025
1513
  console.log(` ${GREEN}Updated to latest.${RESET}`);
1514
+ printGitCloneChangelogSummary(before, after);
1026
1515
  }
1027
1516
  await relinkAndHint();
1028
1517
 
@@ -1045,9 +1534,57 @@ async function update() {
1045
1534
  );
1046
1535
  }
1047
1536
  } else {
1048
- console.log(
1049
- ` Installed via npm. Run ${CYAN}npm update -g forgedock${RESET} to update.`,
1050
- );
1537
+ // npm/npx install (no .git dir in FORGE_HOME) — most commonly the npx
1538
+ // cache path (`npx forgedock`), which was never globally installed, so
1539
+ // "npm update -g forgedock" is a no-op. Check the actual published
1540
+ // version instead and give advice that reflects the real install model.
1541
+ const localVersion = getVersion();
1542
+ const latestVersion = await fetchLatestVersion();
1543
+
1544
+ if (!latestVersion) {
1545
+ console.log(
1546
+ ` ${YELLOW}Could not check npm for the latest version.${RESET} To force a refresh: ${CYAN}npx forgedock@latest${RESET}`,
1547
+ );
1548
+ } else if (!localVersion) {
1549
+ console.log(
1550
+ ` Latest published version is ${CYAN}v${latestVersion}${RESET}. To update: ${CYAN}npx forgedock@latest${RESET}`,
1551
+ );
1552
+ } else if (compareVersions(latestVersion, localVersion) > 0) {
1553
+ console.log(
1554
+ ` ${GREEN}New version available: v${latestVersion}${RESET} (you have v${localVersion}).`,
1555
+ );
1556
+ console.log(` Run ${CYAN}npx forgedock@latest${RESET} to fetch it.`);
1557
+ await printVersionAvailableChangelog(localVersion, latestVersion);
1558
+ } else {
1559
+ console.log(` Already up to date (v${localVersion}).`);
1560
+ }
1561
+
1562
+ // Refresh the persisted ~/.forge/ copy from the currently-resolved
1563
+ // package (forge#1943). The version-check above is advisory only — it
1564
+ // tells the user to re-run npx forgedock@latest but never touched disk.
1565
+ // This actually copies whatever payload FORGE_HOME resolves to right now
1566
+ // (the just-fetched npx package on `npx forgedock@latest update`, or
1567
+ // whatever was already resolved on a plain `npx forgedock update`) into
1568
+ // ~/.forge/, so the persisted copy doesn't go stale between full
1569
+ // install/init runs. Skipped as a no-op for git-clone installs (handled
1570
+ // by the `if (existsSync(gitDir))` branch above — this code path only
1571
+ // runs for npm/npx installs). Fail-open: any error here must never block
1572
+ // relinkAndHint() below.
1573
+ try {
1574
+ const persisted = await persistHome(ctx());
1575
+ if (!persisted.skipped) {
1576
+ await setPersistedHomeState({
1577
+ path: persisted.forgeHome,
1578
+ version: persisted.version,
1579
+ });
1580
+ if (persisted.migrated) {
1581
+ console.log(` ${GREEN}Refreshed ~/.forge/${RESET} (v${persisted.version || "unknown"}).`);
1582
+ }
1583
+ }
1584
+ } catch {
1585
+ // Best-effort — persisted-home refresh must never block the update.
1586
+ }
1587
+
1051
1588
  await relinkAndHint();
1052
1589
  }
1053
1590
  console.log("");
@@ -1202,10 +1739,23 @@ async function labelsSetup(repo) {
1202
1739
  * 9. Playwright MCP registered in ~/.claude/mcp_servers.json (advisory warn — required for /qa-sweep)
1203
1740
  * 10. yq installed (hard dependency for forge.yaml parsing)
1204
1741
  * 11. Claude Code installed and version compatible (advisory warn if not on PATH)
1742
+ * 12. GitHub App / bot token status — informational only; reports that
1743
+ * pipeline gh calls use the active `gh auth` context (personal token
1744
+ * unless the operator has manually configured a bot token). Installing
1745
+ * the GitHub App alone does not create a bot token (see forge#1890).
1746
+ *
1747
+ * `--fix` (fix=true): auto-applies remediation for checks with a safe,
1748
+ * deterministic, no-input-needed fix — Command symlinks (1), SessionStart
1749
+ * hook registration (5), SessionStart hook script path integrity (5c),
1750
+ * CLAUDE.md legacy block (6), and GitHub workflow labels (7). Checks that
1751
+ * require user interaction (gh auth login, git identity, forge.yaml init,
1752
+ * yq/Claude Code installs, Playwright MCP registration) are always
1753
+ * report-only — `--fix` never touches them. See forge#1944.
1205
1754
  *
1206
1755
  * Returns 0 if all checks pass (warnings allowed), 1 if any hard check fails.
1756
+ * @param {boolean} [fix] - When true, auto-apply deterministic remediations.
1207
1757
  */
1208
- async function doctor() {
1758
+ async function doctor(fix = false) {
1209
1759
  console.log("");
1210
1760
  console.log(`${BOLD}ForgeDock Doctor${RESET} — Installation Health Check`);
1211
1761
  console.log("");
@@ -1249,71 +1799,179 @@ async function doctor() {
1249
1799
  console.log(` Note: ${hint}`);
1250
1800
  }
1251
1801
 
1802
+ /**
1803
+ * Print a "fixed" line — distinct from pass/fail/warn — for a check that
1804
+ * failed or warned on first detection but was successfully auto-remediated
1805
+ * during this run. Does not count toward failures/warnings.
1806
+ * @param {string} label
1807
+ * @param {string} [detail]
1808
+ */
1809
+ let fixesApplied = 0;
1810
+ function fixed(label, detail) {
1811
+ fixesApplied++;
1812
+ const suffix = detail ? ` ${detail}` : "";
1813
+ console.log(` ${CYAN}↻${RESET} ${BOLD}${label}${RESET}${suffix}`);
1814
+ }
1815
+
1816
+ const settingsPath = join(HOME, ".claude", "settings.json");
1817
+
1818
+ /**
1819
+ * Detect whether the registered SessionStart hook entry (if any) points at
1820
+ * a script path that no longer exists on disk — the same detection Check 5c
1821
+ * reports on. Shared with runInstallRepairOnce() below: forge()'s
1822
+ * installSessionStartHook() treats ANY existing ForgeDock-marked entry as
1823
+ * "already registered" regardless of whether its script path still
1824
+ * resolves, so a dangling entry must be explicitly cleared before forge()
1825
+ * will register a fresh one. (forge#1895)
1826
+ * @returns {{state: 'ok'|'no-entry'|'unparseable'|'dangling'|'error', path: (string|null), command?: string, ephemeral?: boolean, error: (string|null)}}
1827
+ */
1828
+ function detectSessionStartHookPath() {
1829
+ try {
1830
+ const settings = readClaudeSettings();
1831
+ const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
1832
+ ? settings.hooks.SessionStart
1833
+ : [];
1834
+
1835
+ let hookCommand = null;
1836
+ outer: for (const entry of sessionStartEntries) {
1837
+ if (!entry || typeof entry !== "object") continue;
1838
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
1839
+ for (const h of hooks) {
1840
+ if (h && typeof h.command === "string" && isForgeSessionStartHook(h.command)) {
1841
+ hookCommand = h.command;
1842
+ break outer;
1843
+ }
1844
+ }
1845
+ }
1846
+
1847
+ if (!hookCommand) return { state: "no-entry", path: null, error: null };
1848
+
1849
+ const pathMatch = hookCommand.match(/node\s+"([^"]+)"/);
1850
+ const hookScriptPath = pathMatch ? pathMatch[1] : null;
1851
+ if (!hookScriptPath) {
1852
+ return { state: "unparseable", path: null, command: hookCommand, error: null };
1853
+ }
1854
+ if (existsSync(hookScriptPath)) {
1855
+ return { state: "ok", path: hookScriptPath, error: null };
1856
+ }
1857
+ return {
1858
+ state: "dangling",
1859
+ path: hookScriptPath,
1860
+ ephemeral: isEphemeralCachePath(hookScriptPath),
1861
+ error: null,
1862
+ };
1863
+ } catch (err) {
1864
+ return { state: "error", path: null, error: err.message };
1865
+ }
1866
+ }
1867
+
1868
+ /**
1869
+ * Shared repair primitive for Checks 1 (command symlinks), 5 (SessionStart
1870
+ * hook registration), and 5c (SessionStart hook script path integrity).
1871
+ * Memoized so it only runs once per `doctor --fix` invocation even though
1872
+ * more than one check can trigger it.
1873
+ *
1874
+ * Reuses forge() (bin/journey.mjs) verbatim — the same repair path already
1875
+ * used by `npx forgedock update` (see relinkAndHint()) — rather than
1876
+ * duplicating symlink/copy-mode or hook-registration logic here.
1877
+ */
1878
+ let installRepairRan = false;
1879
+ async function runInstallRepairOnce() {
1880
+ if (installRepairRan) return;
1881
+ installRepairRan = true;
1882
+ console.log(` ${CYAN}↻${RESET} Repairing command links + SessionStart hook...`);
1883
+ try {
1884
+ const staleHook = detectSessionStartHookPath();
1885
+ if (staleHook.state === "dangling") {
1886
+ removeSessionStartHook(settingsPath);
1887
+ }
1888
+ await forge(ctx());
1889
+ } catch (err) {
1890
+ console.log(` ${RED}Repair failed:${RESET} ${err.message}`);
1891
+ }
1892
+ }
1893
+
1252
1894
  // ── Check 1: Command symlinks ──────────────────────────────────────────────
1253
1895
  {
1254
- let symlinkOk = true;
1255
- let checked = 0;
1256
- let broken = 0;
1257
- const brokenLinks = [];
1896
+ async function detectCommandFiles() {
1897
+ let symlinkOk = true;
1898
+ let checked = 0;
1899
+ let broken = 0;
1900
+ const brokenLinks = [];
1258
1901
 
1259
- try {
1260
- // Collect installable .md files from COMMANDS_DIR using the same tier filter
1261
- // applied by forge() / findMarkdownFiles() — doctor() must validate the filtered
1262
- // install surface, not the raw directory walk, or it would report false failures
1263
- // for 'internal' specs that were deliberately excluded from the install.
1264
- const sourceFiles = await findMarkdownFiles(COMMANDS_DIR);
1265
-
1266
- for (const src of sourceFiles) {
1267
- const rel = relative(COMMANDS_DIR, src);
1268
- const tgt = join(TARGET_DIR, rel);
1269
- checked++;
1270
- try {
1271
- const lstats = await lstat(tgt);
1272
- if (lstats.isSymbolicLink()) {
1273
- const dest = await readlink(tgt);
1274
- if (dest !== src) {
1275
- broken++;
1276
- brokenLinks.push(rel);
1277
- symlinkOk = false;
1278
- }
1279
- } else {
1280
- // Regular file — copy-mode install (Windows without Developer Mode).
1281
- // forge() falls back to copyFile() on EPERM/EACCES, so a regular
1282
- // file is valid as long as its content matches the source. <!-- Added: forge#1174 -->
1283
- try {
1284
- const srcContent = readFileSync(src, "utf-8");
1285
- const tgtContent = readFileSync(tgt, "utf-8");
1286
- if (srcContent !== tgtContent) {
1902
+ try {
1903
+ // Collect installable .md files from COMMANDS_DIR using the same tier filter
1904
+ // applied by forge() / findMarkdownFiles() — doctor() must validate the filtered
1905
+ // install surface, not the raw directory walk, or it would report false failures
1906
+ // for 'internal' specs that were deliberately excluded from the install.
1907
+ const sourceFiles = await findMarkdownFiles(COMMANDS_DIR);
1908
+
1909
+ for (const src of sourceFiles) {
1910
+ const rel = relative(COMMANDS_DIR, src);
1911
+ const tgt = join(TARGET_DIR, rel);
1912
+ checked++;
1913
+ try {
1914
+ const lstats = await lstat(tgt);
1915
+ if (lstats.isSymbolicLink()) {
1916
+ const dest = await readlink(tgt);
1917
+ if (dest !== src) {
1287
1918
  broken++;
1288
- brokenLinks.push(`${rel} (stale copy)`);
1919
+ brokenLinks.push(rel);
1920
+ symlinkOk = false;
1921
+ }
1922
+ } else {
1923
+ // Regular file — copy-mode install (Windows without Developer Mode).
1924
+ // forge() falls back to copyFile() on EPERM/EACCES, so a regular
1925
+ // file is valid as long as its content matches the source. <!-- Added: forge#1174 -->
1926
+ try {
1927
+ const srcContent = readFileSync(src, "utf-8");
1928
+ const tgtContent = readFileSync(tgt, "utf-8");
1929
+ if (srcContent !== tgtContent) {
1930
+ broken++;
1931
+ brokenLinks.push(`${rel} (stale copy)`);
1932
+ symlinkOk = false;
1933
+ }
1934
+ // else: content matches — valid copy-mode install, not broken
1935
+ } catch {
1936
+ // Could not read one of the files — treat as broken.
1937
+ broken++;
1938
+ brokenLinks.push(`${rel} (unreadable)`);
1289
1939
  symlinkOk = false;
1290
1940
  }
1291
- // else: content matches — valid copy-mode install, not broken
1292
- } catch {
1293
- // Could not read one of the files — treat as broken.
1294
- broken++;
1295
- brokenLinks.push(`${rel} (unreadable)`);
1296
- symlinkOk = false;
1297
1941
  }
1942
+ } catch {
1943
+ // File missing
1944
+ broken++;
1945
+ brokenLinks.push(`${rel} (missing)`);
1946
+ symlinkOk = false;
1298
1947
  }
1299
- } catch {
1300
- // File missing
1301
- broken++;
1302
- brokenLinks.push(`${rel} (missing)`);
1303
- symlinkOk = false;
1304
1948
  }
1949
+
1950
+ return { ok: symlinkOk, checked, broken, brokenLinks, error: null };
1951
+ } catch (err) {
1952
+ return { ok: false, checked, broken, brokenLinks, error: err.message };
1305
1953
  }
1954
+ }
1306
1955
 
1307
- if (symlinkOk) {
1308
- pass("Command files", `${checked} files installed`);
1956
+ let cmdFiles = await detectCommandFiles();
1957
+ if (!cmdFiles.error && !cmdFiles.ok && fix) {
1958
+ await runInstallRepairOnce();
1959
+ cmdFiles = await detectCommandFiles();
1960
+ }
1961
+
1962
+ if (cmdFiles.error) {
1963
+ fail("Command files", `Could not read commands directory: ${cmdFiles.error}`);
1964
+ } else if (cmdFiles.ok) {
1965
+ if (fix && installRepairRan) {
1966
+ fixed("Command files", `${cmdFiles.checked} files installed (repaired)`);
1309
1967
  } else {
1310
- fail(
1311
- "Command files",
1312
- `Run: npx forgedock install (${broken}/${checked} broken: ${brokenLinks.slice(0, 3).join(", ")}${brokenLinks.length > 3 ? "…" : ""})`,
1313
- );
1968
+ pass("Command files", `${cmdFiles.checked} files installed`);
1314
1969
  }
1315
- } catch (err) {
1316
- fail("Command files", `Could not read commands directory: ${err.message}`);
1970
+ } else {
1971
+ fail(
1972
+ "Command files",
1973
+ `Run: npx forgedock doctor --fix (or: npx forgedock install) (${cmdFiles.broken}/${cmdFiles.checked} broken: ${cmdFiles.brokenLinks.slice(0, 3).join(", ")}${cmdFiles.brokenLinks.length > 3 ? "…" : ""})`,
1974
+ );
1317
1975
  }
1318
1976
  }
1319
1977
 
@@ -1413,11 +2071,56 @@ async function doctor() {
1413
2071
 
1414
2072
  if (missing.length === 0) {
1415
2073
  pass("forge.yaml", "exists with required keys");
1416
- // Extract owner and repo for the label check (simple regex, no YAML parser needed)
1417
- const ownerMatch = content.match(/^\s+owner:\s+"?([^"\n]+)"?\s*$/m);
1418
- const repoMatch = content.match(/^\s+repo:\s+"?([^"\n]+)"?\s*$/m);
2074
+ // Extract owner and repo for the label check (simple regex, no YAML
2075
+ // parser needed). Anchored on the quoted value only (not "rest of
2076
+ // line must be blank") so a trailing `# TODO(forgedock:...)`
2077
+ // comment — exactly what a placeholder/low-confidence field gets —
2078
+ // doesn't prevent extraction. (forge#1850: the previous `\s*$`
2079
+ // anchor silently failed to extract any TODO-flagged value, which
2080
+ // is the one case doctor most needs to see.)
2081
+ const ownerMatch = content.match(/^\s+owner:\s+"([^"]*)"/m);
2082
+ const repoMatch = content.match(/^\s+repo:\s+"([^"]*)"/m);
1419
2083
  if (ownerMatch) forgeOwner = ownerMatch[1].trim();
1420
2084
  if (repoMatch) forgeRepo = repoMatch[1].trim();
2085
+
2086
+ // ── Placeholder / staleness checks (forge#1850) ────────────────
2087
+ // A config can pass the structural check above (has all three
2088
+ // required top-level keys) and still be a stubbed placeholder —
2089
+ // catch that here so it's flagged before any pipeline command
2090
+ // silently consumes it (e.g. opening PRs against a wrong/guessed
2091
+ // branches.staging). Advisory (warn, not fail): a bare `--fast`
2092
+ // install with no git remote yet is a legitimate, already-tested
2093
+ // "get started, fill in details later" path — doctor should
2094
+ // surface the gap loudly without turning normal onboarding into a
2095
+ // broken-install exit code.
2096
+ if (forgeOwner === "your-github-org" || forgeRepo === "your-repo-name") {
2097
+ warn(
2098
+ "forge.yaml identity",
2099
+ "owner/repo are still placeholder values (your-github-org / your-repo-name). Run: npx forgedock init",
2100
+ );
2101
+ }
2102
+
2103
+ const todoMarkers = content.match(/# TODO\(forgedock:[a-zA-Z]+\)/g) || [];
2104
+ if (todoMarkers.length > 0) {
2105
+ const uniqueFields = [...new Set(todoMarkers)].join(", ");
2106
+ warn(
2107
+ "forge.yaml placeholders",
2108
+ `${todoMarkers.length} field(s) still flagged with # TODO(forgedock:...) — verify and fill in: ${uniqueFields}`,
2109
+ );
2110
+ }
2111
+
2112
+ const defaultMatch = content.match(/^\s+default:\s+"([^"]*)"/m);
2113
+ const stagingMatch = content.match(/^\s+staging:\s+"([^"]*)"/m);
2114
+ if (
2115
+ defaultMatch &&
2116
+ stagingMatch &&
2117
+ defaultMatch[1].trim() === stagingMatch[1].trim()
2118
+ ) {
2119
+ warn(
2120
+ "forge.yaml branches",
2121
+ `branches.staging equals branches.default ('${stagingMatch[1].trim()}') — verify this is intentional (some projects have no separate staging branch) rather than an undetected 'origin/staging' fallback. See docs/CONFIG.md.`,
2122
+ );
2123
+ }
1421
2124
  } else {
1422
2125
  fail("forge.yaml", `Missing required keys: ${missing.join(", ")}. Edit forge.yaml or run: npx forgedock init`);
1423
2126
  }
@@ -1427,32 +2130,47 @@ async function doctor() {
1427
2130
 
1428
2131
  // ── Check 5: SessionStart hook registered ─────────────────────────────────
1429
2132
  {
1430
- try {
1431
- const settings = readClaudeSettings();
1432
- const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
1433
- ? settings.hooks.SessionStart
1434
- : [];
1435
- const hookPresent = sessionStartEntries.some((entry) => {
1436
- if (!entry || typeof entry !== "object") return false;
1437
- const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
1438
- return hooks.some(
1439
- (h) =>
1440
- h &&
1441
- typeof h.command === "string" &&
1442
- isForgeSessionStartHook(h.command),
1443
- );
1444
- });
2133
+ function detectSessionStartHookRegistered() {
2134
+ try {
2135
+ const settings = readClaudeSettings();
2136
+ const sessionStartEntries = Array.isArray(settings?.hooks?.SessionStart)
2137
+ ? settings.hooks.SessionStart
2138
+ : [];
2139
+ const hookPresent = sessionStartEntries.some((entry) => {
2140
+ if (!entry || typeof entry !== "object") return false;
2141
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
2142
+ return hooks.some(
2143
+ (h) =>
2144
+ h &&
2145
+ typeof h.command === "string" &&
2146
+ isForgeSessionStartHook(h.command),
2147
+ );
2148
+ });
2149
+ return { ok: hookPresent, error: null };
2150
+ } catch (err) {
2151
+ return { ok: false, error: err.message };
2152
+ }
2153
+ }
1445
2154
 
1446
- if (hookPresent) {
1447
- pass("SessionStart hook", "registered in ~/.claude/settings.json");
2155
+ let hookReg = detectSessionStartHookRegistered();
2156
+ if (!hookReg.error && !hookReg.ok && fix) {
2157
+ await runInstallRepairOnce();
2158
+ hookReg = detectSessionStartHookRegistered();
2159
+ }
2160
+
2161
+ if (hookReg.error) {
2162
+ fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${hookReg.error}. Run: npx forgedock install`);
2163
+ } else if (hookReg.ok) {
2164
+ if (fix && installRepairRan) {
2165
+ fixed("SessionStart hook", "registered in ~/.claude/settings.json (repaired)");
1448
2166
  } else {
1449
- fail(
1450
- "SessionStart hook",
1451
- "Run: npx forgedock install (writes hook entry to ~/.claude/settings.json)",
1452
- );
2167
+ pass("SessionStart hook", "registered in ~/.claude/settings.json");
1453
2168
  }
1454
- } catch (err) {
1455
- fail("SessionStart hook", `Cannot read ~/.claude/settings.json: ${err.message}. Run: npx forgedock install`);
2169
+ } else {
2170
+ fail(
2171
+ "SessionStart hook",
2172
+ "Run: npx forgedock doctor --fix (or: npx forgedock install) (writes hook entry to ~/.claude/settings.json)",
2173
+ );
1456
2174
  }
1457
2175
  }
1458
2176
 
@@ -1491,6 +2209,124 @@ async function doctor() {
1491
2209
  }
1492
2210
  }
1493
2211
 
2212
+ // ── Check 5c: SessionStart hook script path integrity ─────────────────────
2213
+ // Check 5 only verifies a ForgeDock SessionStart hook ENTRY is registered
2214
+ // in settings.json — it never checks whether the script path baked into
2215
+ // that entry's command still exists on disk. For `npx forgedock` installs,
2216
+ // FORGE_HOME (and therefore the hook's script path) can resolve inside an
2217
+ // ephemeral npm/npx/pnpm/yarn cache directory (see isEphemeralCachePath()
2218
+ // in journey.mjs). If that cache is later pruned, the registered entry
2219
+ // still looks healthy to Check 5 while the file it points at is gone —
2220
+ // Claude Code sessions get no ForgeDock context injected and no error is
2221
+ // ever surfaced. This check catches that silent-failure case. (forge#1895)
2222
+ {
2223
+ let hookPathResult = detectSessionStartHookPath();
2224
+ if (hookPathResult.state === "dangling" && fix) {
2225
+ await runInstallRepairOnce();
2226
+ hookPathResult = detectSessionStartHookPath();
2227
+ }
2228
+
2229
+ if (hookPathResult.state === "error") {
2230
+ fail("SessionStart hook script path", `Cannot verify hook script path: ${hookPathResult.error}. Run: npx forgedock install`);
2231
+ } else if (hookPathResult.state === "no-entry") {
2232
+ // No registered entry — Check 5 already reports this; nothing new here.
2233
+ pass("SessionStart hook script path", "skipped — no registered hook entry (see SessionStart hook check above)");
2234
+ } else if (hookPathResult.state === "unparseable") {
2235
+ warn(
2236
+ "SessionStart hook script path",
2237
+ `Could not parse a script path out of the registered hook command ("${hookPathResult.command}"). Run: npx forgedock install`,
2238
+ );
2239
+ } else if (hookPathResult.state === "ok") {
2240
+ if (fix && installRepairRan) {
2241
+ fixed("SessionStart hook script path", `refreshed to a valid path (${hookPathResult.path})`);
2242
+ } else {
2243
+ pass("SessionStart hook script path", `exists on disk (${hookPathResult.path})`);
2244
+ }
2245
+ } else {
2246
+ // Still dangling — either --fix wasn't passed, or the repair attempt
2247
+ // above failed to produce a resolvable path.
2248
+ fail(
2249
+ "SessionStart hook script path",
2250
+ hookPathResult.ephemeral
2251
+ ? `${hookPathResult.path} no longer exists — it was installed from an ephemeral npm/npx/pnpm/yarn cache directory that has since been cleared. Run: npx forgedock doctor --fix (or: npm install -g forgedock then npx forgedock install)`
2252
+ : `${hookPathResult.path} no longer exists. Run: npx forgedock doctor --fix (or: npx forgedock install)`,
2253
+ );
2254
+ }
2255
+ }
2256
+
2257
+ // ── Check 5d: Persisted toolset home (~/.forge) ────────────────────────────
2258
+ // persistHome() (bin/journey.mjs, forge#1943) copies bin/commands/scripts/
2259
+ // templates from wherever FORGE_HOME resolved into a stable ~/.forge/ copy
2260
+ // on every `npx forgedock` install/init/update run, so ~/.claude/commands
2261
+ // symlinks and the SessionStart hook keep working after the npm/npx cache
2262
+ // that originally served them is evicted. This check reports that copy's
2263
+ // state — it is a SIBLING of (and unrelated to) the pre-existing
2264
+ // ~/.forge/{runs,index} engine-data directories used by the run-issue
2265
+ // engine (bin/engine.mjs) and the recall knowledge index (bin/recall.mjs);
2266
+ // this check never reads or writes those.
2267
+ //
2268
+ // A git-clone dev install is explicitly exempt: persistHome() skips it on
2269
+ // purpose (a clone is already stable and user-owned — see Acceptance
2270
+ // Criteria #5 on #1943), so ~/.forge/{bin,commands,...} correctly does not
2271
+ // exist for that install mode. pass() silently rather than warn — an
2272
+ // absence that isn't a problem should not read as one.
2273
+ {
2274
+ try {
2275
+ const isGitCloneInstall = existsSync(join(FORGE_HOME, ".git"));
2276
+ if (isGitCloneInstall) {
2277
+ pass("Persisted toolset home (~/.forge)", "skipped — git-clone install links directly from the clone");
2278
+ } else {
2279
+ const persistedHome = join(HOME, ".forge");
2280
+ const payloadDirs = ["bin", "commands", "scripts", "templates"];
2281
+ const missingDirs = payloadDirs.filter((d) => !existsSync(join(persistedHome, d)));
2282
+ const versionPath = join(persistedHome, "version");
2283
+
2284
+ if (missingDirs.length === payloadDirs.length && !existsSync(versionPath)) {
2285
+ // Pre-migration state — persistHome() simply hasn't run yet for
2286
+ // this user. Not a failure: the very next `npx forgedock` run
2287
+ // creates it.
2288
+ warn(
2289
+ "Persisted toolset home (~/.forge)",
2290
+ "not yet created — run: npx forgedock install (or npx forgedock update)",
2291
+ );
2292
+ } else if (missingDirs.length > 0) {
2293
+ warn(
2294
+ "Persisted toolset home (~/.forge)",
2295
+ `incomplete (missing: ${missingDirs.join(", ")}). Run: npx forgedock install`,
2296
+ );
2297
+ } else {
2298
+ // Prefer the version registry.mjs last recorded for this exact
2299
+ // write (getPersistedHomeState()) — the single source of truth
2300
+ // update()/install also write through — falling back to reading
2301
+ // ~/.forge/version directly on disk when the registry has no entry
2302
+ // (e.g. an older ForgeDock version wrote ~/.forge/ before #1943
2303
+ // added registry tracking). Reading both keeps doctor() honest
2304
+ // even if the registry ever drifts from what's actually on disk.
2305
+ const recorded = getPersistedHomeState();
2306
+ let persistedVersion = recorded?.version || "";
2307
+ if (!persistedVersion) {
2308
+ try {
2309
+ persistedVersion = readFileSync(versionPath, "utf-8").trim();
2310
+ } catch {
2311
+ // version file missing/unreadable — reported below as "unknown"
2312
+ }
2313
+ }
2314
+ const sourceVersion = getVersion();
2315
+ if (persistedVersion && sourceVersion && compareVersions(persistedVersion, sourceVersion) < 0) {
2316
+ warn(
2317
+ "Persisted toolset home (~/.forge)",
2318
+ `v${persistedVersion} — source is v${sourceVersion}. Run: npx forgedock update`,
2319
+ );
2320
+ } else {
2321
+ pass("Persisted toolset home (~/.forge)", `v${persistedVersion || "unknown"} at ${persistedHome}`);
2322
+ }
2323
+ }
2324
+ }
2325
+ } catch (err) {
2326
+ warn("Persisted toolset home (~/.forge)", `Could not verify: ${err.message}`);
2327
+ }
2328
+ }
2329
+
1494
2330
  // ── Check 6: CLAUDE.md legacy managed block (cwd, informational) ──────────
1495
2331
  // The journey-based install never injects a CLAUDE.md block — session
1496
2332
  // context comes from the SessionStart hook (see Check 5). A block's absence
@@ -1499,32 +2335,51 @@ async function doctor() {
1499
2335
  // `npx forgedock uninstall` removes it.
1500
2336
  {
1501
2337
  const claudeMdPath = join(process.cwd(), "CLAUDE.md");
1502
- if (!existsSync(claudeMdPath)) {
1503
- pass(
1504
- "CLAUDE.md legacy block",
1505
- "no CLAUDE.md found — session context comes from the SessionStart hook",
1506
- );
1507
- } else {
2338
+
2339
+ function detectClaudeMdBlock() {
2340
+ if (!existsSync(claudeMdPath)) return { state: "no-file", error: null };
1508
2341
  try {
1509
2342
  const content = readFileSync(claudeMdPath, "utf-8");
1510
- if (
1511
- content.includes(CLAUDE_BLOCK_BEGIN) &&
1512
- content.includes(CLAUDE_BLOCK_END)
1513
- ) {
1514
- warn(
1515
- "CLAUDE.md legacy block",
1516
- `legacy ForgeDock block found in ${claudeMdPath} — uninstall removes it; the SessionStart hook has replaced it`,
1517
- );
1518
- } else {
1519
- pass(
1520
- "CLAUDE.md legacy block",
1521
- "no legacy ForgeDock block — session context comes from the SessionStart hook",
1522
- );
2343
+ if (content.includes(CLAUDE_BLOCK_BEGIN) && content.includes(CLAUDE_BLOCK_END)) {
2344
+ return { state: "has-block", error: null };
1523
2345
  }
2346
+ return { state: "no-block", error: null };
1524
2347
  } catch (err) {
1525
- fail("CLAUDE.md legacy block", `Cannot read CLAUDE.md: ${err.message}`);
2348
+ return { state: "error", error: err.message };
1526
2349
  }
1527
2350
  }
2351
+
2352
+ let claudeMdResult = detectClaudeMdBlock();
2353
+ let claudeMdFixed = false;
2354
+ if (claudeMdResult.state === "has-block" && fix) {
2355
+ if (removeManagedBlock(claudeMdPath) === "removed") {
2356
+ claudeMdFixed = true;
2357
+ claudeMdResult = detectClaudeMdBlock();
2358
+ }
2359
+ }
2360
+
2361
+ if (claudeMdResult.state === "error") {
2362
+ fail("CLAUDE.md legacy block", `Cannot read CLAUDE.md: ${claudeMdResult.error}`);
2363
+ } else if (claudeMdResult.state === "no-file") {
2364
+ pass(
2365
+ "CLAUDE.md legacy block",
2366
+ "no CLAUDE.md found — session context comes from the SessionStart hook",
2367
+ );
2368
+ } else if (claudeMdResult.state === "no-block") {
2369
+ if (claudeMdFixed) {
2370
+ fixed("CLAUDE.md legacy block", `removed legacy ForgeDock block from ${claudeMdPath}`);
2371
+ } else {
2372
+ pass(
2373
+ "CLAUDE.md legacy block",
2374
+ "no legacy ForgeDock block — session context comes from the SessionStart hook",
2375
+ );
2376
+ }
2377
+ } else {
2378
+ warn(
2379
+ "CLAUDE.md legacy block",
2380
+ `legacy ForgeDock block found in ${claudeMdPath} — uninstall removes it; the SessionStart hook has replaced it.${fix ? "" : " Run: npx forgedock doctor --fix"}`,
2381
+ );
2382
+ }
1528
2383
  }
1529
2384
 
1530
2385
  // ── Check 7: Required workflow labels on GitHub repo ───────────────────────
@@ -1578,18 +2433,48 @@ async function doctor() {
1578
2433
  }
1579
2434
 
1580
2435
  if (ghLabelOk) {
1581
- const missingLabels = expectedLabels.filter(
2436
+ let missingLabels = expectedLabels.filter(
1582
2437
  (l) => !existingLabels.includes(l),
1583
2438
  );
1584
- if (missingLabels.length === 0) {
1585
- pass(
1586
- "GitHub workflow labels",
1587
- `all ${expectedLabels.length} workflow labels present on ${forgeOwner}/${forgeRepo}`,
2439
+ let labelsFixed = false;
2440
+
2441
+ if (missingLabels.length > 0 && fix) {
2442
+ console.log(
2443
+ ` ${CYAN}↻${RESET} Bootstrapping ${missingLabels.length} missing workflow label(s) on ${forgeOwner}/${forgeRepo}...`,
1588
2444
  );
2445
+ try {
2446
+ await labelsSetup(`${forgeOwner}/${forgeRepo}`);
2447
+ labelsFixed = true;
2448
+ const recheckOut = execFileSync(
2449
+ "gh",
2450
+ ["label", "list", "-R", `${forgeOwner}/${forgeRepo}`, "--json", "name", "--limit", "200"],
2451
+ { stdio: ["ignore", "pipe", "ignore"], encoding: "utf-8" },
2452
+ );
2453
+ existingLabels = JSON.parse(recheckOut).map((l) => l.name);
2454
+ missingLabels = expectedLabels.filter((l) => !existingLabels.includes(l));
2455
+ } catch {
2456
+ // labelsSetup() already reports its own per-label failures to
2457
+ // stdout; leave missingLabels as computed pre-fix if the
2458
+ // recheck itself fails (e.g. transient network error).
2459
+ }
2460
+ }
2461
+
2462
+ if (missingLabels.length === 0) {
2463
+ if (fix && labelsFixed) {
2464
+ fixed(
2465
+ "GitHub workflow labels",
2466
+ `all ${expectedLabels.length} workflow labels now present on ${forgeOwner}/${forgeRepo}`,
2467
+ );
2468
+ } else {
2469
+ pass(
2470
+ "GitHub workflow labels",
2471
+ `all ${expectedLabels.length} workflow labels present on ${forgeOwner}/${forgeRepo}`,
2472
+ );
2473
+ }
1589
2474
  } else {
1590
2475
  fail(
1591
2476
  "GitHub workflow labels",
1592
- `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(", ")})`,
2477
+ `Run: npx forgedock doctor --fix (or: npx forgedock labels setup --repo ${forgeOwner}/${forgeRepo}) (see bin/labels.json for definitions; missing: ${missingLabels.join(", ")})`,
1593
2478
  );
1594
2479
  }
1595
2480
  }
@@ -1770,8 +2655,26 @@ async function doctor() {
1770
2655
  }
1771
2656
  }
1772
2657
 
2658
+ // ── Check 12: GitHub App / bot token status ─────────────────────────────────
2659
+ // Informational only — never a hard failure. Installing the ForgeDock GitHub
2660
+ // App registers it against the account/org but does not, by itself, mint a
2661
+ // bot token: that requires the app's private key, which only the app owner
2662
+ // holds. Pipeline gh calls always use whatever `gh auth` context is active
2663
+ // locally (personal token unless the operator manually configured a bot
2664
+ // token) — this check exists so that state is surfaced instead of silent.
2665
+ // See docs/CONFIG.md "GitHub App Install" and forge#1890.
2666
+ {
2667
+ warn(
2668
+ "GitHub App / bot token",
2669
+ "pipeline commands use your active `gh auth` (personal token, unless you've manually configured a bot token). Installing the GitHub App alone does not create one — see docs/CONFIG.md \"GitHub App Install\".",
2670
+ );
2671
+ }
2672
+
1773
2673
  // ── Summary ────────────────────────────────────────────────────────────────
1774
2674
  console.log("");
2675
+ if (fix && fixesApplied > 0) {
2676
+ console.log(`${CYAN}${BOLD}${fixesApplied} issue(s) auto-fixed.${RESET}`);
2677
+ }
1775
2678
  if (failures === 0 && warnings === 0) {
1776
2679
  console.log(`${GREEN}${BOLD}All checks passed.${RESET} ForgeDock installation is healthy.`);
1777
2680
  } else if (failures === 0) {
@@ -1780,6 +2683,9 @@ async function doctor() {
1780
2683
  console.log(
1781
2684
  `${RED}${BOLD}${failures} check(s) failed${warnings > 0 ? `, ${warnings} warning(s)` : ""}.${RESET} See fix hints above.`,
1782
2685
  );
2686
+ if (!fix) {
2687
+ console.log(` Some of these may be auto-fixable: run ${CYAN}npx forgedock doctor --fix${RESET}`);
2688
+ }
1783
2689
  }
1784
2690
  console.log("");
1785
2691
 
@@ -1806,6 +2712,7 @@ function help() {
1806
2712
  ["npx forgedock watch [--repo owner/repo]", "Live per-agent orchestration view (Ctrl+C to exit)"],
1807
2713
  ["npx forgedock report [--days 30] [--md] [--json]", "30-day pipeline impact receipts for your repo"],
1808
2714
  ["npx forgedock doctor", "Check installation health"],
2715
+ ["npx forgedock doctor --fix", "Auto-fix deterministic issues (symlinks, hook, labels, legacy block)"],
1809
2716
  ["npx forgedock update", "Pull latest & reinstall"],
1810
2717
  ["npx forgedock uninstall", "Remove commands"],
1811
2718
  ["npx forgedock help", "Show this help"],
@@ -1832,12 +2739,18 @@ function help() {
1832
2739
  *
1833
2740
  * Flags:
1834
2741
  * --dry-run Preview the assembled prompt + tool plan; no API call.
1835
- * --model <id> Override the model (default: claude-sonnet-5 or $FORGEDOCK_MODEL).
2742
+ * --model <id> Override the model (see resolution order below).
1836
2743
  * --max-iterations <n> Bound the tool-use loop (default: 50).
1837
2744
  *
1838
2745
  * The live loop requires ANTHROPIC_API_KEY and the optional @anthropic-ai/sdk
1839
2746
  * dependency. The runtime itself lives in bin/runner.mjs.
1840
2747
  *
2748
+ * Model resolution order (highest precedence first):
2749
+ * 1. --model <id> This flag.
2750
+ * 2. $FORGEDOCK_MODEL Env var, below.
2751
+ * 3. forge.yaml `agents.default_model` Read from the run's cwd, if present.
2752
+ * 4. Hardcoded default "claude-sonnet-5" (bin/runner.mjs DEFAULT_MODEL).
2753
+ *
1841
2754
  * Env:
1842
2755
  * FORGEDOCK_MODEL Default model id when --model is omitted.
1843
2756
  * FORGEDOCK_SHELL Override the shell used by run_bash. Defaults to bash when
@@ -2140,7 +3053,7 @@ const KNOWN_COMMANDS = new Set([
2140
3053
  "install", "init", "enable", "disable", "status", "uninstall", "update",
2141
3054
  "run", "run-issue", "resume-stalled", "demo", "doctor", "watch", "labels", "help", "--help", "-h",
2142
3055
  ]);
2143
- if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash();
3056
+ if (SPLASH_COMMANDS.has(command) || !KNOWN_COMMANDS.has(command)) splash(command);
2144
3057
 
2145
3058
  let exitCode = 0;
2146
3059
  switch (command) {
@@ -2173,9 +3086,25 @@ switch (command) {
2173
3086
  }
2174
3087
  }
2175
3088
  if (existsSync(join(c.cwd, "forge.yaml")) && resolveState(c.cwd) === "managed-active") {
2176
- statusScreen(c);
3089
+ await statusScreen(c);
2177
3090
  } else {
2178
3091
  exitCode = await runJourney(c);
3092
+ // Record persistHome()'s outcome (set on c.persistHomeResult inside
3093
+ // runJourney()) in the registry (forge#1943), mirroring update()'s npm
3094
+ // branch — a single, centrally-updated source of truth for doctor()
3095
+ // rather than each command re-deriving persisted-home state on its own
3096
+ // (forge#1589 split-brain precedent). Best-effort: a failed write here
3097
+ // must never turn a successful install into a failing one.
3098
+ if (c.persistHomeResult && !c.persistHomeResult.skipped) {
3099
+ try {
3100
+ await setPersistedHomeState({
3101
+ path: c.persistHomeResult.forgeHome,
3102
+ version: c.persistHomeResult.version,
3103
+ });
3104
+ } catch {
3105
+ // best-effort — see comment above
3106
+ }
3107
+ }
2179
3108
  }
2180
3109
  break;
2181
3110
  }
@@ -2202,7 +3131,7 @@ switch (command) {
2202
3131
  case "status": {
2203
3132
  const c = ctx();
2204
3133
  if (positional[1]) c.cwd = resolve(positional[1]);
2205
- statusScreen(c);
3134
+ await statusScreen(c);
2206
3135
  break;
2207
3136
  }
2208
3137
  case "uninstall":
@@ -2239,7 +3168,7 @@ switch (command) {
2239
3168
  await demo();
2240
3169
  break;
2241
3170
  case "doctor":
2242
- exitCode = await doctor();
3171
+ exitCode = await doctor(restArgs.includes("--fix"));
2243
3172
  break;
2244
3173
  case "report": {
2245
3174
  const { runReport } = await import("./report.mjs");