forgedock 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/journey.mjs CHANGED
@@ -1060,6 +1060,73 @@ export async function isSymlinkTraversable(target) {
1060
1060
  }
1061
1061
  }
1062
1062
 
1063
+ /**
1064
+ * Atomically install `target` as a symlink to `file`, verifying the result is
1065
+ * readable by *this* process. Single source of truth for the correctness
1066
+ * invariants that previously lived as near-verbatim copies at every symlink
1067
+ * call site (forge#2667):
1068
+ *
1069
+ * - The symlink is first created at a tmp sibling suffixed with this
1070
+ * process's PID, then rename()d onto target. symlink() is exclusive-create,
1071
+ * so two concurrent forge() invocations installing the same path would
1072
+ * collide with an uncaught EEXIST on a shared literal path — the PID
1073
+ * suffix guarantees they never share one (forge#2542, forge#2599,
1074
+ * forge#2600, forge#2631). rename() atomically replaces any existing
1075
+ * target, including on Windows.
1076
+ * - The tmp sibling is unlinked on every failure path so a rename failure
1077
+ * (or any rethrow) never orphans it (forge#2612).
1078
+ * - The link is read back via isSymlinkTraversable() on the PID-scoped tmp
1079
+ * path *before* the rename: fs.symlink() can succeed while producing a link
1080
+ * this runtime cannot resolve (MSYS symlink + native Windows "untrusted
1081
+ * mount point" — forge#2620). Verifying the tmp sibling — which no other
1082
+ * process ever touches — instead of the shared target *after* the rename
1083
+ * closes a TOCTOU window: a post-rename readback could describe (and the
1084
+ * old cleanup unlink could remove) a concurrent installer's freshly-renamed
1085
+ * link, not ours (forge#2679). An unreadable link is discarded as tmp and
1086
+ * never renamed onto target, so target is left untouched.
1087
+ *
1088
+ * Callers keep their own `wantSymlink` guard and site-specific bookkeeping
1089
+ * (counters, manifest mutation, copy fall-through) around the result.
1090
+ *
1091
+ * @param {string} file - Symlink target (the source file to link to).
1092
+ * @param {string} target - Path where the symlink is installed.
1093
+ * @returns {Promise<"linked"|"unreadable"|"denied">}
1094
+ * "linked" — symlink atomically installed at target and traversable.
1095
+ * "unreadable" — symlink was created but not readable back (forge#2620);
1096
+ * the dead link was discarded as tmp and the rename never
1097
+ * happened, so any existing target is left intact (forge#2679).
1098
+ * Caller should fall back to a copy.
1099
+ * "denied" — EPERM/EACCES from symlink/rename (no symlink installed;
1100
+ * e.g. Windows without Developer Mode). Caller should fall
1101
+ * back to a copy.
1102
+ * Any other error (EEXIST on the tmp path, ENOSPC, rename failures, …) is
1103
+ * rethrown after tmp cleanup.
1104
+ */
1105
+ export async function atomicSymlinkInstall(file, target) {
1106
+ const tmpTarget = target + "." + process.pid + ".tmp";
1107
+ try {
1108
+ await symlink(file, tmpTarget);
1109
+ // Read back the PID-scoped tmp sibling *before* renaming onto target. No
1110
+ // other process touches tmpTarget, so the verdict cannot describe a
1111
+ // concurrent installer's link, and an unreadable link is discarded here
1112
+ // without ever unlinking target (forge#2679, forge#2620).
1113
+ if (!(await isSymlinkTraversable(tmpTarget))) {
1114
+ await unlink(tmpTarget).catch(() => {});
1115
+ return "unreadable";
1116
+ }
1117
+ try {
1118
+ await rename(tmpTarget, target);
1119
+ } catch (renameErr) {
1120
+ await unlink(tmpTarget).catch(() => {});
1121
+ throw renameErr;
1122
+ }
1123
+ return "linked";
1124
+ } catch (linkErr) {
1125
+ if (!isLinkPermissionError(linkErr)) throw linkErr;
1126
+ return "denied";
1127
+ }
1128
+ }
1129
+
1063
1130
  /**
1064
1131
  * Allowlist of universal pipeline-agent scripts installed to
1065
1132
  * ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
@@ -1679,27 +1746,7 @@ async function linkPipelineScripts(ctx) {
1679
1746
 
1680
1747
  let linked = false;
1681
1748
  if (wantSymlink) {
1682
- // Suffix the tmp sibling with this process's PID so a losing
1683
- // concurrent forge() invocation linking the same brand-new pipeline
1684
- // script never collides on the same literal target path (which
1685
- // raises an uncaught EEXIST — isLinkPermissionError() only treats
1686
- // EPERM/EACCES as recoverable). Mirrors the fresh-install symlink
1687
- // branch in forge() (forge#2631) and the relink/upgrade branches
1688
- // (forge#2542, forge#2600, forge#2599).
1689
- const tmpTarget = target + "." + process.pid + ".tmp";
1690
- try {
1691
- await symlink(file, tmpTarget);
1692
- try {
1693
- await rename(tmpTarget, target);
1694
- } catch (renameErr) {
1695
- await unlink(tmpTarget).catch(() => {});
1696
- throw renameErr;
1697
- }
1698
- linked = await isSymlinkTraversable(target);
1699
- if (!linked) await unlink(target).catch(() => {});
1700
- } catch (linkErr) {
1701
- if (!isLinkPermissionError(linkErr)) throw linkErr;
1702
- }
1749
+ linked = (await atomicSymlinkInstall(file, target)) === "linked";
1703
1750
  }
1704
1751
  if (!linked) {
1705
1752
  await copyFile(file, target);
@@ -1835,6 +1882,364 @@ export function detectCrossEnvInstall(ctx, envInfo, deps = {}) {
1835
1882
  }
1836
1883
  }
1837
1884
 
1885
+ /**
1886
+ * Final manifest save for forge(): locked re-read → replay this run's
1887
+ * `manifestOps` → write. Returns true on success, false on any failure —
1888
+ * never throws, because manifest housekeeping must never abort the caller's
1889
+ * receipt or the hook installation.
1890
+ */
1891
+ async function saveManifestWithMerge(manifestPath, manifestOps) {
1892
+ try {
1893
+ // Re-read-and-merge (forge#2614): another concurrent forge() invocation
1894
+ // may have saved its own manifest.files adds/deletes since this run's
1895
+ // initial load above. Re-reading the on-disk manifest right before this
1896
+ // run's own save and replaying only this run's own ops (manifestOps)
1897
+ // onto that fresh copy — rather than blindly overwriting with the
1898
+ // run-start in-memory snapshot — means this run's save can no longer
1899
+ // silently drop the other run's changes. loadCopiedManifest() never
1900
+ // throws (missing/corrupt on-disk manifest resolves to an empty one),
1901
+ // so this stays within the surrounding never-abort contract.
1902
+ //
1903
+ // Lock the re-read→merge→write sequence itself (forge#2637): #2614
1904
+ // narrowed the race from "whole run duration" to this critical section,
1905
+ // but left it unprotected — a third concurrent forge() could still land
1906
+ // its own save inside the gap between this run's re-read and its own
1907
+ // write-rename completing. acquireManifestLock() is best-effort: on
1908
+ // failure to acquire (contention exhausted retries, or an unexpected
1909
+ // error) it returns null and this run proceeds unlocked, same as
1910
+ // before — the lock narrows the window further, it does not change the
1911
+ // never-abort contract.
1912
+ const lockHandle = await acquireManifestLock(manifestPath);
1913
+ try {
1914
+ const mergedManifest = await loadCopiedManifest(manifestPath);
1915
+ for (const { rel, op } of manifestOps) {
1916
+ if (op === "add") {
1917
+ mergedManifest.files[rel] = true;
1918
+ } else {
1919
+ delete mergedManifest.files[rel];
1920
+ }
1921
+ }
1922
+ await saveCopiedManifest(manifestPath, mergedManifest);
1923
+ } finally {
1924
+ await releaseManifestLock(manifestPath, lockHandle);
1925
+ }
1926
+ return true;
1927
+ } catch {
1928
+ return false;
1929
+ }
1930
+ }
1931
+
1932
+ /**
1933
+ * Per-file branch for forge(): a manifest-tracked regular file — a copy we
1934
+ * made on a previous run, ours to manage. First try upgrading it to a
1935
+ * symlink (Developer Mode enabled since), preserving atomicSymlinkInstall()'s
1936
+ * tri-state "linked"/"unreadable"/"denied" contract (forge#2620 / ADR #2667)
1937
+ * exactly; on "denied", fall through to content-compare + copy.
1938
+ * Returns counter deltas for forge() to fold in.
1939
+ */
1940
+ async function upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete }) {
1941
+ let upgraded = false;
1942
+ let restoredAsCopy = false;
1943
+ if (wantSymlink) {
1944
+ const result = await atomicSymlinkInstall(file, target);
1945
+ if (result === "linked") {
1946
+ upgraded = true;
1947
+ recordDelete(rel);
1948
+ } else if (result === "unreadable") {
1949
+ // Symlink was created but is not readable back (forge#2620) — the helper
1950
+ // discarded it as tmp and left the existing copy at target untouched
1951
+ // (forge#2679). Overwrite it with a fresh copy so the command stays
1952
+ // readable. Keep the manifest entry (still copy-managed, not
1953
+ // symlink-managed) and do NOT fall through to the generic
1954
+ // copy-comparison below — the copy is already done.
1955
+ await copyFile(file, target);
1956
+ restoredAsCopy = true;
1957
+ }
1958
+ // "denied" — no symlink installed; fall through to content-compare.
1959
+ }
1960
+ if (upgraded || restoredAsCopy) {
1961
+ return { updatedDelta: 1, skippedDelta: 0 };
1962
+ }
1963
+ const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1964
+ if (src.equals(dst)) {
1965
+ return { updatedDelta: 0, skippedDelta: 1 };
1966
+ }
1967
+ await copyFile(file, target);
1968
+ return { updatedDelta: 1, skippedDelta: 0 };
1969
+ }
1970
+
1971
+ /**
1972
+ * Per-file branch for forge(): a regular file not in the manifest — a copy
1973
+ * from a pre-manifest ForgeDock version, or a stale copy that was never
1974
+ * adopted. `rel` here is always enumerated from `files` (ForgeDock's own
1975
+ * commandsDir listing) — every path this branch touches is a
1976
+ * ForgeDock-managed command file, never an arbitrary user file, so there is
1977
+ * no "user-customized" case to protect. Content-compare: if it matches
1978
+ * source, adopt it into the manifest silently (it was ours all along). If it
1979
+ * differs, it's stale — overwrite it and adopt it into the manifest too,
1980
+ * mirroring the manifest-tracked branch, so future runs take the
1981
+ * manifest-aware fast path.
1982
+ *
1983
+ * Never throws. Returns an effects object forge() folds into its counters:
1984
+ * `handled: false` means every repair attempt failed and forge() should
1985
+ * print the per-file WARNING (terminal/progress-bar state stays in forge()).
1986
+ * `backedUpDelta` is mutated in place as backup/rollback progresses so a
1987
+ * mid-sequence failure still reports any backup that remains on disk
1988
+ * (forge#2559).
1989
+ */
1990
+ async function adoptOrRepairUnmanagedCopy(file, target, rel, { recordCopy }) {
1991
+ const effects = { handled: false, updatedDelta: 0, skippedDelta: 0, backedUpDelta: 0 };
1992
+ try {
1993
+ const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1994
+ if (src.equals(dst)) {
1995
+ recordCopy(rel);
1996
+ effects.skippedDelta++;
1997
+ effects.handled = true;
1998
+ } else {
1999
+ // Write to a .tmp sibling and atomically rename onto target — never
2000
+ // open target itself for a direct overwrite. copyFile() is not
2001
+ // atomic: a mid-write failure (AV lock / ENOSPC / process kill)
2002
+ // would otherwise leave target truncated/corrupted rather than
2003
+ // simply stale, which the WARNING in forge() cannot distinguish. This
2004
+ // mirrors the .tmp+rename swap already used for symlink installs
2005
+ // (lines ~1573-1579, ~1601-1607).
2006
+ //
2007
+ // Unlike those symlink-based branches — where symlink() is itself
2008
+ // exclusive-create and therefore naturally races out a concurrent
2009
+ // writer — copyFile() has no such semantics: two concurrent
2010
+ // forge() invocations (e.g. an overlapping `doctor --fix` run, or
2011
+ // a file watcher) targeting the same file would both write
2012
+ // through the same shared `target + ".tmp"` path and could
2013
+ // interleave or clobber each other before either rename() runs.
2014
+ // Suffix the tmp sibling with this process's PID so concurrent
2015
+ // invocations never share a path (forge#2542).
2016
+ const tmpTarget = target + "." + process.pid + ".tmp";
2017
+ try {
2018
+ await copyFile(file, tmpTarget);
2019
+ } catch (copyErr) {
2020
+ // A copyFile failure (disk full mid-copy, AV lock, permission
2021
+ // error) can still leave a partially-written .tmp sibling on
2022
+ // disk even though the write itself threw. The sibling
2023
+ // rename() failure just below already cleans up tmpTarget
2024
+ // on its own failure path (renameErr) — mirror that here so a
2025
+ // copyFile failure doesn't orphan the .tmp file instead
2026
+ // (forge#2540). Best-effort: never let the cleanup itself mask
2027
+ // or replace the original copyErr being rethrown.
2028
+ await unlink(tmpTarget).catch(() => {});
2029
+ throw copyErr;
2030
+ }
2031
+ // Preserve the pre-existing content before it's replaced, mirroring
2032
+ // half of the confirm+backup convention used by the forge.yaml
2033
+ // overwrite path (bin/forgedock.mjs ~990-1021 / journey.mjs
2034
+ // review(), both via backupExisting()). A confirmation prompt is
2035
+ // deliberately NOT added: this branch also runs non-interactively
2036
+ // inside `doctor --fix`'s automated repair (bin/forgedock.mjs
2037
+ // runInstallRepairOnce() -> forge(ctx())), so pausing per-file for
2038
+ // input would break that flow. Placed here — after the .tmp write
2039
+ // has already succeeded, before the final rename — rather than
2040
+ // before copyFile (as the forge.yaml path does), so a copyFile
2041
+ // failure never backs up (and thereby displaces) a target that's
2042
+ // about to remain untouched (see the .tmp-write-failure test,
2043
+ // forge#2498). backupExisting()'s rename is itself atomic, so this
2044
+ // ordering carries none of the non-atomic-write risk that forge#1396
2045
+ // flagged for the old backup-then-writeFileSync forge.yaml path.
2046
+ //
2047
+ // Note: backupExisting() below is a *synchronous* rename
2048
+ // (target -> target+".bak"); the tmpTarget -> target rename just
2049
+ // after it is a separate, async filesystem operation. Between
2050
+ // the two, target genuinely does not exist on disk for a
2051
+ // sub-millisecond window. Only a hard process kill (not any
2052
+ // catchable JS error) can land exactly there — any catchable
2053
+ // failure of the second rename is already handled by the
2054
+ // rollback block below — and recovery in that rare case is via
2055
+ // the surviving target+".bak" on the next forge() run (forge#2558).
2056
+ const backup = backupExisting(target);
2057
+ if (backup) effects.backedUpDelta++;
2058
+ try {
2059
+ await rename(tmpTarget, target);
2060
+ } catch (renameErr) {
2061
+ await unlink(tmpTarget).catch(() => {});
2062
+ if (backup) {
2063
+ // The final rename failed AFTER backupExisting() already moved
2064
+ // the original file to target+".bak" — target no longer exists
2065
+ // on disk at this point. Without this rollback the user's file
2066
+ // would simply vanish (present only as .bak) while the WARNING
2067
+ // in forge() implies it merely "could not be repaired". Restore
2068
+ // the prior content so a failed repair is a no-op, matching the
2069
+ // pre-backup invariant that a rename failure never removes the
2070
+ // existing file (see the .tmp-write-failure test, forge#2498).
2071
+ // WIRE:PROVEN — manual: reasoned, not exercised by an automated
2072
+ // test. This branch requires rename(tmp, target) to fail on the
2073
+ // exact call immediately after backupExisting()'s own rename of
2074
+ // the same target succeeded — an OS-level race with no portable
2075
+ // way to force deterministically (the sibling renameErr catches
2076
+ // for the symlink-relink paths above, lines ~1576/~1604, are the
2077
+ // same kind of defensive OS-failure branch and are likewise
2078
+ // untested for the same reason). Verified by code inspection:
2079
+ // rename() is fs/promises' rename. The rollback rename's own
2080
+ // outcome is captured explicitly rather than assumed — if it
2081
+ // also fails (e.g. .bak itself is locked), target+".bak" is
2082
+ // left in place as the only remaining copy of the prior
2083
+ // content, so backedUpDelta must NOT be decremented in that
2084
+ // case; it correctly reflects "a backup file still exists on
2085
+ // disk" (forge#2559). Only on confirmed rollback success does
2086
+ // the decrement keep the counter consistent with the restored
2087
+ // on-disk state before renameErr is rethrown to the outer catch.
2088
+ let rollbackOk = true;
2089
+ await rename(target + ".bak", target).catch(() => { rollbackOk = false; });
2090
+ if (rollbackOk) effects.backedUpDelta--;
2091
+ }
2092
+ throw renameErr;
2093
+ }
2094
+ recordCopy(rel);
2095
+ effects.updatedDelta++;
2096
+ effects.handled = true;
2097
+ }
2098
+ } catch { /* readFile/copyFile/rename failure — fall through to warning */ }
2099
+ return effects;
2100
+ }
2101
+
2102
+ /**
2103
+ * Per-file branch for forge(): target does not exist yet (ENOENT from
2104
+ * lstat) — fresh install. Symlink-first, copy-fallback; a successful link
2105
+ * also drops any stale manifest record for the path.
2106
+ * Returns counter deltas for forge() to fold in.
2107
+ */
2108
+ async function installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete }) {
2109
+ let linked = false;
2110
+ if (wantSymlink) {
2111
+ linked = (await atomicSymlinkInstall(file, target)) === "linked";
2112
+ if (linked && manifest.files[rel]) {
2113
+ // Symlinks work now and the old copy is gone — drop the stale record.
2114
+ recordDelete(rel);
2115
+ }
2116
+ }
2117
+ if (!linked) {
2118
+ // Windows without Developer Mode/admin, or symlink unreadable — copy.
2119
+ await copyFile(file, target);
2120
+ recordCopy(rel);
2121
+ return { installedDelta: 0, copiedDelta: 1 };
2122
+ }
2123
+ return { installedDelta: 1, copiedDelta: 0 };
2124
+ }
2125
+
2126
+ /**
2127
+ * Act II receipt: render forge()'s summary lines, advisories, and hook
2128
+ * statuses. Pure reporting — mechanical move of the forge() tail; reads
2129
+ * `results` and writes to ctx.stdout, mutates nothing.
2130
+ */
2131
+ function renderForgeReceipt(ctx, results) {
2132
+ const { stdout: w } = ctx;
2133
+ const {
2134
+ installed, updated, skipped, copied, backedUp, pruned, total,
2135
+ staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
2136
+ hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
2137
+ } = results;
2138
+
2139
+ const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
2140
+ const headlineVerb = copied > 0 ? "installed" : "linked";
2141
+ const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
2142
+ const headlineDetail = copied > 0
2143
+ ? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
2144
+ : `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
2145
+ w.write(` ${glyph(true)} ${total} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
2146
+ if (pruned > 0) {
2147
+ w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
2148
+ }
2149
+ if (staleExtensionlessPruned > 0) {
2150
+ w.write(` ${glyph(true)} ${staleExtensionlessPruned} stale extensionless entr${staleExtensionlessPruned === 1 ? "y" : "ies"} removed ${dimLine(ctx, "(leftover from prior npx forgedock runs)")}\n`);
2151
+ }
2152
+ if (copied > 0) {
2153
+ w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
2154
+ }
2155
+ if (scriptsResult.total > 0) {
2156
+ const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
2157
+ const scriptsDetail = scriptsChanged > 0
2158
+ ? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
2159
+ : `(unchanged ${scriptsResult.skipped})`;
2160
+ w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
2161
+ }
2162
+ if (manifestSaveFailed) {
2163
+ w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
2164
+ }
2165
+ if (hookStatus === "skipped-malformed") {
2166
+ w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
2167
+ w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
2168
+ } else {
2169
+ w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
2170
+ }
2171
+
2172
+ // Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
2173
+ // runJourney() calls persistHome(ctx) right before forge() and stashes its
2174
+ // result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
2175
+ // wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
2176
+ // stable ~/.forge/ copy, so report that outcome instead of the raw
2177
+ // ephemeral-cache warning. Direct forge() callers that never set
2178
+ // ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
2179
+ // through to the original ephemeral-cache-only check unchanged.
2180
+ const persistResult = ctx.persistHomeResult;
2181
+ if (persistResult && !persistResult.skipped) {
2182
+ if (persistResult.migrated) {
2183
+ w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
2184
+ } else {
2185
+ w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
2186
+ }
2187
+ } else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
2188
+ // Skipped for a reason OTHER than "still ephemeral" — today that's always
2189
+ // the git-working-tree exemption (Acceptance Criteria #5). Informational,
2190
+ // not a warning: this is the expected, correct state for a git-clone install.
2191
+ w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
2192
+ } else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
2193
+ // Either persistHome() was never run (no ctx.persistHomeResult), or it
2194
+ // ran but failed and left ctx.forgeHome pointing at the original
2195
+ // ephemeral source (fail-open — see persistHome()'s error branch).
2196
+ w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
2197
+ w.write(fixCard([
2198
+ "This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
2199
+ "or OS temp cleanup. If that happens, the SessionStart hook silently stops",
2200
+ "injecting ForgeDock context — no error is shown.",
2201
+ "Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
2202
+ ], ctx.mode, "warning") + "\n");
2203
+ }
2204
+
2205
+ // Cross-environment install advisory (forge#1893): warn when this repo is
2206
+ // reachable from both WSL and native Windows and BOTH sides already have a
2207
+ // ForgeDock install. Each environment's install is fully independent (see
2208
+ // #1589 — installs are always global, never per-repo), so running from
2209
+ // both silently leaves forge.yaml/hook paths pointing at whichever side ran
2210
+ // most recently, clobbering the other's config with no error surfaced.
2211
+ // Advisory only — never fails the install.
2212
+ const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
2213
+ const crossEnv = detectCrossEnvInstall(ctx, envInfo);
2214
+ if (crossEnv.conflict) {
2215
+ const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
2216
+ w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
2217
+ w.write(fixCard([
2218
+ `This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
2219
+ "install already exists. Each side's install is independent — running",
2220
+ "forgedock from both can leave forge.yaml and hook paths pointing at",
2221
+ "whichever environment ran last, silently overwriting the other's config.",
2222
+ "Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
2223
+ ], ctx.mode, "warning") + "\n");
2224
+ }
2225
+
2226
+ // Report enforcement hook status.
2227
+ if (preToolUseStatus !== null) {
2228
+ w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
2229
+ } else {
2230
+ w.write(" " + dimLine(ctx, "PreToolUse hook skipped — requires Claude Code v2.1.163+") + "\n");
2231
+ }
2232
+ // "skipped-malformed" and "absent" are intentionally silent here:
2233
+ // - skipped-malformed: installSessionStartHook() above reads the same
2234
+ // settingsPath and already prints the malformed-JSON fix-card, so a
2235
+ // second message would just repeat the same signal.
2236
+ // - absent: the expected steady state (nothing to remove) — this
2237
+ // function only reports state *changes*, not no-ops.
2238
+ if (subagentStopEnforceStatus === "removed") {
2239
+ w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
2240
+ }
2241
+ }
2242
+
1838
2243
  /**
1839
2244
  * Act II: link commands into ~/.claude/commands with a molten progress line,
1840
2245
  * then register the SessionStart hook.
@@ -1889,6 +2294,14 @@ export async function forge(ctx) {
1889
2294
  }
1890
2295
  manifestOps.push({ rel, op: "add" });
1891
2296
  };
2297
+ // Mirror of recordCopy for removals — keeps manifest.files, manifestChanged,
2298
+ // and the replayable manifestOps diff (forge#2614) mutating as one unit so
2299
+ // the in-memory manifest and the final merge-save replay never diverge.
2300
+ const recordDelete = (rel) => {
2301
+ delete manifest.files[rel];
2302
+ manifestChanged = true;
2303
+ manifestOps.push({ rel, op: "delete" });
2304
+ };
1892
2305
 
1893
2306
  for (let i = 0; i < files.length; i++) {
1894
2307
  const file = files[i];
@@ -1905,40 +2318,15 @@ export async function forge(ctx) {
1905
2318
  } else {
1906
2319
  let relinked = false;
1907
2320
  if (wantSymlink) {
1908
- // Suffix the tmp sibling with this process's PID so a losing
1909
- // concurrent forge() invocation's symlink() call never collides
1910
- // on the same literal path (which raises an uncaught EEXIST —
1911
- // isLinkPermissionError() only treats EPERM/EACCES as
1912
- // recoverable). Mirrors the copyFile branch and
1913
- // saveCopiedManifest fixes already applied in this file
1914
- // (forge#2542, forge#2599).
1915
- const tmpTarget = target + "." + process.pid + ".tmp";
1916
- try {
1917
- await symlink(file, tmpTarget);
1918
- try {
1919
- await rename(tmpTarget, target);
1920
- } catch (renameErr) {
1921
- await unlink(tmpTarget).catch(() => {});
1922
- throw renameErr;
1923
- }
1924
- relinked = await isSymlinkTraversable(target);
1925
- if (relinked) {
1926
- updated++;
1927
- } else {
1928
- // Symlink was created but is not readable back (e.g. MSYS
1929
- // symlink hit by native Windows "untrusted mount point" —
1930
- // forge#2620). Remove it and fall through to the copy branch.
1931
- await unlink(target).catch(() => {});
1932
- }
1933
- } catch (linkErr) {
1934
- if (!isLinkPermissionError(linkErr)) throw linkErr;
1935
- }
2321
+ relinked = (await atomicSymlinkInstall(file, target)) === "linked";
2322
+ if (relinked) updated++;
1936
2323
  }
1937
2324
  if (!relinked) {
1938
2325
  // Can't (or shouldn't) re-link — replace the managed link with a copy.
1939
2326
  // unlink first: copyFile onto a symlink writes THROUGH the link.
1940
- // (target may already be gone here if isSymlinkTraversable() failed
1941
- // and its own cleanup already removed it — tolerate ENOENT.)
2327
+ // (On "unreadable" the rename never happened so the old link is
2328
+ // still here; on "denied" likewise. ENOENT tolerated defensively
2329
+ // in case the entry vanished out from under us — forge#2679.)
1942
2330
  await unlink(target).catch((err) => {
1943
2331
  if (err.code !== "ENOENT") throw err;
1944
2332
  });
@@ -1948,174 +2336,17 @@ export async function forge(ctx) {
1948
2336
  }
1949
2337
  }
1950
2338
  } else if (manifest.files[rel]) {
1951
- // A regular file we copied on a previous run — ours to manage.
1952
- // First try upgrading it to a symlink (Developer Mode enabled since).
1953
- let upgraded = false;
1954
- let restoredAsCopy = false;
1955
- if (wantSymlink) {
1956
- // Same per-process-unique tmp suffix as the relink branch above —
1957
- // sibling branch, identical shared-literal-path EEXIST risk
1958
- // (forge#2542, forge#2599).
1959
- const tmpTarget = target + "." + process.pid + ".tmp";
1960
- try {
1961
- await symlink(file, tmpTarget);
1962
- try {
1963
- await rename(tmpTarget, target); // rename replaces existing files on Windows
1964
- } catch (renameErr) {
1965
- await unlink(tmpTarget).catch(() => {});
1966
- throw renameErr;
1967
- }
1968
- upgraded = await isSymlinkTraversable(target);
1969
- if (upgraded) {
1970
- updated++;
1971
- delete manifest.files[rel];
1972
- manifestChanged = true;
1973
- manifestOps.push({ rel, op: "delete" });
1974
- } else {
1975
- // Symlink was created but is not readable back (e.g. MSYS
1976
- // symlink hit by native Windows "untrusted mount point" —
1977
- // forge#2620). Restore a real copy so the command stays
1978
- // readable. Keep the manifest entry (still copy-managed, not
1979
- // symlink-managed) and do NOT fall through to the generic
1980
- // copy-comparison below — the copy is already done.
1981
- await unlink(target).catch(() => {});
1982
- await copyFile(file, target);
1983
- updated++;
1984
- restoredAsCopy = true;
1985
- }
1986
- } catch (linkErr) {
1987
- if (!isLinkPermissionError(linkErr)) throw linkErr;
1988
- }
1989
- }
1990
- if (!upgraded && !restoredAsCopy) {
1991
- const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1992
- if (src.equals(dst)) {
1993
- skipped++;
1994
- } else {
1995
- await copyFile(file, target);
1996
- updated++;
1997
- }
1998
- }
2339
+ const eff = await upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete });
2340
+ updated += eff.updatedDelta;
2341
+ skipped += eff.skippedDelta;
1999
2342
  } else {
2000
- // Regular file not in manifest — a copy from a pre-manifest ForgeDock
2001
- // version, or a stale copy that was never adopted. `rel` here is
2002
- // always enumerated from `files` (ForgeDock's own commandsDir
2003
- // listing, see above) — every path this loop touches is a
2004
- // ForgeDock-managed command file, never an arbitrary user file, so
2005
- // there is no "user-customized" case to protect. Content-compare: if
2006
- // it matches source, adopt it into the manifest silently (it was
2007
- // ours all along). If it differs, it's stale — overwrite it and
2008
- // adopt it into the manifest too, mirroring the manifest-tracked
2009
- // branch above, so future runs take the manifest-aware fast path.
2010
- let handled = false;
2011
- try {
2012
- const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
2013
- if (src.equals(dst)) {
2014
- recordCopy(rel);
2015
- skipped++;
2016
- handled = true;
2017
- } else {
2018
- // Write to a .tmp sibling and atomically rename onto target — never
2019
- // open target itself for a direct overwrite. copyFile() is not
2020
- // atomic: a mid-write failure (AV lock / ENOSPC / process kill)
2021
- // would otherwise leave target truncated/corrupted rather than
2022
- // simply stale, which the WARNING below cannot distinguish. This
2023
- // mirrors the .tmp+rename swap already used for symlink installs
2024
- // above (lines ~1573-1579, ~1601-1607).
2025
- //
2026
- // Unlike those symlink-based branches — where symlink() is itself
2027
- // exclusive-create and therefore naturally races out a concurrent
2028
- // writer — copyFile() has no such semantics: two concurrent
2029
- // forge() invocations (e.g. an overlapping `doctor --fix` run, or
2030
- // a file watcher) targeting the same file would both write
2031
- // through the same shared `target + ".tmp"` path and could
2032
- // interleave or clobber each other before either rename() runs.
2033
- // Suffix the tmp sibling with this process's PID so concurrent
2034
- // invocations never share a path (forge#2542).
2035
- const tmpTarget = target + "." + process.pid + ".tmp";
2036
- try {
2037
- await copyFile(file, tmpTarget);
2038
- } catch (copyErr) {
2039
- // A copyFile failure (disk full mid-copy, AV lock, permission
2040
- // error) can still leave a partially-written .tmp sibling on
2041
- // disk even though the write itself threw. The sibling
2042
- // rename() failure just below already cleans up tmpTarget
2043
- // on its own failure path (renameErr) — mirror that here so a
2044
- // copyFile failure doesn't orphan the .tmp file instead
2045
- // (forge#2540). Best-effort: never let the cleanup itself mask
2046
- // or replace the original copyErr being rethrown.
2047
- await unlink(tmpTarget).catch(() => {});
2048
- throw copyErr;
2049
- }
2050
- // Preserve the pre-existing content before it's replaced, mirroring
2051
- // half of the confirm+backup convention used by the forge.yaml
2052
- // overwrite path (bin/forgedock.mjs ~990-1021 / journey.mjs
2053
- // review(), both via backupExisting()). A confirmation prompt is
2054
- // deliberately NOT added: this branch also runs non-interactively
2055
- // inside `doctor --fix`'s automated repair (bin/forgedock.mjs
2056
- // runInstallRepairOnce() -> forge(ctx())), so pausing per-file for
2057
- // input would break that flow. Placed here — after the .tmp write
2058
- // has already succeeded, before the final rename — rather than
2059
- // before copyFile (as the forge.yaml path does), so a copyFile
2060
- // failure never backs up (and thereby displaces) a target that's
2061
- // about to remain untouched (see the .tmp-write-failure test,
2062
- // forge#2498). backupExisting()'s rename is itself atomic, so this
2063
- // ordering carries none of the non-atomic-write risk that forge#1396
2064
- // flagged for the old backup-then-writeFileSync forge.yaml path.
2065
- //
2066
- // Note: backupExisting() below is a *synchronous* rename
2067
- // (target -> target+".bak"); the tmpTarget -> target rename just
2068
- // after it is a separate, async filesystem operation. Between
2069
- // the two, target genuinely does not exist on disk for a
2070
- // sub-millisecond window. Only a hard process kill (not any
2071
- // catchable JS error) can land exactly there — any catchable
2072
- // failure of the second rename is already handled by the
2073
- // rollback block below — and recovery in that rare case is via
2074
- // the surviving target+".bak" on the next forge() run (forge#2558).
2075
- const backup = backupExisting(target);
2076
- if (backup) backedUp++;
2077
- try {
2078
- await rename(tmpTarget, target);
2079
- } catch (renameErr) {
2080
- await unlink(tmpTarget).catch(() => {});
2081
- if (backup) {
2082
- // The final rename failed AFTER backupExisting() already moved
2083
- // the original file to target+".bak" — target no longer exists
2084
- // on disk at this point. Without this rollback the user's file
2085
- // would simply vanish (present only as .bak) while the WARNING
2086
- // below implies it merely "could not be repaired". Restore the
2087
- // prior content so a failed repair is a no-op, matching the
2088
- // pre-backup invariant that a rename failure never removes the
2089
- // existing file (see the .tmp-write-failure test, forge#2498).
2090
- // WIRE:PROVEN — manual: reasoned, not exercised by an automated
2091
- // test. This branch requires rename(tmp, target) to fail on the
2092
- // exact call immediately after backupExisting()'s own rename of
2093
- // the same target succeeded — an OS-level race with no portable
2094
- // way to force deterministically (the sibling renameErr catches
2095
- // for the symlink-relink paths above, lines ~1576/~1604, are the
2096
- // same kind of defensive OS-failure branch and are likewise
2097
- // untested for the same reason). Verified by code inspection:
2098
- // rename() is fs/promises' rename. The rollback rename's own
2099
- // outcome is captured explicitly rather than assumed — if it
2100
- // also fails (e.g. .bak itself is locked), target+".bak" is
2101
- // left in place as the only remaining copy of the prior
2102
- // content, so backedUp must NOT be decremented in that case;
2103
- // it correctly reflects "a backup file still exists on disk"
2104
- // (forge#2559). Only on confirmed rollback success does
2105
- // backedUp-- keep the counter consistent with the restored
2106
- // on-disk state before renameErr is rethrown to the outer catch.
2107
- let rollbackOk = true;
2108
- await rename(target + ".bak", target).catch(() => { rollbackOk = false; });
2109
- if (rollbackOk) backedUp--;
2110
- }
2111
- throw renameErr;
2112
- }
2113
- recordCopy(rel);
2114
- updated++;
2115
- handled = true;
2116
- }
2117
- } catch { /* readFile/copyFile/rename failure — fall through to warning */ }
2118
- if (!handled) {
2343
+ const eff = await adoptOrRepairUnmanagedCopy(file, target, rel, { recordCopy });
2344
+ updated += eff.updatedDelta;
2345
+ skipped += eff.skippedDelta;
2346
+ backedUp += eff.backedUpDelta;
2347
+ if (!eff.handled) {
2348
+ // Terminal/progress-bar state stays here — helpers never touch
2349
+ // barShown or write to the stream.
2119
2350
  if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
2120
2351
  w.write(` WARNING: ${rel} could not be repaired — remove it manually to let ForgeDock manage it\n`);
2121
2352
  skipped++;
@@ -2123,48 +2354,9 @@ export async function forge(ctx) {
2123
2354
  }
2124
2355
  } catch (err) {
2125
2356
  if (err.code !== "ENOENT") throw err;
2126
- let linked = false;
2127
- if (wantSymlink) {
2128
- // Suffix the tmp sibling with this process's PID so a losing
2129
- // concurrent forge() invocation installing the same brand-new
2130
- // command never collides on the same literal target path (which
2131
- // raises an uncaught EEXIST — isLinkPermissionError() only treats
2132
- // EPERM/EACCES as recoverable). Mirrors the relink and copy-upgrade
2133
- // branches above (forge#2542, forge#2600, forge#2599).
2134
- const tmpTarget = target + "." + process.pid + ".tmp";
2135
- try {
2136
- await symlink(file, tmpTarget);
2137
- try {
2138
- await rename(tmpTarget, target);
2139
- } catch (renameErr) {
2140
- await unlink(tmpTarget).catch(() => {});
2141
- throw renameErr;
2142
- }
2143
- linked = await isSymlinkTraversable(target);
2144
- if (linked) {
2145
- installed++;
2146
- if (manifest.files[rel]) {
2147
- // Symlinks work now and the old copy is gone — drop the stale record.
2148
- delete manifest.files[rel];
2149
- manifestChanged = true;
2150
- manifestOps.push({ rel, op: "delete" });
2151
- }
2152
- } else {
2153
- // Symlink created but not readable back (e.g. MSYS symlink hit by
2154
- // native Windows "untrusted mount point" — forge#2620). Remove it
2155
- // and fall through to the copy path below.
2156
- await unlink(target).catch(() => {});
2157
- }
2158
- } catch (linkErr) {
2159
- if (!isLinkPermissionError(linkErr)) throw linkErr;
2160
- }
2161
- }
2162
- if (!linked) {
2163
- // Windows without Developer Mode/admin, or symlink unreadable — copy.
2164
- await copyFile(file, target);
2165
- copied++;
2166
- recordCopy(rel);
2167
- }
2357
+ const eff = await installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete });
2358
+ installed += eff.installedDelta;
2359
+ copied += eff.copiedDelta;
2168
2360
  }
2169
2361
 
2170
2362
  if (ctx.motion) {
@@ -2204,149 +2396,18 @@ export async function forge(ctx) {
2204
2396
  const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
2205
2397
 
2206
2398
  // Housekeeping — must never abort the receipt or the hook.
2399
+ // saveManifestWithMerge() owns the locked re-read → replay-manifestOps →
2400
+ // save sequence (forge#2614/forge#2637) and never throws.
2207
2401
  let manifestSaveFailed = false;
2208
2402
  if (manifestChanged) {
2209
- try {
2210
- // Re-read-and-merge (forge#2614): another concurrent forge() invocation
2211
- // may have saved its own manifest.files adds/deletes since this run's
2212
- // initial load above. Re-reading the on-disk manifest right before this
2213
- // run's own save and replaying only this run's own ops (manifestOps)
2214
- // onto that fresh copy — rather than blindly overwriting with the
2215
- // run-start in-memory snapshot — means this run's save can no longer
2216
- // silently drop the other run's changes. loadCopiedManifest() never
2217
- // throws (missing/corrupt on-disk manifest resolves to an empty one),
2218
- // so this stays within the surrounding never-abort contract.
2219
- //
2220
- // Lock the re-read→merge→write sequence itself (forge#2637): #2614
2221
- // narrowed the race from "whole run duration" to this critical section,
2222
- // but left it unprotected — a third concurrent forge() could still land
2223
- // its own save inside the gap between this run's re-read and its own
2224
- // write-rename completing. acquireManifestLock() is best-effort: on
2225
- // failure to acquire (contention exhausted retries, or an unexpected
2226
- // error) it returns null and this run proceeds unlocked, same as
2227
- // before — the lock narrows the window further, it does not change the
2228
- // never-abort contract.
2229
- const lockHandle = await acquireManifestLock(manifestPath);
2230
- try {
2231
- const mergedManifest = await loadCopiedManifest(manifestPath);
2232
- for (const { rel, op } of manifestOps) {
2233
- if (op === "add") {
2234
- mergedManifest.files[rel] = true;
2235
- } else {
2236
- delete mergedManifest.files[rel];
2237
- }
2238
- }
2239
- await saveCopiedManifest(manifestPath, mergedManifest);
2240
- } finally {
2241
- await releaseManifestLock(manifestPath, lockHandle);
2242
- }
2243
- } catch {
2244
- manifestSaveFailed = true;
2245
- }
2246
- }
2247
-
2248
- const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
2249
- const headlineVerb = copied > 0 ? "installed" : "linked";
2250
- const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
2251
- const headlineDetail = copied > 0
2252
- ? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
2253
- : `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
2254
- w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
2255
- if (pruned > 0) {
2256
- w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
2257
- }
2258
- if (staleExtensionlessPruned > 0) {
2259
- w.write(` ${glyph(true)} ${staleExtensionlessPruned} stale extensionless entr${staleExtensionlessPruned === 1 ? "y" : "ies"} removed ${dimLine(ctx, "(leftover from prior npx forgedock runs)")}\n`);
2260
- }
2261
- if (copied > 0) {
2262
- w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
2263
- }
2264
- if (scriptsResult.total > 0) {
2265
- const scriptsChanged = scriptsResult.installed + scriptsResult.updated + scriptsResult.copied;
2266
- const scriptsDetail = scriptsChanged > 0
2267
- ? `(new ${scriptsResult.installed}, updated ${scriptsResult.updated}, copied ${scriptsResult.copied}, unchanged ${scriptsResult.skipped})`
2268
- : `(unchanged ${scriptsResult.skipped})`;
2269
- w.write(` ${glyph(true)} ${scriptsResult.total} pipeline scripts linked into ~/.claude/scripts ${dimLine(ctx, scriptsDetail)}\n`);
2270
- }
2271
- if (manifestSaveFailed) {
2272
- w.write(" " + dimLine(ctx, "manifest not saved — re-runs may warn about copied files") + "\n");
2273
- }
2274
- if (hookStatus === "skipped-malformed") {
2275
- w.write(` ${glyph(false)} SessionStart hook NOT registered — ${settingsPath} is not valid JSON\n`);
2276
- w.write(fixCard([`Fix the JSON in ${settingsPath}, then re-run: npx forgedock install`], ctx.mode) + "\n");
2277
- } else {
2278
- w.write(` ${glyph(true)} SessionStart hook ${hookStatus === "already" ? "active" : "registered"} ${dimLine(ctx, settingsPath)}\n`);
2279
- }
2280
-
2281
- // Persisted-home / ephemeral-cache advisory (forge#1895, extended #1943):
2282
- // runJourney() calls persistHome(ctx) right before forge() and stashes its
2283
- // result on ctx.persistHomeResult; when persistHome() actually ran (i.e.
2284
- // wasn't skipped), ctx.forgeHome above has ALREADY been reassigned to the
2285
- // stable ~/.forge/ copy, so report that outcome instead of the raw
2286
- // ephemeral-cache warning. Direct forge() callers that never set
2287
- // ctx.persistHomeResult (e.g. relinkAndHint() in bin/forgedock.mjs) fall
2288
- // through to the original ephemeral-cache-only check unchanged.
2289
- const persistResult = ctx.persistHomeResult;
2290
- if (persistResult && !persistResult.skipped) {
2291
- if (persistResult.migrated) {
2292
- w.write(` ${glyph(true)} toolset migrated to persisted home ${dimLine(ctx, persistResult.forgeHome)}\n`);
2293
- } else {
2294
- w.write(` ${glyph(true)} persisted home already current ${dimLine(ctx, `v${persistResult.version || "unknown"} at ${persistResult.forgeHome}`)}\n`);
2295
- }
2296
- } else if (persistResult && persistResult.skipped && !isEphemeralCachePath(ctx.forgeHome)) {
2297
- // Skipped for a reason OTHER than "still ephemeral" — today that's always
2298
- // the git-working-tree exemption (Acceptance Criteria #5). Informational,
2299
- // not a warning: this is the expected, correct state for a git-clone install.
2300
- w.write(` ${dimLine(ctx, `persisted home skipped — ${persistResult.reason || "not applicable"}`)}\n`);
2301
- } else if (hookStatus !== "skipped-malformed" && isEphemeralCachePath(ctx.forgeHome)) {
2302
- // Either persistHome() was never run (no ctx.persistHomeResult), or it
2303
- // ran but failed and left ctx.forgeHome pointing at the original
2304
- // ephemeral source (fail-open — see persistHome()'s error branch).
2305
- w.write(` ${glyph(false)} FORGE_HOME resolves inside an ephemeral cache directory ${dimLine(ctx, ctx.forgeHome)}\n`);
2306
- w.write(fixCard([
2307
- "This path (npx/pnpm dlx/yarn dlx cache) can be pruned by npm/npx/pnpm/yarn",
2308
- "or OS temp cleanup. If that happens, the SessionStart hook silently stops",
2309
- "injecting ForgeDock context — no error is shown.",
2310
- "Mitigation: npm install -g forgedock (or re-run npx forgedock periodically).",
2311
- ], ctx.mode, "warning") + "\n");
2312
- }
2313
-
2314
- // Cross-environment install advisory (forge#1893): warn when this repo is
2315
- // reachable from both WSL and native Windows and BOTH sides already have a
2316
- // ForgeDock install. Each environment's install is fully independent (see
2317
- // #1589 — installs are always global, never per-repo), so running from
2318
- // both silently leaves forge.yaml/hook paths pointing at whichever side ran
2319
- // most recently, clobbering the other's config with no error surfaced.
2320
- // Advisory only — never fails the install.
2321
- const envInfo = detectEnvironment({ platform: ctx.platform, env: ctx.env, release: ctx.release });
2322
- const crossEnv = detectCrossEnvInstall(ctx, envInfo);
2323
- if (crossEnv.conflict) {
2324
- const otherLabel = crossEnv.direction === "windows" ? "a native Windows" : "a WSL";
2325
- w.write(` ${glyph(false)} ${otherLabel} ForgeDock install detected for this repo ${dimLine(ctx, crossEnv.otherPath)}\n`);
2326
- w.write(fixCard([
2327
- `This repo is reachable from both WSL and native Windows, and ${otherLabel}`,
2328
- "install already exists. Each side's install is independent — running",
2329
- "forgedock from both can leave forge.yaml and hook paths pointing at",
2330
- "whichever environment ran last, silently overwriting the other's config.",
2331
- "Mitigation: pick one environment for this repo, or sync forge.yaml manually.",
2332
- ], ctx.mode, "warning") + "\n");
2403
+ manifestSaveFailed = !(await saveManifestWithMerge(manifestPath, manifestOps));
2333
2404
  }
2334
2405
 
2335
- // Report enforcement hook status.
2336
- if (preToolUseStatus !== null) {
2337
- w.write(` ${glyph(true)} PreToolUse enforcement hook ${preToolUseStatus === "already" ? "active" : "registered"} ${dimLine(ctx, "(branch/label enforcement)")}\n`);
2338
- } else {
2339
- w.write(" " + dimLine(ctx, "PreToolUse hook skipped — requires Claude Code v2.1.163+") + "\n");
2340
- }
2341
- // "skipped-malformed" and "absent" are intentionally silent here:
2342
- // - skipped-malformed: installSessionStartHook() above reads the same
2343
- // settingsPath and already prints the malformed-JSON fix-card, so a
2344
- // second message would just repeat the same signal.
2345
- // - absent: the expected steady state (nothing to remove) — this
2346
- // function only reports state *changes*, not no-ops.
2347
- if (subagentStopEnforceStatus === "removed") {
2348
- w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
2349
- }
2406
+ renderForgeReceipt(ctx, {
2407
+ installed, updated, skipped, copied, backedUp, pruned, total: files.length,
2408
+ staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
2409
+ hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
2410
+ });
2350
2411
 
2351
2412
  return { installed, updated, skipped, copied, backedUp, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
2352
2413
  }