forgedock 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/forgedock.mjs CHANGED
@@ -1513,6 +1513,51 @@ function resolveGlobalNpmForgedockDir() {
1513
1513
  }
1514
1514
  }
1515
1515
 
1516
+ /**
1517
+ * Compare two already-`resolve()`d absolute paths for equality or containment
1518
+ * (child equals parent, or sits strictly under it). On case-insensitive
1519
+ * filesystems (win32/darwin) the same physical directory can resolve with
1520
+ * differing letter casing (drive letter, path segments), so both operands are
1521
+ * lowercased before comparing there; on Linux the comparison stays
1522
+ * case-sensitive byte-for-byte (forge#2668). On the case-insensitive branch
1523
+ * both operands are also Unicode-normalized to NFC: macOS APFS/HFS+ enumerate
1524
+ * filenames NFD-normalized while CLI args/env vars typically arrive NFC, and
1525
+ * `resolve()` preserves the input form — so without normalization two paths
1526
+ * for the same physical directory containing accented characters can compare
1527
+ * unequal (forge#2674). NFC is idempotent for pure-ASCII and pre-composed
1528
+ * paths, so ASCII behavior is unchanged; win32 normalizes harmlessly. The
1529
+ * `+ sep` boundary keeps `/foo-bar` from matching parent `/foo`. Callers pass
1530
+ * paths that are already resolved — this helper deliberately does not
1531
+ * re-resolve.
1532
+ *
1533
+ * Known limitation: `caseInsensitive` is gated on `process.platform`, an
1534
+ * OS-level constant, not on the actual case-sensitivity of the volume
1535
+ * FORGE_HOME/the npm global root live on. This is the correct assumption for
1536
+ * the default filesystem format on each OS (NTFS on win32, APFS/HFS+ on
1537
+ * darwin), but a volume explicitly formatted case-sensitive (e.g. opt-in
1538
+ * case-sensitive APFS) would still be case-folded here, and two distinct
1539
+ * directories differing only by case could be treated as equal. This is an
1540
+ * accepted approximation, not a probe of real volume semantics — both call
1541
+ * sites (`isGlobalNpmInstall`, `findSeparateGlobalInstall`) already fail
1542
+ * closed / stay advisory-only, so a false match here degrades to their
1543
+ * pre-existing "detection inconclusive" behavior rather than causing any
1544
+ * destructive or incorrect action (forge#2675). Probing the actual volume
1545
+ * (e.g. an `existsSync` case-twist check) is deliberately not done — the
1546
+ * added complexity and syscall aren't warranted for a purely advisory path.
1547
+ *
1548
+ * @param {string} child - resolved absolute path being tested
1549
+ * @param {string} parent - resolved absolute path of the candidate ancestor
1550
+ * @returns {boolean}
1551
+ */
1552
+ function samePathOrUnder(child, parent) {
1553
+ const caseInsensitive =
1554
+ process.platform === "win32" || process.platform === "darwin";
1555
+ const fold = (p) => (caseInsensitive ? p.normalize("NFC").toLowerCase() : p);
1556
+ const a = fold(child);
1557
+ const b = fold(parent);
1558
+ return a === b || a.startsWith(b + sep);
1559
+ }
1560
+
1516
1561
  /**
1517
1562
  * Detect whether FORGE_HOME resolves inside npm's global install tree, i.e.
1518
1563
  * this process is running as a globally-installed `forgedock` package (not
@@ -1533,7 +1578,7 @@ function isGlobalNpmInstall() {
1533
1578
  const globalPkgDir = resolveGlobalNpmForgedockDir();
1534
1579
  if (!globalPkgDir) return false;
1535
1580
  const home = resolve(FORGE_HOME);
1536
- return home === globalPkgDir || home.startsWith(globalPkgDir + sep);
1581
+ return samePathOrUnder(home, globalPkgDir);
1537
1582
  }
1538
1583
 
1539
1584
  /**
@@ -1558,7 +1603,7 @@ function findSeparateGlobalInstall() {
1558
1603
  const globalPkgDir = resolveGlobalNpmForgedockDir();
1559
1604
  if (!globalPkgDir) return null;
1560
1605
  const home = resolve(FORGE_HOME);
1561
- if (home === globalPkgDir || home.startsWith(globalPkgDir + sep)) {
1606
+ if (samePathOrUnder(home, globalPkgDir)) {
1562
1607
  // Current FORGE_HOME already IS the global install — nothing separate.
1563
1608
  return null;
1564
1609
  }
@@ -1804,9 +1849,10 @@ async function update() {
1804
1849
  // dirty tree. HEAD is still never moved in either case.
1805
1850
  const cwdResolved = resolve(process.cwd());
1806
1851
  const forgeHomeResolved = resolve(FORGE_HOME);
1807
- const isSourceRepoSelf =
1808
- cwdResolved === forgeHomeResolved ||
1809
- cwdResolved.startsWith(forgeHomeResolved + sep);
1852
+ const isSourceRepoSelf = samePathOrUnder(
1853
+ cwdResolved,
1854
+ forgeHomeResolved,
1855
+ );
1810
1856
 
1811
1857
  if (isSourceRepoSelf) {
1812
1858
  console.log(
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
  }
@@ -29,7 +29,7 @@ COMPLEXITY_BAND=$(gh api repos/{GH_REPO}/issues/{NUMBER}/comments \
29
29
  COMPLEXITY_BAND="${COMPLEXITY_BAND:-STANDARD}"
30
30
  ```
31
31
 
32
- **If COMPLEXITY_BAND: TRIVIAL** → skip all phases (A0 through A5), post NO comment, return empty plan to caller immediately. Do not read any files. This is not an error — trivial single-file changes have no cross-path consistency risk. <!-- Added: forge#679 -->
32
+ **If COMPLEXITY_BAND: TRIVIAL** → skip all phases (A0 through A5), **post the minimal skip marker comment described in "Skip Marker" under Skip Conditions below** (reason: `TRIVIAL complexity band`), then return an empty plan to caller immediately. Do not read any files. This is not an error — trivial single-file changes have no cross-path consistency risk. The skip marker (ending in `<!-- FORGE:ARCHITECT:COMPLETE -->`) is what makes a legitimate skip observable to the headless engine's marker-only gate, so the issue advances to build instead of being stranded at `needs-human`. <!-- Added: forge#679; skip-marker on skip: forge#2689 -->
33
33
 
34
34
  **If COMPLEXITY_BAND: STANDARD or COMPLEX** → proceed to Phase A0 below.
35
35
 
@@ -668,14 +668,39 @@ gh issue comment {NUMBER} {GH_FLAG} --body "<!-- FORGE:ARCHITECT -->
668
668
 
669
669
  ## Skip Conditions
670
670
 
671
- Skip this entire step (post nothing, return empty plan to caller) if:
671
+ Skip the planning work (Phases A0–A5) and return an empty plan to caller — **but always post the Skip Marker below first** — if:
672
672
  - **COMPLEXITY_BAND: TRIVIAL** — checked via FORGE:FAST_PATH comment at entry (see guard above) <!-- Primary skip path: forge#679 -->
673
673
  - Issue creates only **new files** with no callers to find (e.g. a new command file with no existing integration point yet)
674
674
  - Issue is a 1-file config or docs edit with no code logic
675
675
  - Issue title starts with "docs:" or "chore:"
676
676
  - `{AFFECTED_FILES}` is empty
677
677
 
678
- When skipped, the builder proceeds with investigation report + context briefing only.
678
+ When skipped, the builder proceeds with investigation report + context briefing only (see Integration Point below — the builder falls back gracefully on an empty plan).
679
+
680
+ ### Skip Marker (MANDATORY on every skip — do NOT post nothing) <!-- forge#2689 -->
681
+
682
+ **Do not post nothing.** The headless engine gate (`bin/engine/phases.mjs` → `architect.detectOutcome`) treats a **missing** `FORGE:ARCHITECT:COMPLETE` sentinel as a phase **failure**, retries to `maxAttempts` (3), then strands the issue at `needs-human`. It reads GitHub comment markers only — it cannot see this subcommand's in-process "empty plan" return value. So a skip that posts no marker is indistinguishable from a crashed architect run and deterministically strands **every** `chore:`/`docs:`/trivial issue.
683
+
684
+ On **every** skip branch above, post exactly this minimal comment (substitute the concrete `{SKIP_REASON}`, e.g. `chore:/docs: title`, `TRIVIAL complexity band`, `new files only — no callers`, or `AFFECTED_FILES empty`) **before** returning the empty plan:
685
+
686
+ ```bash
687
+ # The skip marker is mandatory and un-gated by design — posting it IS the point
688
+ # (a missing marker strands the issue at needs-human; see above). Same report-only
689
+ # risk class as the unguarded plan post in "Output Format" above, so it carries the
690
+ # same allowlist rather than a DRY_RUN guard.
691
+ SKIP_BODY=$(cat <<SKIP_EOF
692
+ <!-- FORGE:ARCHITECT -->
693
+ ## Architecture Plan — Skipped
694
+
695
+ **Skipped**: {SKIP_REASON}. No cross-path consistency risk — the builder proceeds with the investigation report + context briefing.
696
+
697
+ <!-- FORGE:ARCHITECT:COMPLETE -->
698
+ SKIP_EOF
699
+ )
700
+ gh issue comment {NUMBER} {GH_FLAG} --body "$SKIP_BODY" # <!-- allowlist:check-command-side-effects -->
701
+ ```
702
+
703
+ The empty-plan-with-marker changes no downstream behavior — the builder already falls back to the investigation report + contract when the plan is empty (see Integration Point below). The marker only makes the legitimate skip **observable** to the marker-only engine gate. This intentionally does **not** weaken crash detection: a genuinely interrupted architect run still posts no marker at all, so `detectOutcome` still fails-and-escalates it correctly.
679
704
 
680
705
  ---
681
706
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgedock",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Autonomous dev pipeline for Claude Code. GitHub as institutional memory — every issue, PR, and annotation survives context resets and makes the next agent smarter.",
5
5
  "bin": {
6
6
  "forgedock": "bin/forgedock.mjs"
@@ -48,7 +48,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, rea
48
48
  import { execSync, spawnSync } from 'child_process';
49
49
  import { homedir } from 'os';
50
50
  import { dirname, join, resolve } from 'path';
51
- import { fileURLToPath } from 'url';
51
+ import { fileURLToPath, pathToFileURL } from 'url';
52
52
 
53
53
  const __filename = fileURLToPath(import.meta.url);
54
54
  const __dirname = dirname(__filename);
@@ -309,7 +309,11 @@ async function getParser() {
309
309
  const parsePath = join(repoRoot, 'packages', 'protocol', 'src', 'parse.js');
310
310
 
311
311
  try {
312
- const mod = await import(parsePath);
312
+ // On win32 a bare absolute path (C:\...) makes Node parse the drive letter as a
313
+ // URL scheme (ERR_UNSUPPORTED_ESM_URL_SCHEME). Convert to a file:// URL first —
314
+ // valid on POSIX too, and matches the import(pathToFileURL(...).href) idiom used
315
+ // at check-admission-parity.mjs / validate-annotation-node.mjs / check-phase-registry-drift.mjs.
316
+ const mod = await import(pathToFileURL(parsePath).href);
313
317
  _parseAnnotation = mod.parse;
314
318
  return _parseAnnotation;
315
319
  } catch (e) {
@@ -126,11 +126,15 @@ fi
126
126
  # Portable (non-PCRE) extraction — grep -oP is not supported by Git Bash's
127
127
  # grep build on Windows, so use a bash regex + BASH_REMATCH instead of a
128
128
  # lookbehind (same convention as scripts/code-index.sh).
129
+ # The leading (^|[^a-z]) group anchors the alternation to a word start so a
130
+ # close-verb embedded in a larger word (e.g. "encloses #1234") cannot match;
131
+ # the body is already lowercased, so [^a-z] is the correct complement class.
132
+ # The extra group shifts the issue-number capture to BASH_REMATCH[3].
129
133
  PR_BODY=$(echo "$PR_JSON" | jq -r '.body // empty')
130
134
  SOURCE_ISSUE=""
131
135
  PR_BODY_LOWER=$(echo "$PR_BODY" | tr '[:upper:]' '[:lower:]')
132
- if [[ "$PR_BODY_LOWER" =~ (closes|fixes|resolves)[[:space:]]*#([0-9]+) ]]; then
133
- SOURCE_ISSUE="${BASH_REMATCH[2]}"
136
+ if [[ "$PR_BODY_LOWER" =~ (^|[^a-z])(closes|fixes|resolves)[[:space:]]*#([0-9]+) ]]; then
137
+ SOURCE_ISSUE="${BASH_REMATCH[3]}"
134
138
  fi
135
139
 
136
140
  if [ -n "$SOURCE_ISSUE" ]; then
@@ -167,12 +171,18 @@ if [ -n "$MILESTONE_BRANCH" ]; then
167
171
  ALL_MILESTONES=$(gh api "repos/${MILESTONES_REPO}/milestones" 2>/dev/null || echo "")
168
172
 
169
173
  if [ -n "$ALL_MILESTONES" ] && [ "$ALL_MILESTONES" != "[]" ]; then
170
- MATCH=$(echo "$ALL_MILESTONES" | jq -r --arg slug "$BRANCH_SLUG" \
171
- '.[] | select((.title | ascii_downcase | gsub("[^a-z0-9]+"; "-")) == $slug) | .title' | head -1)
172
- if [ -z "$MATCH" ]; then
173
- MATCH=$(echo "$ALL_MILESTONES" | jq -r --arg slug "$BRANCH_SLUG" \
174
- '.[] | select((.title | ascii_downcase | gsub("[^a-z0-9]+"; "-")) | test($slug)) | .title' | head -1)
175
- fi
174
+ # Single jq invocation: shared `def slugify` replaces the duplicated
175
+ # downcase/gsub slug transform that previously appeared once per tier
176
+ # (exact-match, then substring-fallback). Exact match is tried first
177
+ # (array 1), substring match second (array 2); `first` picks the winner
178
+ # from the concatenation, preserving the exact-match-wins-over-substring
179
+ # ordering and the regex semantics of `test($slug)` exactly as before.
180
+ # <!-- Fixes: forge#2670 -->
181
+ MATCH=$(echo "$ALL_MILESTONES" | jq -r --arg slug "$BRANCH_SLUG" '
182
+ def slugify: ascii_downcase | gsub("[^a-z0-9]+"; "-");
183
+ ([.[] | select((.title | slugify) == $slug)]
184
+ + [.[] | select((.title | slugify) | test($slug))])
185
+ | (first.title // empty)')
176
186
  if [ -n "$MATCH" ]; then
177
187
  echo "$MATCH"
178
188
  exit 0