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/journey.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * init-detect.mjs, init-enrich-api.mjs, tui.annotatedReviewScreen, registry.mjs
10
10
  */
11
11
 
12
- import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
12
+ import { existsSync, lstatSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
13
13
  import { join, basename } from "path";
14
14
 
15
15
  // ---------------------------------------------------------------------------
@@ -179,14 +179,63 @@ export function manualLowConfidenceKeys(draft, description, values) {
179
179
  * Back up an existing file to <name>.bak (timestamped if .bak exists).
180
180
  * @returns {{ backupName: string } | null} null when the file didn't exist.
181
181
  */
182
+ /**
183
+ * Back up an existing file to <name>.bak, keeping `.bak` as the newest copy.
184
+ *
185
+ * Regression fix (forge#1850): the previous implementation left the bare
186
+ * `.bak` untouched once created, so only the FIRST-ever clobber's content
187
+ * ever lived there — every later overwrite rotated the (by then already
188
+ * stubbed) current file into a timestamped sibling instead. The net effect
189
+ * was that the newest-looking backup (by filename/mtime) was the most
190
+ * stubbed one, while the one recoverable good copy sat forever under the
191
+ * unindicated bare name. A user restoring "the .bak" or "the newest backup"
192
+ * got garbage either way.
193
+ *
194
+ * Fixed behavior: before writing the current file into `.bak`, rotate any
195
+ * existing `.bak` out to a timestamped file first. `.bak` therefore always
196
+ * holds the immediately-prior state (the best next guess for "undo my last
197
+ * run"), and the full chronological history is preserved in timestamped
198
+ * files — nothing is ever silently buried.
199
+ *
200
+ * @returns {{ backupName: string } | null} null when the file didn't exist.
201
+ */
182
202
  export function backupExisting(outputPath) {
183
203
  if (!existsSync(outputPath)) return null;
184
204
  const baseBak = outputPath + ".bak";
185
- const backupPath = existsSync(baseBak)
186
- ? `${baseBak}.${new Date().toISOString().replace(/[:.]/g, "-")}`
187
- : baseBak;
188
- renameSync(outputPath, backupPath);
189
- return { backupName: basename(backupPath) };
205
+ if (existsSync(baseBak)) {
206
+ // Guard against two rotations landing on the same millisecond timestamp
207
+ // (e.g. rapid successive calls in tests, or a fast disk) silently
208
+ // clobbering an already-rotated generation.
209
+ let rotatedPath = `${baseBak}.${new Date().toISOString().replace(/[:.]/g, "-")}`;
210
+ let suffix = 1;
211
+ while (existsSync(rotatedPath)) {
212
+ rotatedPath = `${baseBak}.${new Date().toISOString().replace(/[:.]/g, "-")}-${suffix++}`;
213
+ }
214
+ renameSync(baseBak, rotatedPath);
215
+ }
216
+ renameSync(outputPath, baseBak);
217
+ return { backupName: basename(baseBak) };
218
+ }
219
+
220
+ /**
221
+ * Lightweight forge.yaml presence/shape check for the install receipt
222
+ * (Issue #1946) — no YAML parser dependency, mirrors the regex-based
223
+ * approach already used by resolveLabelsRepo() in bin/forgedock.mjs. Only
224
+ * checks that the three REQUIRED top-level section keys are present; never
225
+ * reads field values or copies file contents into the caller's result.
226
+ * @param {string} cwd
227
+ * @returns {{ present: boolean, validShape: boolean }}
228
+ */
229
+ export function validateForgeYamlShape(cwd) {
230
+ const p = join(cwd, "forge.yaml");
231
+ if (!existsSync(p)) return { present: false, validShape: false };
232
+ try {
233
+ const raw = readFileSync(p, "utf-8");
234
+ const validShape = /^project:/m.test(raw) && /^paths:/m.test(raw) && /^branches:/m.test(raw);
235
+ return { present: true, validShape };
236
+ } catch {
237
+ return { present: true, validShape: false };
238
+ }
190
239
  }
191
240
 
192
241
  // ---------------------------------------------------------------------------
@@ -244,6 +293,7 @@ import {
244
293
  renderMark, ember, shimmer, revealRows, moltenBar, fixCard,
245
294
  colorMode, motionEnabled, CHROME_STOPS, HERO_MARK, COMPACT_MARK, sleep,
246
295
  } from "./cinema.mjs";
296
+ import { detectEnvironment, wslPathToWindows } from "./env-detect.mjs";
247
297
 
248
298
  // ---------------------------------------------------------------------------
249
299
  // URL opener (default openFn implementation — injectable for tests)
@@ -283,6 +333,16 @@ export function makeCtx(overrides = {}) {
283
333
  const nodeVersion = overrides.nodeVersion ?? process.versions.node;
284
334
  const enrichFn = overrides.enrichFn ?? enrich;
285
335
  const openFn = overrides.openFn ?? openUrl;
336
+ const confirmFn = overrides.confirmFn ?? confirm;
337
+ const platform = overrides.platform ?? process.platform;
338
+ let release = overrides.release;
339
+ if (release === undefined) {
340
+ try {
341
+ release = os.release();
342
+ } catch {
343
+ release = "";
344
+ }
345
+ }
286
346
  return {
287
347
  cwd: process.cwd(),
288
348
  home: env.HOME || env.USERPROFILE || os.homedir(),
@@ -293,9 +353,12 @@ export function makeCtx(overrides = {}) {
293
353
  mode: colorMode(env, stdout),
294
354
  motion: motionEnabled(argv, env, stdout),
295
355
  nodeVersion,
356
+ platform,
357
+ release,
296
358
  linkStrategy: "symlink",
297
359
  enrichFn,
298
360
  openFn,
361
+ confirmFn,
299
362
  exec: (cmd, args) =>
300
363
  execFileSync(cmd, args, {
301
364
  encoding: "utf-8",
@@ -315,7 +378,9 @@ const dimLine = (ctx, s) => (ctx.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
315
378
 
316
379
  /**
317
380
  * Render the hero banner and run preflight checks. Failures render fix cards
318
- * and the journey continues — advisory, never fatal.
381
+ * and the journey continues — advisory, never fatal. Includes informational
382
+ * Platform/WSL/Shell rows (via env-detect.mjs) after the pass/fail checks —
383
+ * those three always report `ok: true`, they surface state rather than gate it.
319
384
  * @returns {Promise<{ checks: Array<{name, ok, detail, fix?}>, ghReady: boolean }>}
320
385
  */
321
386
  export async function preflight(ctx) {
@@ -323,7 +388,7 @@ export async function preflight(ctx) {
323
388
  w.write("\n");
324
389
  await shimmer(HERO_MARK, CHROME_STOPS, { mode: ctx.mode, motion: ctx.motion, writer: w });
325
390
  w.write("\n " + ember("F O R G E D O C K", ctx.mode) + "\n");
326
- w.write(" " + dimLine(ctx, "──── lighting the forge ────────────────────") + "\n\n");
391
+ w.write(" " + dimLine(ctx, `──── ${getLogoTagline("install")} ────────────────────`) + "\n\n");
327
392
 
328
393
  const rows = [
329
394
  {
@@ -377,6 +442,31 @@ export async function preflight(ctx) {
377
442
  },
378
443
  ];
379
444
 
445
+ // Environment reveal rows (platform/WSL/shell) — informational only, never
446
+ // fail the preflight. Appended after the existing 4 checks so `named[3]`
447
+ // (GitHub CLI, used for `ghReady` below) keeps its index.
448
+ const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
449
+ rows.push(
450
+ {
451
+ label: "Platform",
452
+ run: async () => {
453
+ const wslNote = envInfo.isWSL ? ` (WSL${envInfo.wslDistro ? `: ${envInfo.wslDistro}` : ""})` : "";
454
+ return { ok: true, detail: `${envInfo.platformLabel}${wslNote} (${envInfo.shell})` };
455
+ },
456
+ },
457
+ {
458
+ label: "WSL",
459
+ run: async () => ({
460
+ ok: true,
461
+ detail: envInfo.isWSL ? envInfo.wslDistro || "detected" : "not detected",
462
+ }),
463
+ },
464
+ {
465
+ label: "Shell",
466
+ run: async () => ({ ok: true, detail: envInfo.shell }),
467
+ },
468
+ );
469
+
380
470
  const results = await revealRows(rows, { mode: ctx.mode, motion: ctx.motion, writer: w });
381
471
  const named = rows.map((r, i) => ({ name: r.label, ...results[i] }));
382
472
  return { checks: named, ghReady: named[3].ok };
@@ -499,6 +589,24 @@ async function saveCopiedManifest(manifestPath, manifest) {
499
589
 
500
590
  const isLinkPermissionError = (err) => err.code === "EPERM" || err.code === "EACCES";
501
591
 
592
+ /**
593
+ * Allowlist of universal pipeline-agent scripts installed to
594
+ * ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
595
+ * forgedock.mjs's uninstall(). This is the single source of truth for both
596
+ * directions — re-export from forgedock.mjs rather than duplicating it.
597
+ *
598
+ * Deliberately narrow (forge#715): project-specific/internal tooling that
599
+ * lives in scripts/ (verify-*.sh, doctor-pipeline-state.sh, gen-logo.mjs,
600
+ * the self-dogfooding *.mjs analysis scripts, etc.) must NOT be copied here
601
+ * — only scripts meant to be invoked generically by command specs without
602
+ * knowing ForgeDock's own install path belong in this set.
603
+ */
604
+ export const PIPELINE_SCRIPTS = new Set([
605
+ "classify-lane.sh",
606
+ "transition-label.sh",
607
+ "validate-pr-target.sh",
608
+ ]);
609
+
502
610
  /**
503
611
  * Recursively walk targetDir and remove any symlink whose target begins with
504
612
  * commandsDir (i.e. a ForgeDock-managed link) but whose target file no longer
@@ -561,6 +669,487 @@ async function pruneOrphanedSymlinks(targetDir, commandsDir) {
561
669
  return pruned;
562
670
  }
563
671
 
672
+ /**
673
+ * Detect whether `p` sits inside a known ephemeral npm/npx/pnpm/yarn cache
674
+ * directory rather than a durable install location (global npm install,
675
+ * local repo clone, or a project's own `node_modules`).
676
+ *
677
+ * Used to warn when `ctx.forgeHome` — and therefore the SessionStart hook
678
+ * script path baked into `~/.claude/settings.json` — resolves somewhere that
679
+ * can be silently pruned later (`npm cache clean`, OS temp cleanup, npx's
680
+ * own eviction), which would break context injection with no error surfaced.
681
+ *
682
+ * Matching is path-SEGMENT based (split on `/` and `\`), never a bare
683
+ * substring match — this keeps the check conservative and avoids false
684
+ * positives on a real install whose path merely contains one of these
685
+ * strings (e.g. a project directory named `npx-utils` or `my-dlx-tool`).
686
+ *
687
+ * Recognized shapes:
688
+ * - npm's npx cache: a `_npx` segment — covers both
689
+ * `~/.npm/_npx/<hash>/node_modules/<pkg>` (POSIX) and
690
+ * `%LocalAppData%\npm-cache\_npx\<hash>\node_modules\<pkg>` (Windows).
691
+ * - pnpm's dlx cache: a `dlx` segment — pnpm's ephemeral `dlx` subdir,
692
+ * distinct from `node_modules/.pnpm/` (pnpm's persistent
693
+ * content-addressable store used by ordinary local installs, which must
694
+ * NOT be flagged here).
695
+ * - yarn (Berry) dlx: a segment matching `/^xfs-/i` — `yarn dlx` builds its
696
+ * throwaway project in a temp directory created via `xfs.mktempPromise()`,
697
+ * which yarn names with an `xfs-` prefix.
698
+ *
699
+ * @param {string} p - Absolute path to test (typically `ctx.forgeHome`).
700
+ * @returns {boolean}
701
+ */
702
+ export function isEphemeralCachePath(p) {
703
+ if (!p || typeof p !== "string") return false;
704
+ const segments = p.split(/[\\/]/).filter(Boolean);
705
+ return segments.some(
706
+ (seg) => seg === "_npx" || seg === "dlx" || /^xfs-/i.test(seg),
707
+ );
708
+ }
709
+
710
+ // ---------------------------------------------------------------------------
711
+ // Act 0/I.5 — Persist Home: copy the toolset into a stable ~/.forge/ (#1943)
712
+ // ---------------------------------------------------------------------------
713
+
714
+ /**
715
+ * The four top-level directories that make up ForgeDock's installable
716
+ * payload. Kept as a single list so persistHome() and its tests agree on
717
+ * exactly what gets copied.
718
+ */
719
+ const PERSIST_HOME_DIRS = ["bin", "commands", "scripts", "templates"];
720
+
721
+ /**
722
+ * Detect whether `dir` is a git working tree — has a `.git` entry at all,
723
+ * regardless of whether it's a directory (ordinary clone) or a file (git
724
+ * worktree, whose `.git` is a pointer file back to the main repo's `.git`
725
+ * dir). Both shapes mean "this is a stable, user-owned git checkout" for
726
+ * persistHome()'s purposes — neither should ever be copied into ~/.forge/.
727
+ *
728
+ * Uses lstatSync (not existsSync) so the check reflects the real entry on
729
+ * disk rather than following symlinks, mirroring the detection *style* of
730
+ * resolveRealForgeHome() in bin/forgedock.mjs (which additionally has to
731
+ * distinguish file-vs-directory to resolve worktrees to their main repo
732
+ * root — a distinction persistHome() doesn't need, since both shapes skip
733
+ * identically here).
734
+ *
735
+ * @param {string} dir
736
+ * @returns {boolean}
737
+ */
738
+ function isGitWorkingTree(dir) {
739
+ try {
740
+ lstatSync(join(dir, ".git"));
741
+ return true;
742
+ } catch {
743
+ return false;
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Recursively copy `srcDir` into `destDir`, content-comparing existing files
749
+ * before overwriting so unchanged bytes are never rewritten (same idempotency
750
+ * discipline as forge()'s command-linking loop and linkPipelineScripts() —
751
+ * forge#1916). A missing `srcDir` is treated as "nothing to copy" rather than
752
+ * an error, since not every ForgeDock release necessarily ships every one of
753
+ * PERSIST_HOME_DIRS.
754
+ *
755
+ * @param {string} srcDir
756
+ * @param {string} destDir
757
+ * @returns {Promise<{ copied: number, unchanged: number }>}
758
+ */
759
+ async function copyDirIfChanged(srcDir, destDir) {
760
+ let entries;
761
+ try {
762
+ entries = await readdir(srcDir, { withFileTypes: true });
763
+ } catch (err) {
764
+ if (err.code === "ENOENT") return { copied: 0, unchanged: 0 };
765
+ throw err;
766
+ }
767
+
768
+ await mkdir(destDir, { recursive: true });
769
+
770
+ let copied = 0;
771
+ let unchanged = 0;
772
+
773
+ for (const entry of entries) {
774
+ const srcPath = join(srcDir, entry.name);
775
+ const destPath = join(destDir, entry.name);
776
+
777
+ if (entry.isDirectory()) {
778
+ const sub = await copyDirIfChanged(srcPath, destPath);
779
+ copied += sub.copied;
780
+ unchanged += sub.unchanged;
781
+ continue;
782
+ }
783
+ if (!entry.isFile()) continue; // symlinks/sockets/etc. — not expected in this payload
784
+
785
+ let needsCopy = true;
786
+ try {
787
+ const [src, dst] = await Promise.all([readFile(srcPath), readFile(destPath)]);
788
+ if (src.equals(dst)) needsCopy = false;
789
+ } catch (err) {
790
+ if (err.code !== "ENOENT") throw err;
791
+ // destPath missing — needs copy, needsCopy stays true.
792
+ }
793
+
794
+ if (needsCopy) {
795
+ await copyFile(srcPath, destPath);
796
+ copied++;
797
+ } else {
798
+ unchanged++;
799
+ }
800
+ }
801
+
802
+ return { copied, unchanged };
803
+ }
804
+
805
+ /**
806
+ * Copy ForgeDock's own installable payload (bin/, commands/, scripts/,
807
+ * templates/) from wherever the package currently resolved — npm global
808
+ * install, npx/dlx cache, or any other non-git extraction — into a stable
809
+ * `{ctx.home}/.forge/` home, and point `ctx.forgeHome` at it for the rest of
810
+ * the journey. This is what makes `~/.claude/commands/` symlinks and the
811
+ * SessionStart hook's baked-in script path survive npm/npx cache eviction
812
+ * (issue #1943) — before this, both were built directly from the ephemeral
813
+ * source location and broke silently once that cache was pruned.
814
+ *
815
+ * Skipped entirely when `ctx.forgeHome` is a git working tree (see
816
+ * isGitWorkingTree() above): a git clone (or worktree) is already a stable,
817
+ * user-owned location. Copying it into ~/.forge/ would silently disconnect
818
+ * `git pull`/`npx forgedock update` from what's actually linked into
819
+ * ~/.claude/commands/ — the regression issue #1943's Acceptance Criteria #5
820
+ * explicitly calls out.
821
+ *
822
+ * Content-compares before overwriting (see copyDirIfChanged() above) so
823
+ * steady-state re-runs (every `npx forgedock` invocation) don't rewrite
824
+ * byte-identical files.
825
+ *
826
+ * IMPORTANT: consumes `ctx.forgeHome` exactly as already resolved by the
827
+ * caller — it must never re-derive its own "where does ForgeDock actually
828
+ * live" path. Re-deriving risks reintroducing the worktree-leakage
829
+ * regression fixed by resolveRealForgeHome() (forge#1700): a worktree-scoped
830
+ * FORGE_HOME baked into a persisted copy would dangle once the worktree is
831
+ * removed.
832
+ *
833
+ * Fail-open: any filesystem error (permission denied, disk full, etc.) is
834
+ * caught and reported via the returned `skipped`/`reason` fields rather than
835
+ * thrown. Callers must treat a `skipped: true` result as "fall back to the
836
+ * original ctx.forgeHome" — the pre-existing ephemeral-FORGE_HOME behavior.
837
+ *
838
+ * @param {{ forgeHome: string, home: string }} ctx
839
+ * @returns {Promise<{
840
+ * forgeHome: string,
841
+ * migrated: boolean,
842
+ * skipped: boolean,
843
+ * reason?: string,
844
+ * version: string,
845
+ * filesCopied?: number,
846
+ * filesUnchanged?: number,
847
+ * }>}
848
+ */
849
+ export async function persistHome(ctx) {
850
+ const source = ctx.forgeHome;
851
+ const persistedHome = join(ctx.home, ".forge");
852
+
853
+ if (isGitWorkingTree(source)) {
854
+ return {
855
+ forgeHome: source,
856
+ migrated: false,
857
+ skipped: true,
858
+ reason: "git working tree — linked directly from the clone, not persisted",
859
+ version: "",
860
+ };
861
+ }
862
+
863
+ // Read the source package's version up front — best-effort, never fatal.
864
+ // A missing/unreadable package.json degrades to an empty version string
865
+ // rather than aborting the whole persist step.
866
+ let version = "";
867
+ try {
868
+ const pkg = JSON.parse(readFileSync(join(source, "package.json"), "utf-8"));
869
+ version = pkg.version || "";
870
+ } catch {
871
+ // proceed with version === ""
872
+ }
873
+
874
+ try {
875
+ await mkdir(persistedHome, { recursive: true });
876
+
877
+ let filesCopied = 0;
878
+ let filesUnchanged = 0;
879
+ for (const name of PERSIST_HOME_DIRS) {
880
+ const res = await copyDirIfChanged(join(source, name), join(persistedHome, name));
881
+ filesCopied += res.copied;
882
+ filesUnchanged += res.unchanged;
883
+ }
884
+
885
+ // Also persist package.json itself (not just PERSIST_HOME_DIRS) — several
886
+ // callers read `{forgeHome}/package.json` directly (readForgedockVersion()
887
+ // in this file, used by writeInstallReceipt() — forge#1946) and expect it
888
+ // to resolve relative to whatever ctx.forgeHome currently points at. Once
889
+ // this function reassigns ctx.forgeHome to the persisted copy, those
890
+ // callers would otherwise find no package.json there and silently degrade
891
+ // to an empty version string. Copying it keeps ~/.forge a complete
892
+ // drop-in stand-in for the original forgeHome, not just a commands/hooks
893
+ // mirror. Missing source package.json (unusual layout) is a no-op, same
894
+ // as any other PERSIST_HOME_DIRS entry.
895
+ try {
896
+ const [src, dst] = await Promise.all([
897
+ readFile(join(source, "package.json")),
898
+ readFile(join(persistedHome, "package.json")).catch(() => null),
899
+ ]);
900
+ if (!dst || !src.equals(dst)) {
901
+ await copyFile(join(source, "package.json"), join(persistedHome, "package.json"));
902
+ filesCopied++;
903
+ } else {
904
+ filesUnchanged++;
905
+ }
906
+ } catch (err) {
907
+ if (err.code !== "ENOENT") throw err;
908
+ // source package.json missing — nothing to persist, not an error.
909
+ }
910
+
911
+ // Write ~/.forge/version — content-compared like everything else here so
912
+ // an unchanged version doesn't touch the file's mtime on every re-run.
913
+ const versionPath = join(persistedHome, "version");
914
+ let versionChanged = true;
915
+ try {
916
+ versionChanged = readFileSync(versionPath, "utf-8").trim() !== version;
917
+ } catch {
918
+ // missing/unreadable — treat as changed
919
+ }
920
+ if (versionChanged) {
921
+ const tmpVersionPath = versionPath + ".tmp";
922
+ try {
923
+ writeFileSync(tmpVersionPath, version + "\n", "utf-8");
924
+ renameSync(tmpVersionPath, versionPath);
925
+ } catch (err) {
926
+ try { unlinkSync(tmpVersionPath); } catch { /* best-effort cleanup */ }
927
+ throw err;
928
+ }
929
+ }
930
+
931
+ return {
932
+ forgeHome: persistedHome,
933
+ migrated: filesCopied > 0 || versionChanged,
934
+ skipped: false,
935
+ version,
936
+ filesCopied,
937
+ filesUnchanged,
938
+ };
939
+ } catch (err) {
940
+ // Fail-open (forge#383): a permission error, disk-full, etc. must never
941
+ // abort install/update — fall back to the original, un-persisted forgeHome.
942
+ return {
943
+ forgeHome: source,
944
+ migrated: false,
945
+ skipped: true,
946
+ reason: `error: ${err && err.message ? err.message : String(err)}`,
947
+ version,
948
+ };
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Link the PIPELINE_SCRIPTS allowlist from {forgeHome}/scripts/ into
954
+ * ~/.claude/scripts/, using the same symlink-first / copy-fallback strategy
955
+ * as command installation (isLinkPermissionError-gated). Flat set, no
956
+ * subdirectories, no ownership manifest needed — three well-known filenames.
957
+ *
958
+ * Restores the linkScripts() step (commit 9bf382a, forge#677) that was
959
+ * silently dropped when install moved from the legacy install()/update()
960
+ * flow to this journey-based forge() (forge#1885).
961
+ *
962
+ * @param {{forgeHome: string, home: string, linkStrategy?: string}} ctx
963
+ * @returns {Promise<{installed: number, updated: number, skipped: number, copied: number, total: number}>}
964
+ */
965
+ async function linkPipelineScripts(ctx) {
966
+ const scriptsSourceDir = join(ctx.forgeHome, "scripts");
967
+ const scriptsTargetDir = join(ctx.home, ".claude", "scripts");
968
+ const wantSymlink = ctx.linkStrategy !== "copy";
969
+
970
+ await mkdir(scriptsTargetDir, { recursive: true });
971
+
972
+ let installed = 0, updated = 0, skipped = 0, copied = 0;
973
+
974
+ for (const name of PIPELINE_SCRIPTS) {
975
+ const file = join(scriptsSourceDir, name);
976
+ if (!existsSync(file)) continue; // source missing (unusual package layout) — skip silently
977
+
978
+ const target = join(scriptsTargetDir, name);
979
+ let existed = false;
980
+ let alreadyCorrect = false;
981
+
982
+ try {
983
+ const stats = await lstat(target);
984
+ existed = true;
985
+ if (stats.isSymbolicLink()) {
986
+ const current = await readlink(target);
987
+ if (current === file) alreadyCorrect = true;
988
+ } else if (stats.isFile() && !wantSymlink) {
989
+ // Copy-fallback path (Windows without Developer Mode): content-compare
990
+ // before unlinking/recopying, matching the sibling linkCommands() loop
991
+ // and the legacy linkScripts() this restores (forge#1916).
992
+ const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
993
+ if (src.equals(dst)) alreadyCorrect = true;
994
+ }
995
+ } catch (err) {
996
+ if (err.code !== "ENOENT") throw err;
997
+ }
998
+
999
+ if (alreadyCorrect) {
1000
+ skipped++;
1001
+ continue;
1002
+ }
1003
+
1004
+ if (existed) {
1005
+ await unlink(target).catch((err) => {
1006
+ if (err.code !== "ENOENT") throw err;
1007
+ });
1008
+ }
1009
+
1010
+ let linked = false;
1011
+ if (wantSymlink) {
1012
+ try {
1013
+ await symlink(file, target);
1014
+ linked = true;
1015
+ } catch (linkErr) {
1016
+ if (!isLinkPermissionError(linkErr)) throw linkErr;
1017
+ }
1018
+ }
1019
+ if (!linked) {
1020
+ await copyFile(file, target);
1021
+ copied++;
1022
+ } else if (existed) {
1023
+ updated++;
1024
+ } else {
1025
+ installed++;
1026
+ }
1027
+ }
1028
+
1029
+ return { installed, updated, skipped, copied, total: PIPELINE_SCRIPTS.size };
1030
+ }
1031
+
1032
+ // Windows -> WSL UNC probing in detectCrossEnvInstall() below hits a real
1033
+ // blocking syscall per distro/root: fs.existsSync() on a stopped WSL
1034
+ // distro's UNC path triggers Windows to synchronously spin up that distro's
1035
+ // VM before the stat resolves, which can take multiple seconds. Bound both
1036
+ // how many distros get probed and how long the loop is willing to keep
1037
+ // probing before giving up (forge#1917).
1038
+ const CROSS_ENV_PROBE_MAX_DISTROS = 5;
1039
+ const CROSS_ENV_PROBE_BUDGET_MS = 3000;
1040
+
1041
+ /**
1042
+ * Detect whether this repo is also installed from the "other" environment —
1043
+ * WSL vs native Windows — for the SAME physical repo (forge#1893).
1044
+ *
1045
+ * ForgeDock installs are always global (`~/.claude` — see #1589's split-brain
1046
+ * finding: there is no per-repo install state), so there is no per-repo
1047
+ * install record to compare directly. Instead: when the repo itself is
1048
+ * reachable from both sides, the *other* environment's home directory is
1049
+ * directly derivable, and its `.claude/forgedock` manifest dir (written by
1050
+ * `forge()` below) tells us whether ForgeDock has ever run there.
1051
+ *
1052
+ * Two directions:
1053
+ * - WSL → Windows: well-defined. If `ctx.cwd` is under
1054
+ * `/mnt/<drive>/Users/<user>/...`, that's a live bind mount onto the
1055
+ * Windows drive — the Windows home for that same user is
1056
+ * `/mnt/<drive>/Users/<user>`, directly checkable through the mount, no
1057
+ * cross-OS trickery needed.
1058
+ * - Windows → WSL: best-effort. If `ctx.cwd` is under
1059
+ * `<drive>:\Users\<user>\...`, enumerate installed WSL distros
1060
+ * (`wsl -l -q`) and probe each one's
1061
+ * `\\wsl.localhost\<distro>\home\<user>\.claude\forgedock` (falling back
1062
+ * to the older `\\wsl$\<distro>\...` UNC root), guessing the Linux
1063
+ * username equals the Windows username. A wrong guess only produces a
1064
+ * false NEGATIVE (silently skips a real conflict) — never a false
1065
+ * positive, matching this feature's "no false positives" requirement.
1066
+ *
1067
+ * Never throws — any failure (WSL not installed, `wsl -l -q` unavailable,
1068
+ * an inaccessible/slow UNC path, or a `cwd` that doesn't match either shape)
1069
+ * degrades to "no conflict". The Windows -> WSL probe loop is additionally
1070
+ * bounded by `CROSS_ENV_PROBE_MAX_DISTROS` and `CROSS_ENV_PROBE_BUDGET_MS` —
1071
+ * exceeding either bound also degrades to "no conflict" rather than
1072
+ * continuing to block (forge#1917).
1073
+ *
1074
+ * @param {{ cwd: string, exec: (cmd: string, args: string[]) => string }} ctx
1075
+ * @param {import('./env-detect.mjs').EnvironmentInfo} envInfo
1076
+ * @param {{ existsSyncFn?: (p: string) => boolean, nowFn?: () => number }} [deps] - inject
1077
+ * for tests; `existsSyncFn` defaults to the real `fs.existsSync`, `nowFn` defaults to
1078
+ * `Date.now`.
1079
+ * @returns {{ conflict: boolean, otherPath: string | null, direction: "windows" | "wsl" | null }}
1080
+ */
1081
+ export function detectCrossEnvInstall(ctx, envInfo, deps = {}) {
1082
+ const existsSyncFn = deps.existsSyncFn ?? existsSync;
1083
+ const nowFn = deps.nowFn ?? Date.now;
1084
+ const none = { conflict: false, otherPath: null, direction: null };
1085
+
1086
+ try {
1087
+ const cwd = String(ctx.cwd ?? "");
1088
+
1089
+ if (envInfo.isWSL) {
1090
+ const m = cwd.match(/^(\/mnt\/[a-z])\/Users\/([^/]+)/i);
1091
+ if (!m) return none;
1092
+ const windowsHomeOnMount = `${m[1]}/Users/${m[2]}`;
1093
+ const checkPath = `${windowsHomeOnMount}/.claude/forgedock`;
1094
+ if (existsSyncFn(checkPath)) {
1095
+ return { conflict: true, otherPath: wslPathToWindows(checkPath) ?? checkPath, direction: "windows" };
1096
+ }
1097
+ return none;
1098
+ }
1099
+
1100
+ if (envInfo.platform === "win32") {
1101
+ const m = cwd.match(/^([a-zA-Z]):[\\/]Users[\\/]([^\\/]+)/i);
1102
+ if (!m) return none;
1103
+ const user = m[2];
1104
+
1105
+ let distros = [];
1106
+ try {
1107
+ const raw = ctx.exec("wsl", ["-l", "-q"]);
1108
+ // `wsl -l -q` prints its distro list as UTF-16LE. `ctx.exec` decodes
1109
+ // process output as UTF-8, so ASCII characters come back interleaved
1110
+ // with NUL bytes (e.g. "Ubuntu" -> "U\0b\0u\0n\0t\0u\0"). Strip them
1111
+ // before splitting into lines.
1112
+ distros = raw
1113
+ .replace(/\0/g, "")
1114
+ .split(/\r?\n/)
1115
+ .map((s) => s.trim())
1116
+ .filter(Boolean);
1117
+ } catch {
1118
+ return none; // WSL not installed, or `wsl` isn't on PATH — no conflict possible.
1119
+ }
1120
+
1121
+ // Bound worst-case blocking time: cap how many distros get probed, and
1122
+ // stop probing once the wall-clock budget is exhausted. Each existsSync
1123
+ // call below is a real blocking syscall — a stopped WSL distro auto-
1124
+ // starts its VM synchronously to service the UNC stat, which can take
1125
+ // multiple seconds. Checking the deadline before every probe (not just
1126
+ // once per distro) stops the delay from compounding once the budget
1127
+ // is gone; it can't preempt a single already-in-flight call, but it
1128
+ // does bound the total across distros/roots (forge#1917).
1129
+ const probeDeadline = nowFn() + CROSS_ENV_PROBE_BUDGET_MS;
1130
+ for (const distro of distros.slice(0, CROSS_ENV_PROBE_MAX_DISTROS)) {
1131
+ if (nowFn() > probeDeadline) break;
1132
+ for (const root of [`\\\\wsl.localhost\\${distro}`, `\\\\wsl$\\${distro}`]) {
1133
+ if (nowFn() > probeDeadline) break;
1134
+ const checkPath = `${root}\\home\\${user}\\.claude\\forgedock`;
1135
+ try {
1136
+ if (existsSyncFn(checkPath)) {
1137
+ return { conflict: true, otherPath: checkPath, direction: "wsl" };
1138
+ }
1139
+ } catch {
1140
+ // Inaccessible/unresponsive UNC path — treat as not found, try the next one.
1141
+ }
1142
+ }
1143
+ }
1144
+ return none;
1145
+ }
1146
+
1147
+ return none;
1148
+ } catch {
1149
+ return none;
1150
+ }
1151
+ }
1152
+
564
1153
  /**
565
1154
  * Act II: link commands into ~/.claude/commands with a molten progress line,
566
1155
  * then register the SessionStart hook.
@@ -706,6 +1295,9 @@ export async function forge(ctx) {
706
1295
  // target file no longer exists (e.g. after a command is renamed or deleted).
707
1296
  const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
708
1297
 
1298
+ // Link the small set of universal pipeline-agent scripts (forge#1885).
1299
+ const scriptsResult = await linkPipelineScripts(ctx);
1300
+
709
1301
  const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
710
1302
  const settingsPath = join(ctx.home, ".claude", "settings.json");
711
1303
  const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
@@ -749,6 +1341,13 @@ export async function forge(ctx) {
749
1341
  if (copied > 0) {
750
1342
  w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
751
1343
  }
1344
+ if (scriptsResult.total > 0) {
1345
+ const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
1346
+ const scriptsDetail = scriptsChanged > 0
1347
+ ? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
1348
+ : `(unchanged ${scriptsResult.skipped})`;
1349
+ w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
1350
+ }
752
1351
  if (manifestSaveFailed) {
753
1352
  w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
754
1353
  }
@@ -759,6 +1358,60 @@ export async function forge(ctx) {
759
1358
  w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
760
1359
  }
761
1360
 
1361
+ // Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
1362
+ // runJourney() calls persistHome(ctx) right before forge() and stashes its
1363
+ // result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
1364
+ // wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
1365
+ // stable ~/.forge/ copy, so report that outcome instead of the raw
1366
+ // ephemeral-cache warning. Direct forge() callers that never set
1367
+ // ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
1368
+ // through to the original ephemeral-cache-only check unchanged.
1369
+ const persistResult = ctx.persistHomeResult;
1370
+ if (persistResult && !persistResult.skipped) {
1371
+ if (persistResult.migrated) {
1372
+ w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
1373
+ } else {
1374
+ w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
1375
+ }
1376
+ } else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
1377
+ // Skipped for a reason OTHER than "still ephemeral" — today that's always
1378
+ // the git-working-tree exemption (Acceptance Criteria #5). Informational,
1379
+ // not a warning: this is the expected, correct state for a git-clone install.
1380
+ w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
1381
+ } else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
1382
+ // Either persistHome() was never run (no ctx.persistHomeResult), or it
1383
+ // ran but failed and left ctx.forgeHome pointing at the original
1384
+ // ephemeral source (fail-open — see persistHome()'s error branch).
1385
+ w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
1386
+ w.write(fixCard([
1387
+ "This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
1388
+ "or OS temp cleanup. If that happens, the SessionStart hook silently stops",
1389
+ "injecting ForgeDock context — no error is shown.",
1390
+ "Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
1391
+ ], ctx.mode, "warning") + "\n");
1392
+ }
1393
+
1394
+ // Cross-environment install advisory (forge#1893): warn when this repo is
1395
+ // reachable from both WSL and native Windows and BOTH sides already have a
1396
+ // ForgeDock install. Each environment's install is fully independent (see
1397
+ // #1589 — installs are always global, never per-repo), so running from
1398
+ // both silently leaves forge.yaml/hook paths pointing at whichever side ran
1399
+ // most recently, clobbering the other's config with no error surfaced.
1400
+ // Advisory only — never fails the install.
1401
+ const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
1402
+ const crossEnv = detectCrossEnvInstall(ctx, envInfo);
1403
+ if (crossEnv.conflict) {
1404
+ const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
1405
+ w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
1406
+ w.write(fixCard([
1407
+ `This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
1408
+ "install already exists. Each side's install is independent — running",
1409
+ "forgedock from both can leave forge.yaml and hook paths pointing at",
1410
+ "whichever environment ran last, silently overwriting the other's config.",
1411
+ "Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
1412
+ ], ctx.mode, "warning") + "\n");
1413
+ }
1414
+
762
1415
  // Report enforcement hook status.
763
1416
  if (preToolUseStatus !== null) {
764
1417
  w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
@@ -775,7 +1428,7 @@ export async function forge(ctx) {
775
1428
  w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
776
1429
  }
777
1430
 
778
- return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
1431
+ return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
779
1432
  }
780
1433
 
781
1434
  // ---------------------------------------------------------------------------
@@ -784,7 +1437,7 @@ export async function forge(ctx) {
784
1437
 
785
1438
  import { detectConfig } from "./init-detect.mjs";
786
1439
  import { enrich } from "./init-enrich-api.mjs";
787
- import { annotatedReviewScreen, box, confirm } from "./tui.mjs";
1440
+ import { annotatedReviewScreen, box, confirm, getLogoTagline } from "./tui.mjs";
788
1441
 
789
1442
  const badgeOf = (field) => field.confidence;
790
1443
 
@@ -837,6 +1490,13 @@ export async function read(ctx) {
837
1490
  * Act IV: the single interaction. Non-TTY + existing config aborts to protect
838
1491
  * the file; non-TTY + fresh config writes detection values with TODO flags
839
1492
  * (annotatedReviewScreen's non-TTY path returns them directly).
1493
+ *
1494
+ * Overwrite protection (forge#1850, regression of #578): a TTY session with
1495
+ * an existing forge.yaml must give EXPLICIT default-No consent before any
1496
+ * backup or write happens — the review screen's own "Overwrite Mode" banner
1497
+ * is advisory, not consent. This restores the #578 gate via ctx.confirmFn
1498
+ * (defaults to tui.mjs's confirm(), which is non-TTY-safe on its own —
1499
+ * belt-and-suspenders with the explicit isTTY check above it).
840
1500
  */
841
1501
  export async function review(ctx, draft, description) {
842
1502
  const { stdout: w } = ctx;
@@ -849,6 +1509,17 @@ export async function review(ctx, draft, description) {
849
1509
  return { written: false, todoCount: 0, backupName: null, aborted: true };
850
1510
  }
851
1511
 
1512
+ if (hasExisting) {
1513
+ const confirmed = await ctx.confirmFn(
1514
+ "forge.yaml already exists. Overwrite it? A backup will be created.",
1515
+ false,
1516
+ );
1517
+ if (!confirmed) {
1518
+ w.write("\n Overwrite cancelled — forge.yaml left untouched.\n");
1519
+ return { written: false, todoCount: 0, backupName: null, aborted: true };
1520
+ }
1521
+ }
1522
+
852
1523
  const extraFields = description.value
853
1524
  ? { description: { value: description.value, confidence: "medium", source: description.source, why: `First paragraph of ${description.source}` } }
854
1525
  : {};
@@ -859,6 +1530,19 @@ export async function review(ctx, draft, description) {
859
1530
  extraFields,
860
1531
  });
861
1532
 
1533
+ // Hard-fail on unresolved identity placeholders when OVERWRITING an
1534
+ // existing config (forge#1850 spec item 4): never destroy a working
1535
+ // forge.yaml in favor of one that can't address a repo. A brand-new
1536
+ // (non-existing) config is allowed to write placeholders — that's the
1537
+ // existing, already-flagged (# TODO comments) greenfield-setup path, and
1538
+ // there's nothing valuable to lose. Checked AFTER the review screen so an
1539
+ // interactive edit that fixes the field still passes.
1540
+ if (hasExisting && (accepted.owner === "your-github-org" || accepted.repo === "your-repo-name")) {
1541
+ w.write("\n Could not determine the GitHub owner/repo for this project.\n");
1542
+ w.write(" " + dimLine(ctx, "Run this inside the target git repository, or edit those fields before accepting.") + "\n");
1543
+ return { written: false, todoCount: 0, backupName: null, aborted: true };
1544
+ }
1545
+
862
1546
  const backup = backupExisting(outputPath);
863
1547
  if (backup) w.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
864
1548
 
@@ -894,6 +1578,9 @@ export function celebrate(ctx, summary) {
894
1578
  const hookNote = summary.hookStatus === "skipped-malformed" ? "hook NOT active — see fix above" : "Claude Code knows this repo";
895
1579
  w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
896
1580
  }
1581
+ if (summary.scriptsResult && summary.scriptsResult.total > 0) {
1582
+ w.write(` ${glyph} ${summary.scriptsResult.total} pipeline scripts ${dimLine(ctx, "~/.claude/scripts — classify-lane, transition-label, validate-pr-target")}\n`);
1583
+ }
897
1584
  w.write("\n");
898
1585
  if (summary.isMinimal) {
899
1586
  w.write(" " + dimLine(ctx, "see docs/CONFIG.md for optional sections") + "\n");
@@ -906,10 +1593,13 @@ export function celebrate(ctx, summary) {
906
1593
  ` 3. run npx forgedock doctor — verify the install is green`,
907
1594
  ` 4. open claude in this repo`,
908
1595
  ` 5. run /issue <title> then /work-on <number>`,
1596
+ ` 6. run npx forgedock run-issue <N> — drive an issue via the durable engine`,
1597
+ ` 7. run npx forgedock watch — monitor pipeline state`,
909
1598
  ],
910
1599
  { title: "what's next" },
911
1600
  ),
912
1601
  );
1602
+ w.write(" " + dimLine(ctx, "not for you? npx forgedock uninstall removes everything · full command list: npx forgedock help") + "\n");
913
1603
  w.write(" " + dimLine(ctx, "docs: github.com/RapierCraftStudios/ForgeDock · ⭐ a star is the whole marketing budget") + "\n\n");
914
1604
  }
915
1605
 
@@ -925,8 +1615,18 @@ const GITHUB_APP_URL = "https://github.com/apps/rapiercraft-forgedock/installati
925
1615
  * the journey without error.
926
1616
  *
927
1617
  * Uses ctx.openFn (injectable for tests) to open the URL in the default
928
- * browser. In non-TTY environments (piped stdin, --fast) confirm() resolves
929
- * immediately to false (the defaultValue), so no prompt is shown.
1618
+ * browser, and ctx.confirmFn (injectable for tests) to ask for consent. In
1619
+ * non-TTY environments (piped stdin, --fast) confirm() resolves immediately
1620
+ * to false (the defaultValue), so no prompt is shown.
1621
+ *
1622
+ * IMPORTANT: installing the app today only registers it against the
1623
+ * account/org — it does NOT mint a bot token, does NOT auto-refresh
1624
+ * anything, and does NOT change which `gh` auth pipeline commands use.
1625
+ * Minting an installation token requires the app's private key, which only
1626
+ * RapierCraft Studios (the app owner) holds — an end user's installation
1627
+ * cannot self-serve a token without a hosted minting backend, which does
1628
+ * not exist yet (forge#1890). Keep this prompt's copy honest about that
1629
+ * until such a backend ships — see docs/CONFIG.md "GitHub App Install".
930
1630
  *
931
1631
  * @returns {Promise<{ opened: boolean }>}
932
1632
  */
@@ -934,8 +1634,8 @@ export async function connect(ctx) {
934
1634
  const { stdout: w } = ctx;
935
1635
  w.write("\n " + ember("Connect to GitHub", ctx.mode) + "\n\n");
936
1636
 
937
- const yes = await confirm(
938
- "Install the ForgeDock GitHub App for automatic pipeline triggers?",
1637
+ const yes = await ctx.confirmFn(
1638
+ "Install the ForgeDock GitHub App on this account/org?",
939
1639
  false,
940
1640
  );
941
1641
 
@@ -943,7 +1643,8 @@ export async function connect(ctx) {
943
1643
  ctx.openFn(GITHUB_APP_URL);
944
1644
  w.write(
945
1645
  ` \x1b[38;2;255;179;71m✔\x1b[0m Opening ${GITHUB_APP_URL}\n` +
946
- ` ${ctx.mode === "none" ? "" : "\x1b[2m"}Install account-wide (all repositories) to enable webhook triggers.\x1b[22m\n`,
1646
+ ` ${ctx.mode === "none" ? "" : "\x1b[2m"}This registers the app only — it does not create a bot token yet.\x1b[22m\n` +
1647
+ ` ${ctx.mode === "none" ? "" : "\x1b[2m"}Pipeline commands keep using your personal \`gh\` auth. See docs/CONFIG.md.\x1b[22m\n`,
947
1648
  );
948
1649
  return { opened: true };
949
1650
  }
@@ -952,6 +1653,193 @@ export async function connect(ctx) {
952
1653
  return { opened: false };
953
1654
  }
954
1655
 
1656
+ // ---------------------------------------------------------------------------
1657
+ // Install Receipt (Issue #1946) — machine-readable record of what an
1658
+ // install/update actually did, so drift debugging can read a receipt instead
1659
+ // of re-deriving state from scratch.
1660
+ // ---------------------------------------------------------------------------
1661
+
1662
+ /**
1663
+ * Read the installed forgedock package version from {forgeHome}/package.json.
1664
+ * Best-effort — returns "" on any read/parse failure. Duplicated (rather than
1665
+ * imported) from bin/forgedock.mjs's getVersion() to avoid a circular import:
1666
+ * forgedock.mjs already imports from journey.mjs.
1667
+ * @param {string} forgeHome
1668
+ * @returns {string}
1669
+ */
1670
+ function readForgedockVersion(forgeHome) {
1671
+ try {
1672
+ const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
1673
+ return typeof pkg.version === "string" ? pkg.version : "";
1674
+ } catch {
1675
+ return "";
1676
+ }
1677
+ }
1678
+
1679
+ /**
1680
+ * Write a machine-readable install-receipt.json to {ctx.home}/.forge/ after a
1681
+ * successful install (runJourney) or update (bin/forgedock.mjs's
1682
+ * relinkAndHint — shared by both update() branches: the git-clone
1683
+ * fast-forward path and the npm version-check path). Note: re-running
1684
+ * `npx forgedock install` on an already-managed-active repo takes the
1685
+ * statusScreen() short-circuit instead of runJourney()/relinkAndHint() — that
1686
+ * path does not refresh the receipt (it also does not touch forge() or
1687
+ * anything else, so this is consistent with the rest of that short-circuit's
1688
+ * no-op behavior, not a gap specific to this feature). See docs/CONFIG.md
1689
+ * "Install Receipt" for the schema.
1690
+ *
1691
+ * Deliberately narrow field set — no PII/secrets: no process.env values, no
1692
+ * GitHub tokens, no forge.yaml file contents (only a presence/shape boolean
1693
+ * from validateForgeYamlShape()). Absolute paths (forgeHome/cwd) ARE included
1694
+ * for drift debugging; they are not secrets, matching existing precedent
1695
+ * (ctx.cwd/ctx.home already appear in on-disk state such as registry.json's
1696
+ * path-keyed entries).
1697
+ *
1698
+ * Never throws: any failure (permission denied, disk full, malformed
1699
+ * forgeHome) degrades to a silent no-op, matching every other housekeeping
1700
+ * step in forge() (manifest save, hook install) — a receipt write must never
1701
+ * fail the install/update it merely records.
1702
+ *
1703
+ * @param {object} ctx - Journey context (forgeHome, home, cwd, platform, env,
1704
+ * release, includeExtras — see makeCtx()).
1705
+ * @param {{ forged?: Awaited<ReturnType<typeof forge>> }} summary
1706
+ * `forged` is forge()'s return value (hook statuses, script results). The
1707
+ * caller does not need to pass `reviewed` — forgeYaml status is recomputed
1708
+ * independently via validateForgeYamlShape() so this works identically
1709
+ * whether called from runJourney() (after Act III/IV) or relinkAndHint()
1710
+ * (which never reaches Act III/IV).
1711
+ * @returns {Promise<{ written: boolean, path: string }>}
1712
+ */
1713
+ export async function writeInstallReceipt(ctx, summary = {}) {
1714
+ // Computed inside the try block (not before it): ctx.home is expected to
1715
+ // always be a string in production (makeCtx()/ctx() both default it via
1716
+ // env.HOME || env.USERPROFILE || os.homedir()), but this function's own
1717
+ // contract is "never throws" — join() on a malformed ctx.home must degrade
1718
+ // to written:false, not escape uncaught to the CLI entrypoint.
1719
+ let receiptPath = join(os.homedir(), ".forge", "install-receipt.json");
1720
+ try {
1721
+ receiptPath = join(ctx.home, ".forge", "install-receipt.json");
1722
+ const { forged = {} } = summary;
1723
+ const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
1724
+ const installMode = existsSync(join(ctx.forgeHome, ".git")) ? "git-clone" : "npm";
1725
+ const tier = ctx.includeExtras ? "extras" : "core";
1726
+
1727
+ const commandsDir = join(ctx.forgeHome, "commands");
1728
+ let commands = [];
1729
+ if (existsSync(commandsDir)) {
1730
+ try {
1731
+ const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
1732
+ commands = files.map((f) => relative(commandsDir, f).replace(/\.md$/, "").replace(/\\/g, "/"));
1733
+ } catch {
1734
+ commands = [];
1735
+ }
1736
+ }
1737
+
1738
+ const receipt = {
1739
+ schemaVersion: 1,
1740
+ timestamp: new Date().toISOString(),
1741
+ forgedockVersion: readForgedockVersion(ctx.forgeHome),
1742
+ installMode,
1743
+ forgeHome: ctx.forgeHome,
1744
+ cwd: ctx.cwd,
1745
+ platform: {
1746
+ platform: envInfo.platform,
1747
+ platformLabel: envInfo.platformLabel,
1748
+ isWSL: envInfo.isWSL,
1749
+ wslDistro: envInfo.wslDistro,
1750
+ shell: envInfo.shell,
1751
+ },
1752
+ tier,
1753
+ commands: { count: commands.length, list: commands },
1754
+ hooks: {
1755
+ sessionStart: forged.hookStatus ?? null,
1756
+ preToolUse: forged.preToolUseStatus ?? null,
1757
+ subagentStopEnforce: forged.subagentStopEnforceStatus ?? null,
1758
+ },
1759
+ forgeYaml: validateForgeYamlShape(ctx.cwd),
1760
+ };
1761
+
1762
+ await mkdir(join(ctx.home, ".forge"), { recursive: true });
1763
+ const tmpPath = receiptPath + ".tmp";
1764
+ await writeFile(tmpPath, JSON.stringify(receipt, null, 2) + "\n", "utf-8");
1765
+ await rename(tmpPath, receiptPath);
1766
+ return { written: true, path: receiptPath };
1767
+ } catch {
1768
+ // Best-effort only — a receipt write failure must never fail install/update.
1769
+ return { written: false, path: receiptPath };
1770
+ }
1771
+ }
1772
+
1773
+ // ---------------------------------------------------------------------------
1774
+ // Act V.6 — Proactive demo offer (Issue #1945)
1775
+ // ---------------------------------------------------------------------------
1776
+
1777
+ /**
1778
+ * Act V.6: after a fresh (non-update) install, proactively offer the
1779
+ * risk-free interactive demo (`bin/demo.mjs`, issue #1145) instead of leaving
1780
+ * it as an undiscoverable opt-in buried in help text.
1781
+ *
1782
+ * Modeled directly on `connect()` above: `ctx.confirmFn` defaults to
1783
+ * `false` and is already non-TTY-safe (see `tui.mjs`'s `confirm()` — a
1784
+ * non-interactive stdin resolves immediately to the default, no prompt is
1785
+ * shown). No separate `--fast`/non-TTY guard is needed here for the same
1786
+ * reason `connect()` doesn't need one.
1787
+ *
1788
+ * `ctx.runDemoFn` is injectable (defaults to a dynamic `import("./demo.mjs")`
1789
+ * wrapper, the same call shape `demo()` in bin/forgedock.mjs already uses) so
1790
+ * tests can stub it without spawning real `git clone`/network calls.
1791
+ * `runDemo()` reports failure via a returned `{status: "error"}` rather than
1792
+ * throwing (see bin/demo.mjs), so both the returned status and a thrown
1793
+ * exception are treated as "could not start the demo" here.
1794
+ *
1795
+ * Never throws past this function: a failed dynamic import, a thrown error,
1796
+ * or an error status from `runDemo()` must not turn an otherwise-successful
1797
+ * install into a non-zero exit — same best-effort posture as `openUrl()`.
1798
+ *
1799
+ * Only ever called from genuinely fresh/reconfigure flows (`runJourney()`
1800
+ * below, and `initFlow()` in bin/forgedock.mjs) — `npx forgedock update`
1801
+ * calls `relinkAndHint()` instead of `celebrate()`/this function, so the
1802
+ * repair path is structurally excluded already.
1803
+ *
1804
+ * @param {{confirmFn: Function, forgeHome: string, cwd: string, stdout: object,
1805
+ * mode: string, runDemoFn?: Function}} ctx
1806
+ * @returns {Promise<{ offered: boolean, ranDemo: boolean }>}
1807
+ */
1808
+ export async function maybeOfferDemo(ctx) {
1809
+ const { stdout: w } = ctx;
1810
+ const dim = (s) => (ctx.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
1811
+
1812
+ let yes = false;
1813
+ try {
1814
+ yes = await ctx.confirmFn("Run the interactive demo now?", false);
1815
+ } catch {
1816
+ return { offered: false, ranDemo: false };
1817
+ }
1818
+
1819
+ if (!yes) {
1820
+ w.write(" " + dim("Skipped — try it anytime: npx forgedock demo") + "\n");
1821
+ return { offered: true, ranDemo: false };
1822
+ }
1823
+
1824
+ const runDemoFn = ctx.runDemoFn ?? (async (opts) => {
1825
+ const { runDemo } = await import("./demo.mjs");
1826
+ return runDemo(opts);
1827
+ });
1828
+
1829
+ try {
1830
+ const result = await runDemoFn({ forgeHome: ctx.forgeHome, cwd: ctx.cwd });
1831
+ if (result && result.status === "error") {
1832
+ w.write(" " + dim(`Could not start the demo — try it later: npx forgedock demo (${result.error || "unknown error"})`) + "\n");
1833
+ return { offered: true, ranDemo: false };
1834
+ }
1835
+ return { offered: true, ranDemo: true };
1836
+ } catch {
1837
+ // Best-effort — a failed demo run must not fail the install itself.
1838
+ w.write(" " + dim("Could not start the demo — try it later: npx forgedock demo") + "\n");
1839
+ return { offered: true, ranDemo: false };
1840
+ }
1841
+ }
1842
+
955
1843
  // ---------------------------------------------------------------------------
956
1844
  // The full journey (Task 8)
957
1845
  // ---------------------------------------------------------------------------
@@ -968,11 +1856,24 @@ export async function runJourney(ctx) {
968
1856
  process.on("SIGINT", onSigint);
969
1857
  try {
970
1858
  await preflight(ctx);
1859
+ // Persist the toolset into ~/.forge/ before linking anything (#1943): when
1860
+ // not skipped (git-clone installs are exempt), reassign ctx.forgeHome so
1861
+ // every symlink/hook path forge() creates below originates from the
1862
+ // stable copy, not the ephemeral npm/npx/git source. Stashed on ctx so
1863
+ // forge()'s reporting block can surface the outcome (see isEphemeralCachePath
1864
+ // advisory extension above).
1865
+ const persistResult = await persistHome(ctx);
1866
+ ctx.persistHomeResult = persistResult;
1867
+ if (!persistResult.skipped) {
1868
+ ctx.forgeHome = persistResult.forgeHome;
1869
+ }
971
1870
  const forged = await forge(ctx);
972
1871
  const { draft, description } = await read(ctx);
973
1872
  const reviewed = await review(ctx, draft, description);
974
1873
  const connected = await connect(ctx);
975
- celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus });
1874
+ celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus, scriptsResult: forged.scriptsResult });
1875
+ await writeInstallReceipt(ctx, { forged });
1876
+ if (!reviewed.aborted) await maybeOfferDemo(ctx);
976
1877
  return reviewed.aborted ? 1 : 0;
977
1878
  } finally {
978
1879
  process.removeListener("SIGINT", onSigint);