forgedock 1.1.8 → 1.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine-cli.mjs +137 -11
  4. package/bin/env-detect.mjs +249 -0
  5. package/bin/forge-utils.mjs +364 -4
  6. package/bin/forgedock.mjs +1064 -135
  7. package/bin/hooks/session-start.mjs +68 -3
  8. package/bin/init-detect.mjs +101 -12
  9. package/bin/journey.mjs +935 -19
  10. package/bin/recall.mjs +24 -7
  11. package/bin/registry.mjs +91 -3
  12. package/bin/runner.mjs +38 -2
  13. package/bin/tui.mjs +49 -3
  14. package/commands/adopt.md +1 -1
  15. package/commands/analytics.md +8 -8
  16. package/commands/audit.md +1 -1
  17. package/commands/autopilot.md +1 -1
  18. package/commands/ci-audit.md +1 -1
  19. package/commands/cleanup.md +87 -52
  20. package/commands/compat-audit.md +1 -1
  21. package/commands/deploy-info.md +1 -1
  22. package/commands/deploy-pr.md +1 -1
  23. package/commands/diagnose.md +1 -1
  24. package/commands/explain.md +1 -1
  25. package/commands/fix-ci.md +1 -1
  26. package/commands/forgedock-init.md +1 -1
  27. package/commands/incident-response.md +3 -3
  28. package/commands/issue.md +1 -1
  29. package/commands/optimize.md +1 -1
  30. package/commands/orchestrate/config.md +13 -3
  31. package/commands/orchestrate/phase-1-resolve.md +1 -1
  32. package/commands/orchestrate/phase-2-triage.md +1 -1
  33. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  34. package/commands/orchestrate/phase-3-dependency.md +128 -20
  35. package/commands/orchestrate/phase-4-execution.md +598 -43
  36. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  37. package/commands/orchestrate/phase-6-report.md +51 -3
  38. package/commands/orchestrate/safety.md +2 -2
  39. package/commands/qa-sweep.md +1 -1
  40. package/commands/quality-gate.md +1 -1
  41. package/commands/recover-orphans.md +5 -5
  42. package/commands/replay.md +1 -1
  43. package/commands/review-pr-agents/api.md +2 -2
  44. package/commands/review-pr-agents/auth.md +2 -2
  45. package/commands/review-pr-agents/billing.md +2 -2
  46. package/commands/review-pr-agents/concurrency.md +2 -2
  47. package/commands/review-pr-agents/database.md +2 -2
  48. package/commands/review-pr-agents/frontend.md +2 -2
  49. package/commands/review-pr-agents/infra.md +2 -2
  50. package/commands/review-pr-agents/protocols.md +1 -1
  51. package/commands/review-pr-agents/scraper.md +2 -2
  52. package/commands/review-pr-agents/security.md +2 -2
  53. package/commands/review-pr-staging.md +41 -3
  54. package/commands/review-pr.md +71 -6
  55. package/commands/rollback.md +1 -1
  56. package/commands/scope.md +1 -1
  57. package/commands/security-audit.md +1 -1
  58. package/commands/test-gate.md +1 -1
  59. package/commands/validate.md +1 -1
  60. package/commands/work-on/build/architect.md +1 -1
  61. package/commands/work-on/build/context.md +9 -6
  62. package/commands/work-on/build/implement.md +1 -1
  63. package/commands/work-on/build/validate.md +1 -1
  64. package/commands/work-on/build.md +31 -6
  65. package/commands/work-on/close.md +3 -2
  66. package/commands/work-on/decompose.md +1 -1
  67. package/commands/work-on/investigate.md +14 -5
  68. package/commands/work-on/remediate.md +331 -0
  69. package/commands/work-on/review.md +1 -1
  70. package/commands/work-on-monolithic.md +1 -1
  71. package/commands/work-on.md +194 -56
  72. package/docs/CI.md +2 -2
  73. package/docs/CODEX.md +1 -1
  74. package/docs/CONFIG.md +186 -0
  75. package/docs/site/command-reference.md +31 -1
  76. package/docs/site/getting-started.md +9 -34
  77. package/docs/site/index.md +1 -1
  78. package/docs/spec/label-state-machine.md +1 -1
  79. package/package.json +2 -1
  80. package/packages/protocol/src/types.js +1 -1
  81. package/scripts/check-command-docs-drift.sh +4 -2
  82. package/scripts/danger-zones.mjs +19 -12
  83. package/scripts/transition-label.sh +101 -13
  84. 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.
@@ -665,9 +1254,24 @@ export async function forge(ctx) {
665
1254
  }
666
1255
  }
667
1256
  } else {
668
- if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
669
- w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
670
- skipped++;
1257
+ // Regular file not in manifest — could be a copy from a pre-manifest
1258
+ // ForgeDock version, or a user-owned file. Content-compare: if it
1259
+ // matches source exactly, adopt it into the manifest silently (it was
1260
+ // ours). If it differs, leave it untouched — it may be user-customized.
1261
+ let adopted = false;
1262
+ try {
1263
+ const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1264
+ if (src.equals(dst)) {
1265
+ recordCopy(rel);
1266
+ skipped++;
1267
+ adopted = true;
1268
+ }
1269
+ } catch { /* readFile failure — fall through to warning */ }
1270
+ if (!adopted) {
1271
+ if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
1272
+ w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
1273
+ skipped++;
1274
+ }
671
1275
  }
672
1276
  } catch (err) {
673
1277
  if (err.code !== "ENOENT") throw err;
@@ -706,6 +1310,9 @@ export async function forge(ctx) {
706
1310
  // target file no longer exists (e.g. after a command is renamed or deleted).
707
1311
  const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
708
1312
 
1313
+ // Link the small set of universal pipeline-agent scripts (forge#1885).
1314
+ const scriptsResult = await linkPipelineScripts(ctx);
1315
+
709
1316
  const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
710
1317
  const settingsPath = join(ctx.home, ".claude", "settings.json");
711
1318
  const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
@@ -749,6 +1356,13 @@ export async function forge(ctx) {
749
1356
  if (copied > 0) {
750
1357
  w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
751
1358
  }
1359
+ if (scriptsResult.total > 0) {
1360
+ const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
1361
+ const scriptsDetail = scriptsChanged > 0
1362
+ ? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
1363
+ : `(unchanged ${scriptsResult.skipped})`;
1364
+ w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
1365
+ }
752
1366
  if (manifestSaveFailed) {
753
1367
  w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
754
1368
  }
@@ -759,6 +1373,60 @@ export async function forge(ctx) {
759
1373
  w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
760
1374
  }
761
1375
 
1376
+ // Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
1377
+ // runJourney() calls persistHome(ctx) right before forge() and stashes its
1378
+ // result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
1379
+ // wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
1380
+ // stable ~/.forge/ copy, so report that outcome instead of the raw
1381
+ // ephemeral-cache warning. Direct forge() callers that never set
1382
+ // ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
1383
+ // through to the original ephemeral-cache-only check unchanged.
1384
+ const persistResult = ctx.persistHomeResult;
1385
+ if (persistResult && !persistResult.skipped) {
1386
+ if (persistResult.migrated) {
1387
+ w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
1388
+ } else {
1389
+ w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
1390
+ }
1391
+ } else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
1392
+ // Skipped for a reason OTHER than "still ephemeral" — today that's always
1393
+ // the git-working-tree exemption (Acceptance Criteria #5). Informational,
1394
+ // not a warning: this is the expected, correct state for a git-clone install.
1395
+ w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
1396
+ } else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
1397
+ // Either persistHome() was never run (no ctx.persistHomeResult), or it
1398
+ // ran but failed and left ctx.forgeHome pointing at the original
1399
+ // ephemeral source (fail-open — see persistHome()'s error branch).
1400
+ w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
1401
+ w.write(fixCard([
1402
+ "This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
1403
+ "or OS temp cleanup. If that happens, the SessionStart hook silently stops",
1404
+ "injecting ForgeDock context — no error is shown.",
1405
+ "Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
1406
+ ], ctx.mode, "warning") + "\n");
1407
+ }
1408
+
1409
+ // Cross-environment install advisory (forge#1893): warn when this repo is
1410
+ // reachable from both WSL and native Windows and BOTH sides already have a
1411
+ // ForgeDock install. Each environment's install is fully independent (see
1412
+ // #1589 — installs are always global, never per-repo), so running from
1413
+ // both silently leaves forge.yaml/hook paths pointing at whichever side ran
1414
+ // most recently, clobbering the other's config with no error surfaced.
1415
+ // Advisory only — never fails the install.
1416
+ const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
1417
+ const crossEnv = detectCrossEnvInstall(ctx, envInfo);
1418
+ if (crossEnv.conflict) {
1419
+ const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
1420
+ w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
1421
+ w.write(fixCard([
1422
+ `This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
1423
+ "install already exists. Each side's install is independent — running",
1424
+ "forgedock from both can leave forge.yaml and hook paths pointing at",
1425
+ "whichever environment ran last, silently overwriting the other's config.",
1426
+ "Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
1427
+ ], ctx.mode, "warning") + "\n");
1428
+ }
1429
+
762
1430
  // Report enforcement hook status.
763
1431
  if (preToolUseStatus !== null) {
764
1432
  w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
@@ -775,7 +1443,7 @@ export async function forge(ctx) {
775
1443
  w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
776
1444
  }
777
1445
 
778
- return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus };
1446
+ return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
779
1447
  }
780
1448
 
781
1449
  // ---------------------------------------------------------------------------
@@ -784,7 +1452,7 @@ export async function forge(ctx) {
784
1452
 
785
1453
  import { detectConfig } from "./init-detect.mjs";
786
1454
  import { enrich } from "./init-enrich-api.mjs";
787
- import { annotatedReviewScreen, box, confirm } from "./tui.mjs";
1455
+ import { annotatedReviewScreen, box, confirm, getLogoTagline } from "./tui.mjs";
788
1456
 
789
1457
  const badgeOf = (field) => field.confidence;
790
1458
 
@@ -837,6 +1505,13 @@ export async function read(ctx) {
837
1505
  * Act IV: the single interaction. Non-TTY + existing config aborts to protect
838
1506
  * the file; non-TTY + fresh config writes detection values with TODO flags
839
1507
  * (annotatedReviewScreen's non-TTY path returns them directly).
1508
+ *
1509
+ * Overwrite protection (forge#1850, regression of #578): a TTY session with
1510
+ * an existing forge.yaml must give EXPLICIT default-No consent before any
1511
+ * backup or write happens — the review screen's own "Overwrite Mode" banner
1512
+ * is advisory, not consent. This restores the #578 gate via ctx.confirmFn
1513
+ * (defaults to tui.mjs's confirm(), which is non-TTY-safe on its own —
1514
+ * belt-and-suspenders with the explicit isTTY check above it).
840
1515
  */
841
1516
  export async function review(ctx, draft, description) {
842
1517
  const { stdout: w } = ctx;
@@ -849,6 +1524,17 @@ export async function review(ctx, draft, description) {
849
1524
  return { written: false, todoCount: 0, backupName: null, aborted: true };
850
1525
  }
851
1526
 
1527
+ if (hasExisting) {
1528
+ const confirmed = await ctx.confirmFn(
1529
+ "forge.yaml already exists. Overwrite it? A backup will be created.",
1530
+ false,
1531
+ );
1532
+ if (!confirmed) {
1533
+ w.write("\n Overwrite cancelled — forge.yaml left untouched.\n");
1534
+ return { written: false, todoCount: 0, backupName: null, aborted: true };
1535
+ }
1536
+ }
1537
+
852
1538
  const extraFields = description.value
853
1539
  ? { description: { value: description.value, confidence: "medium", source: description.source, why: `First paragraph of ${description.source}` } }
854
1540
  : {};
@@ -859,6 +1545,19 @@ export async function review(ctx, draft, description) {
859
1545
  extraFields,
860
1546
  });
861
1547
 
1548
+ // Hard-fail on unresolved identity placeholders when OVERWRITING an
1549
+ // existing config (forge#1850 spec item 4): never destroy a working
1550
+ // forge.yaml in favor of one that can't address a repo. A brand-new
1551
+ // (non-existing) config is allowed to write placeholders — that's the
1552
+ // existing, already-flagged (# TODO comments) greenfield-setup path, and
1553
+ // there's nothing valuable to lose. Checked AFTER the review screen so an
1554
+ // interactive edit that fixes the field still passes.
1555
+ if (hasExisting && (accepted.owner === "your-github-org" || accepted.repo === "your-repo-name")) {
1556
+ w.write("\n Could not determine the GitHub owner/repo for this project.\n");
1557
+ w.write(" " + dimLine(ctx, "Run this inside the target git repository, or edit those fields before accepting.") + "\n");
1558
+ return { written: false, todoCount: 0, backupName: null, aborted: true };
1559
+ }
1560
+
862
1561
  const backup = backupExisting(outputPath);
863
1562
  if (backup) w.write(` Backed up: forge.yaml → ${backup.backupName}\n`);
864
1563
 
@@ -894,6 +1593,9 @@ export function celebrate(ctx, summary) {
894
1593
  const hookNote = summary.hookStatus === "skipped-malformed" ? "hook NOT active — see fix above" : "Claude Code knows this repo";
895
1594
  w.write(` ${glyph} ${summary.total} commands · hook ${summary.hookStatus === "skipped-malformed" ? "skipped" : "active"} ${dimLine(ctx, hookNote)}\n`);
896
1595
  }
1596
+ if (summary.scriptsResult && summary.scriptsResult.total > 0) {
1597
+ w.write(` ${glyph} ${summary.scriptsResult.total} pipeline scripts ${dimLine(ctx, "~/.claude/scripts — classify-lane, transition-label, validate-pr-target")}\n`);
1598
+ }
897
1599
  w.write("\n");
898
1600
  if (summary.isMinimal) {
899
1601
  w.write(" " + dimLine(ctx, "see docs/CONFIG.md for optional sections") + "\n");
@@ -906,10 +1608,13 @@ export function celebrate(ctx, summary) {
906
1608
  ` 3. run npx forgedock doctor — verify the install is green`,
907
1609
  ` 4. open claude in this repo`,
908
1610
  ` 5. run /issue <title> then /work-on <number>`,
1611
+ ` 6. run npx forgedock run-issue <N> — drive an issue via the durable engine`,
1612
+ ` 7. run npx forgedock watch — monitor pipeline state`,
909
1613
  ],
910
1614
  { title: "what's next" },
911
1615
  ),
912
1616
  );
1617
+ w.write(" " + dimLine(ctx, "not for you? npx forgedock uninstall removes everything · full command list: npx forgedock help") + "\n");
913
1618
  w.write(" " + dimLine(ctx, "docs: github.com/RapierCraftStudios/ForgeDock · ⭐ a star is the whole marketing budget") + "\n\n");
914
1619
  }
915
1620
 
@@ -925,8 +1630,18 @@ const GITHUB_APP_URL = "https://github.com/apps/rapiercraft-forgedock/installati
925
1630
  * the journey without error.
926
1631
  *
927
1632
  * 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.
1633
+ * browser, and ctx.confirmFn (injectable for tests) to ask for consent. In
1634
+ * non-TTY environments (piped stdin, --fast) confirm() resolves immediately
1635
+ * to false (the defaultValue), so no prompt is shown.
1636
+ *
1637
+ * IMPORTANT: installing the app today only registers it against the
1638
+ * account/org — it does NOT mint a bot token, does NOT auto-refresh
1639
+ * anything, and does NOT change which `gh` auth pipeline commands use.
1640
+ * Minting an installation token requires the app's private key, which only
1641
+ * RapierCraft Studios (the app owner) holds — an end user's installation
1642
+ * cannot self-serve a token without a hosted minting backend, which does
1643
+ * not exist yet (forge#1890). Keep this prompt's copy honest about that
1644
+ * until such a backend ships — see docs/CONFIG.md "GitHub App Install".
930
1645
  *
931
1646
  * @returns {Promise<{ opened: boolean }>}
932
1647
  */
@@ -934,8 +1649,8 @@ export async function connect(ctx) {
934
1649
  const { stdout: w } = ctx;
935
1650
  w.write("\n " + ember("Connect to GitHub", ctx.mode) + "\n\n");
936
1651
 
937
- const yes = await confirm(
938
- "Install the ForgeDock GitHub App for automatic pipeline triggers?",
1652
+ const yes = await ctx.confirmFn(
1653
+ "Install the ForgeDock GitHub App on this account/org?",
939
1654
  false,
940
1655
  );
941
1656
 
@@ -943,7 +1658,8 @@ export async function connect(ctx) {
943
1658
  ctx.openFn(GITHUB_APP_URL);
944
1659
  w.write(
945
1660
  ` \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`,
1661
+ ` ${ctx.mode === "none" ? "" : "\x1b[2m"}This registers the app only — it does not create a bot token yet.\x1b[22m\n` +
1662
+ ` ${ctx.mode === "none" ? "" : "\x1b[2m"}Pipeline commands keep using your personal \`gh\` auth. See docs/CONFIG.md.\x1b[22m\n`,
947
1663
  );
948
1664
  return { opened: true };
949
1665
  }
@@ -952,6 +1668,193 @@ export async function connect(ctx) {
952
1668
  return { opened: false };
953
1669
  }
954
1670
 
1671
+ // ---------------------------------------------------------------------------
1672
+ // Install Receipt (Issue #1946) — machine-readable record of what an
1673
+ // install/update actually did, so drift debugging can read a receipt instead
1674
+ // of re-deriving state from scratch.
1675
+ // ---------------------------------------------------------------------------
1676
+
1677
+ /**
1678
+ * Read the installed forgedock package version from {forgeHome}/package.json.
1679
+ * Best-effort — returns "" on any read/parse failure. Duplicated (rather than
1680
+ * imported) from bin/forgedock.mjs's getVersion() to avoid a circular import:
1681
+ * forgedock.mjs already imports from journey.mjs.
1682
+ * @param {string} forgeHome
1683
+ * @returns {string}
1684
+ */
1685
+ function readForgedockVersion(forgeHome) {
1686
+ try {
1687
+ const pkg = JSON.parse(readFileSync(join(forgeHome, "package.json"), "utf-8"));
1688
+ return typeof pkg.version === "string" ? pkg.version : "";
1689
+ } catch {
1690
+ return "";
1691
+ }
1692
+ }
1693
+
1694
+ /**
1695
+ * Write a machine-readable install-receipt.json to {ctx.home}/.forge/ after a
1696
+ * successful install (runJourney) or update (bin/forgedock.mjs's
1697
+ * relinkAndHint — shared by both update() branches: the git-clone
1698
+ * fast-forward path and the npm version-check path). Note: re-running
1699
+ * `npx forgedock install` on an already-managed-active repo takes the
1700
+ * statusScreen() short-circuit instead of runJourney()/relinkAndHint() — that
1701
+ * path does not refresh the receipt (it also does not touch forge() or
1702
+ * anything else, so this is consistent with the rest of that short-circuit's
1703
+ * no-op behavior, not a gap specific to this feature). See docs/CONFIG.md
1704
+ * "Install Receipt" for the schema.
1705
+ *
1706
+ * Deliberately narrow field set — no PII/secrets: no process.env values, no
1707
+ * GitHub tokens, no forge.yaml file contents (only a presence/shape boolean
1708
+ * from validateForgeYamlShape()). Absolute paths (forgeHome/cwd) ARE included
1709
+ * for drift debugging; they are not secrets, matching existing precedent
1710
+ * (ctx.cwd/ctx.home already appear in on-disk state such as registry.json's
1711
+ * path-keyed entries).
1712
+ *
1713
+ * Never throws: any failure (permission denied, disk full, malformed
1714
+ * forgeHome) degrades to a silent no-op, matching every other housekeeping
1715
+ * step in forge() (manifest save, hook install) — a receipt write must never
1716
+ * fail the install/update it merely records.
1717
+ *
1718
+ * @param {object} ctx - Journey context (forgeHome, home, cwd, platform, env,
1719
+ * release, includeExtras — see makeCtx()).
1720
+ * @param {{ forged?: Awaited<ReturnType<typeof forge>> }} summary
1721
+ * `forged` is forge()'s return value (hook statuses, script results). The
1722
+ * caller does not need to pass `reviewed` — forgeYaml status is recomputed
1723
+ * independently via validateForgeYamlShape() so this works identically
1724
+ * whether called from runJourney() (after Act III/IV) or relinkAndHint()
1725
+ * (which never reaches Act III/IV).
1726
+ * @returns {Promise<{ written: boolean, path: string }>}
1727
+ */
1728
+ export async function writeInstallReceipt(ctx, summary = {}) {
1729
+ // Computed inside the try block (not before it): ctx.home is expected to
1730
+ // always be a string in production (makeCtx()/ctx() both default it via
1731
+ // env.HOME || env.USERPROFILE || os.homedir()), but this function's own
1732
+ // contract is "never throws" — join() on a malformed ctx.home must degrade
1733
+ // to written:false, not escape uncaught to the CLI entrypoint.
1734
+ let receiptPath = join(os.homedir(), ".forge", "install-receipt.json");
1735
+ try {
1736
+ receiptPath = join(ctx.home, ".forge", "install-receipt.json");
1737
+ const { forged = {} } = summary;
1738
+ const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
1739
+ const installMode = existsSync(join(ctx.forgeHome, ".git")) ? "git-clone" : "npm";
1740
+ const tier = ctx.includeExtras ? "extras" : "core";
1741
+
1742
+ const commandsDir = join(ctx.forgeHome, "commands");
1743
+ let commands = [];
1744
+ if (existsSync(commandsDir)) {
1745
+ try {
1746
+ const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
1747
+ commands = files.map((f) => relative(commandsDir, f).replace(/\.md$/, "").replace(/\\/g, "/"));
1748
+ } catch {
1749
+ commands = [];
1750
+ }
1751
+ }
1752
+
1753
+ const receipt = {
1754
+ schemaVersion: 1,
1755
+ timestamp: new Date().toISOString(),
1756
+ forgedockVersion: readForgedockVersion(ctx.forgeHome),
1757
+ installMode,
1758
+ forgeHome: ctx.forgeHome,
1759
+ cwd: ctx.cwd,
1760
+ platform: {
1761
+ platform: envInfo.platform,
1762
+ platformLabel: envInfo.platformLabel,
1763
+ isWSL: envInfo.isWSL,
1764
+ wslDistro: envInfo.wslDistro,
1765
+ shell: envInfo.shell,
1766
+ },
1767
+ tier,
1768
+ commands: { count: commands.length, list: commands },
1769
+ hooks: {
1770
+ sessionStart: forged.hookStatus ?? null,
1771
+ preToolUse: forged.preToolUseStatus ?? null,
1772
+ subagentStopEnforce: forged.subagentStopEnforceStatus ?? null,
1773
+ },
1774
+ forgeYaml: validateForgeYamlShape(ctx.cwd),
1775
+ };
1776
+
1777
+ await mkdir(join(ctx.home, ".forge"), { recursive: true });
1778
+ const tmpPath = receiptPath + ".tmp";
1779
+ await writeFile(tmpPath, JSON.stringify(receipt, null, 2) + "\n", "utf-8");
1780
+ await rename(tmpPath, receiptPath);
1781
+ return { written: true, path: receiptPath };
1782
+ } catch {
1783
+ // Best-effort only — a receipt write failure must never fail install/update.
1784
+ return { written: false, path: receiptPath };
1785
+ }
1786
+ }
1787
+
1788
+ // ---------------------------------------------------------------------------
1789
+ // Act V.6 — Proactive demo offer (Issue #1945)
1790
+ // ---------------------------------------------------------------------------
1791
+
1792
+ /**
1793
+ * Act V.6: after a fresh (non-update) install, proactively offer the
1794
+ * risk-free interactive demo (`bin/demo.mjs`, issue #1145) instead of leaving
1795
+ * it as an undiscoverable opt-in buried in help text.
1796
+ *
1797
+ * Modeled directly on `connect()` above: `ctx.confirmFn` defaults to
1798
+ * `false` and is already non-TTY-safe (see `tui.mjs`'s `confirm()` — a
1799
+ * non-interactive stdin resolves immediately to the default, no prompt is
1800
+ * shown). No separate `--fast`/non-TTY guard is needed here for the same
1801
+ * reason `connect()` doesn't need one.
1802
+ *
1803
+ * `ctx.runDemoFn` is injectable (defaults to a dynamic `import("./demo.mjs")`
1804
+ * wrapper, the same call shape `demo()` in bin/forgedock.mjs already uses) so
1805
+ * tests can stub it without spawning real `git clone`/network calls.
1806
+ * `runDemo()` reports failure via a returned `{status: "error"}` rather than
1807
+ * throwing (see bin/demo.mjs), so both the returned status and a thrown
1808
+ * exception are treated as "could not start the demo" here.
1809
+ *
1810
+ * Never throws past this function: a failed dynamic import, a thrown error,
1811
+ * or an error status from `runDemo()` must not turn an otherwise-successful
1812
+ * install into a non-zero exit — same best-effort posture as `openUrl()`.
1813
+ *
1814
+ * Only ever called from genuinely fresh/reconfigure flows (`runJourney()`
1815
+ * below, and `initFlow()` in bin/forgedock.mjs) — `npx forgedock update`
1816
+ * calls `relinkAndHint()` instead of `celebrate()`/this function, so the
1817
+ * repair path is structurally excluded already.
1818
+ *
1819
+ * @param {{confirmFn: Function, forgeHome: string, cwd: string, stdout: object,
1820
+ * mode: string, runDemoFn?: Function}} ctx
1821
+ * @returns {Promise<{ offered: boolean, ranDemo: boolean }>}
1822
+ */
1823
+ export async function maybeOfferDemo(ctx) {
1824
+ const { stdout: w } = ctx;
1825
+ const dim = (s) => (ctx.mode === "none" ? s : `\x1b[2m${s}\x1b[22m`);
1826
+
1827
+ let yes = false;
1828
+ try {
1829
+ yes = await ctx.confirmFn("Run the interactive demo now?", false);
1830
+ } catch {
1831
+ return { offered: false, ranDemo: false };
1832
+ }
1833
+
1834
+ if (!yes) {
1835
+ w.write(" " + dim("Skipped — try it anytime: npx forgedock demo") + "\n");
1836
+ return { offered: true, ranDemo: false };
1837
+ }
1838
+
1839
+ const runDemoFn = ctx.runDemoFn ?? (async (opts) => {
1840
+ const { runDemo } = await import("./demo.mjs");
1841
+ return runDemo(opts);
1842
+ });
1843
+
1844
+ try {
1845
+ const result = await runDemoFn({ forgeHome: ctx.forgeHome, cwd: ctx.cwd });
1846
+ if (result && result.status === "error") {
1847
+ w.write(" " + dim(`Could not start the demo — try it later: npx forgedock demo (${result.error || "unknown error"})`) + "\n");
1848
+ return { offered: true, ranDemo: false };
1849
+ }
1850
+ return { offered: true, ranDemo: true };
1851
+ } catch {
1852
+ // Best-effort — a failed demo run must not fail the install itself.
1853
+ w.write(" " + dim("Could not start the demo — try it later: npx forgedock demo") + "\n");
1854
+ return { offered: true, ranDemo: false };
1855
+ }
1856
+ }
1857
+
955
1858
  // ---------------------------------------------------------------------------
956
1859
  // The full journey (Task 8)
957
1860
  // ---------------------------------------------------------------------------
@@ -968,11 +1871,24 @@ export async function runJourney(ctx) {
968
1871
  process.on("SIGINT", onSigint);
969
1872
  try {
970
1873
  await preflight(ctx);
1874
+ // Persist the toolset into ~/.forge/ before linking anything (#1943): when
1875
+ // not skipped (git-clone installs are exempt), reassign ctx.forgeHome so
1876
+ // every symlink/hook path forge() creates below originates from the
1877
+ // stable copy, not the ephemeral npm/npx/git source. Stashed on ctx so
1878
+ // forge()'s reporting block can surface the outcome (see isEphemeralCachePath
1879
+ // advisory extension above).
1880
+ const persistResult = await persistHome(ctx);
1881
+ ctx.persistHomeResult = persistResult;
1882
+ if (!persistResult.skipped) {
1883
+ ctx.forgeHome = persistResult.forgeHome;
1884
+ }
971
1885
  const forged = await forge(ctx);
972
1886
  const { draft, description } = await read(ctx);
973
1887
  const reviewed = await review(ctx, draft, description);
974
1888
  const connected = await connect(ctx);
975
- celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus });
1889
+ celebrate(ctx, { ...reviewed, ...connected, total: forged.total, hookStatus: forged.hookStatus, scriptsResult: forged.scriptsResult });
1890
+ await writeInstallReceipt(ctx, { forged });
1891
+ if (!reviewed.aborted) await maybeOfferDemo(ctx);
976
1892
  return reviewed.aborted ? 1 : 0;
977
1893
  } finally {
978
1894
  process.removeListener("SIGINT", onSigint);