forgedock 1.4.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/journey.mjs CHANGED
@@ -747,9 +747,9 @@ export async function preflight(ctx) {
747
747
  // Act II — Forging: command symlinks + SessionStart hook (Task 6)
748
748
  // ---------------------------------------------------------------------------
749
749
 
750
- import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink, rm } from "fs/promises";
750
+ import { mkdir, symlink, readlink, lstat, readdir, rename, copyFile, readFile, writeFile, unlink, rm, open } from "fs/promises";
751
751
  import { compareVersions } from "./registry.mjs";
752
- import { relative, dirname as pathDirname } from "path";
752
+ import { relative, dirname as pathDirname, isAbsolute } from "path";
753
753
  import {
754
754
  installSessionStartHook,
755
755
  installSubagentStopHook,
@@ -855,12 +855,278 @@ async function loadCopiedManifest(manifestPath) {
855
855
  /** Save the manifest atomically (mkdir recursive + .tmp+rename). */
856
856
  async function saveCopiedManifest(manifestPath, manifest) {
857
857
  await mkdir(pathDirname(manifestPath), { recursive: true });
858
- await writeFile(manifestPath + ".tmp", JSON.stringify(manifest, null, 2) + "\n", "utf-8");
859
- await rename(manifestPath + ".tmp", manifestPath);
858
+ // Suffix the tmp sibling with this process's PID so concurrent forge()
859
+ // invocations never share the same tmp path — mirrors the fix already
860
+ // applied to the copy-file branch in this same function (forge#2542).
861
+ const tmpPath = manifestPath + "." + process.pid + ".tmp";
862
+ try {
863
+ await writeFile(tmpPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
864
+ } catch (writeErr) {
865
+ await unlink(tmpPath).catch(() => {});
866
+ throw writeErr;
867
+ }
868
+ try {
869
+ await rename(tmpPath, manifestPath);
870
+ } catch (renameErr) {
871
+ await unlink(tmpPath).catch(() => {});
872
+ throw renameErr;
873
+ }
874
+ }
875
+
876
+ // Age (ms) below which a pid-suffixed manifest tmp sibling is assumed to be a
877
+ // concurrent forge()'s in-flight write and left untouched. Anything older is a
878
+ // crash-orphan (SIGKILL/OOM/power loss between writeFile and rename) that no
879
+ // future pid-matched run and no other cleanup path will ever reclaim
880
+ // (forge#2612). Generous relative to real write→rename latency.
881
+ const STALE_MANIFEST_TMP_AGE_MS = 60_000;
882
+
883
+ // Age (ms) above which a held manifest lock file is assumed to be a
884
+ // crash-orphan (the holder died between acquiring the lock and releasing it)
885
+ // rather than a live concurrent holder, and is safe to reclaim. Deliberately
886
+ // shorter than STALE_MANIFEST_TMP_AGE_MS (60s, forge#2612) rather than
887
+ // matching it: the lock's critical section (one readFile+JSON.parse, an
888
+ // in-memory loop, one writeFile+rename) is far lighter than the write this
889
+ // process guards against staleness in the tmp-sweep case, so a live holder
890
+ // should never legitimately hold this lock anywhere near that long. Not set
891
+ // as low as the original 10s either — that left too little headroom under
892
+ // slow I/O (AV scanning, network home dirs, loaded CI), which could plausibly
893
+ // exceed 10s and trigger premature reclaim of a still-live lock (review
894
+ // finding on PR #2654, forge#2655). 30s is a documented middle ground: 3x the
895
+ // original headroom, still 2x shorter than the tmp precedent.
896
+ const STALE_MANIFEST_LOCK_AGE_MS = 30_000;
897
+
898
+ // Bounded retry/backoff for acquireManifestLock (forge#2637). Total worst-case
899
+ // wait is small (a few hundred ms) — long enough to let a concurrent holder's
900
+ // short critical section finish, short enough that a stuck/foreign lock never
901
+ // meaningfully delays a forge() run before falling back to unlocked behavior.
902
+ const MANIFEST_LOCK_RETRY_DELAYS_MS = [10, 20, 40, 80, 150];
903
+
904
+ /**
905
+ * Acquire an exclusive lock guarding the manifest's final read→merge→write
906
+ * critical section (forge#2637). Uses `fs.open(lockPath, 'wx')` — O_EXCL
907
+ * create — as a zero-dependency mutual-exclusion primitive: the call fails
908
+ * with EEXIST if another process already holds the lock, and succeeds
909
+ * atomically otherwise (no separate exists-check + create races).
910
+ *
911
+ * On contention, retries with the bounded backoff schedule above. Each retry
912
+ * also opportunistically busts a stale lock (older than
913
+ * STALE_MANIFEST_LOCK_AGE_MS — a crash-orphan from a holder that died before
914
+ * releasing) so a hard-killed process can never permanently deadlock future
915
+ * runs.
916
+ *
917
+ * Never throws. Returns the open file handle on success, or `null` if the
918
+ * lock could not be acquired after all retries — callers MUST treat `null`
919
+ * as "proceed without the lock" (this file's manifest-save path is
920
+ * best-effort/never-abort by design; the lock narrows the race window, it is
921
+ * not a hard correctness requirement).
922
+ */
923
+ async function acquireManifestLock(manifestPath) {
924
+ const lockPath = manifestPath + ".lock";
925
+ for (let attempt = 0; attempt <= MANIFEST_LOCK_RETRY_DELAYS_MS.length; attempt++) {
926
+ try {
927
+ return await open(lockPath, "wx");
928
+ } catch (err) {
929
+ if (err?.code !== "EEXIST") return null; // unexpected error (e.g. EACCES) — proceed unlocked
930
+ // Contended — opportunistically reclaim a stale (crash-orphaned) lock.
931
+ let reclaimed = false;
932
+ try {
933
+ const st = await lstat(lockPath);
934
+ if (Date.now() - st.mtimeMs >= STALE_MANIFEST_LOCK_AGE_MS) {
935
+ // Re-stat immediately before deleting and only unlink if it's
936
+ // provably the SAME file we just judged stale (matching inode +
937
+ // mtime) — a plain lstat-then-unlink-by-path has a window where
938
+ // the original holder releases and a brand-new live holder
939
+ // acquires a fresh lock at this same path in between; unlinking
940
+ // by path alone would then delete that new holder's live lock,
941
+ // reproducing the exact race this lock exists to prevent, one
942
+ // layer down (review finding on PR #2654). If the file changed
943
+ // (or vanished) between the two stats, leave it alone — some
944
+ // other process has since claimed or released the path — and let
945
+ // the next retry iteration re-evaluate from scratch.
946
+ try {
947
+ const st2 = await lstat(lockPath);
948
+ if (st2.ino === st.ino && st2.mtimeMs === st.mtimeMs) {
949
+ await unlink(lockPath).catch(() => {});
950
+ reclaimed = true;
951
+ }
952
+ } catch {
953
+ // vanished between the two stats — already released, nothing to reclaim
954
+ }
955
+ }
956
+ } catch {
957
+ // lock file vanished between the failed open and this stat (the
958
+ // holder released it) — fall through to the next attempt/retry.
959
+ }
960
+ // A successful reclaim just proved the path is free — retry open()
961
+ // immediately instead of waiting out the full backoff delay for this
962
+ // iteration (review finding on PR #2654, forge#2656). Still counts as
963
+ // a used attempt (loop counter still advances), it just skips the
964
+ // sleep.
965
+ if (reclaimed) continue;
966
+ if (attempt === MANIFEST_LOCK_RETRY_DELAYS_MS.length) return null; // retries exhausted
967
+ await new Promise((resolve) => setTimeout(resolve, MANIFEST_LOCK_RETRY_DELAYS_MS[attempt]));
968
+ }
969
+ }
970
+ return null;
971
+ }
972
+
973
+ /**
974
+ * Release a lock acquired by acquireManifestLock() (forge#2637). Closes the
975
+ * handle and removes the lock file. Never throws — same best-effort contract
976
+ * as the rest of the manifest-save path; a failure to release cleanly is
977
+ * recovered by the next contender's stale-lock reclaim in
978
+ * acquireManifestLock() rather than by this function succeeding.
979
+ */
980
+ async function releaseManifestLock(manifestPath, handle) {
981
+ if (!handle) return;
982
+ await handle.close().catch(() => {});
983
+ await unlink(manifestPath + ".lock").catch(() => {});
984
+ }
985
+
986
+ /**
987
+ * Best-effort sweep of crash-orphaned pid-suffixed manifest tmp siblings
988
+ * (forge#2612). saveCopiedManifest() writes to `manifestPath.<pid>.tmp` then
989
+ * renames it into place; if the process is hard-killed in that window the
990
+ * pid-suffixed tmp is orphaned permanently — no future run reuses that exact
991
+ * pid (unlike the old shared literal `.tmp` name any run would overwrite) and
992
+ * no other cleanup path scans this directory. Removes siblings matching
993
+ * `<manifest-basename>.<digits>.tmp` that are older than
994
+ * STALE_MANIFEST_TMP_AGE_MS (so a concurrent forge()'s in-flight tmp is never
995
+ * deleted) and are not this process's own live tmp. Never throws —
996
+ * housekeeping only, same never-abort contract as the manifest-save guard.
997
+ */
998
+ async function sweepStaleManifestTmps(manifestPath) {
999
+ const dir = pathDirname(manifestPath);
1000
+ const base = basename(manifestPath);
1001
+ const liveTmp = base + "." + process.pid + ".tmp";
1002
+ // Match only `<base>.<digits>.tmp` — the exact pid-suffixed shape. The legacy
1003
+ // plain `<base>.tmp` (no digit segment) is intentionally excluded so a
1004
+ // foreign/legacy sibling is never reclaimed here.
1005
+ const stalePattern = new RegExp(
1006
+ "^" + base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\.\\d+\\.tmp$",
1007
+ );
1008
+ let entries;
1009
+ try {
1010
+ entries = await readdir(dir);
1011
+ } catch {
1012
+ return; // dir missing/unreadable — nothing to reclaim
1013
+ }
1014
+ const now = Date.now();
1015
+ await Promise.all(
1016
+ entries.map(async (name) => {
1017
+ if (name === liveTmp || !stalePattern.test(name)) return;
1018
+ const full = join(dir, name);
1019
+ try {
1020
+ const st = await lstat(full);
1021
+ if (now - st.mtimeMs < STALE_MANIFEST_TMP_AGE_MS) return; // maybe in-flight
1022
+ await unlink(full);
1023
+ } catch {
1024
+ // best-effort — ignore races / permission errors
1025
+ }
1026
+ }),
1027
+ );
860
1028
  }
861
1029
 
862
1030
  const isLinkPermissionError = (err) => err.code === "EPERM" || err.code === "EACCES";
863
1031
 
1032
+ /**
1033
+ * Verify a freshly created symlink is actually readable by *this* process
1034
+ * before trusting it. `fs.symlink()` can return successfully (no EPERM/
1035
+ * EACCES — so `isLinkPermissionError()` never fires) while still producing a
1036
+ * link the OS refuses to resolve for this security/runtime context. The
1037
+ * concrete case this guards against (forge#2620): installing under Git Bash
1038
+ * (MSYS2) on Windows creates an MSYS-style symlink; the reparse point itself
1039
+ * is written successfully, but a native Windows process (the Node binary
1040
+ * behind Claude Code) later hits `"The path cannot be traversed because it
1041
+ * contains an untrusted mount point"` when it tries to read through it. That
1042
+ * failure surfaces at *read* time, not at *creation* time, and with an error
1043
+ * code (`UNKNOWN`/`EIO`, not `EPERM`/`EACCES`) that the existing permission
1044
+ * gate doesn't recognize — so the copy-fallback path never engaged and a
1045
+ * dead, unreadable link was left in place.
1046
+ *
1047
+ * Reading the link back immediately after creating it catches this (and any
1048
+ * other silently-broken-symlink case) regardless of the specific error code
1049
+ * involved, without needing to enumerate every possible OS/runtime error.
1050
+ *
1051
+ * @param {string} target - Path to the symlink just created.
1052
+ * @returns {Promise<boolean>} true if the symlink resolves and is readable.
1053
+ */
1054
+ export async function isSymlinkTraversable(target) {
1055
+ try {
1056
+ await readFile(target);
1057
+ return true;
1058
+ } catch {
1059
+ return false;
1060
+ }
1061
+ }
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
+
864
1130
  /**
865
1131
  * Allowlist of universal pipeline-agent scripts installed to
866
1132
  * ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
@@ -941,6 +1207,108 @@ async function pruneOrphanedSymlinks(targetDir, commandsDir) {
941
1207
  return pruned;
942
1208
  }
943
1209
 
1210
+ /**
1211
+ * Remove leftover extensionless entries at the top level of targetDir left
1212
+ * behind by prior `npx forgedock <command>` runs (forge#2620). Those older
1213
+ * ephemeral invocations created extensionless symlinks/directories (e.g.
1214
+ * `orchestrate`, `pipeline-health`) pointing into the npx download cache
1215
+ * (`%LocalAppData%\npm-cache\_npx\...` / `~/.npm/_npx/...`). Those cache
1216
+ * targets are routinely evicted by npx/npm cache cleanup or OS temp
1217
+ * cleanup, leaving dead entries that collide on the base command name with
1218
+ * the real `{name}.md` file this installer is about to create —
1219
+ * `mkdir(..., {recursive:true})` throws when a same-named non-directory
1220
+ * file already occupies that path segment.
1221
+ *
1222
+ * Conservative by design — must run BEFORE the main install loop so stale
1223
+ * entries are cleared before any mkdir/symlink attempt can collide with
1224
+ * them this run (pruneOrphanedSymlinks above only catches entries that
1225
+ * point *into commandsDir*, and runs after the loop; this catches the
1226
+ * separate npx-cache-origin case pre-emptively). Only removes a top-level
1227
+ * entry when its basename has no file extension (`.md` files and dotfiles
1228
+ * are never touched) AND it is a symlink whose target either no longer
1229
+ * exists or resolves into a known ephemeral cache path
1230
+ * (`isEphemeralCachePath`). Real command directories (e.g. `work-on/`)
1231
+ * created by this installer are plain directories containing `.md` files,
1232
+ * never symlinks — untouched by either condition.
1233
+ *
1234
+ * @param {string} targetDir - ~/.claude/commands
1235
+ * @returns {Promise<number>} Number of stale entries removed.
1236
+ */
1237
+ // Matches a Windows drive-relative path like "C:foo" (drive letter + colon,
1238
+ // NOT followed by a path separator) — as opposed to a drive-absolute path
1239
+ // like "C:\foo" or "C:/foo", which path.isAbsolute() already recognizes
1240
+ // correctly. A drive-relative target resolves relative to that drive's own
1241
+ // current working directory, which Node has no API to query — there is no
1242
+ // way to compute the correct absolute path here (review finding on PR #2658,
1243
+ // forge#2659). Deliberately narrow: only fires when isAbsolute() has already
1244
+ // said "no" and the string still starts with `<letter>:` immediately
1245
+ // followed by a non-separator character. Platform-gated (review finding
1246
+ // forge#2663): on POSIX, `:` has no special meaning in filenames, so a
1247
+ // literal relative target like `C:foo` is a perfectly valid path component
1248
+ // and must NOT be misclassified as an unresolvable Windows drive-relative
1249
+ // path — only apply this heuristic on win32.
1250
+ const WINDOWS_DRIVE_RELATIVE_RE = /^[A-Za-z]:(?![\\/])/;
1251
+
1252
+ export async function pruneStaleExtensionlessEntries(targetDir) {
1253
+ let pruned = 0;
1254
+ let entries;
1255
+ try {
1256
+ entries = await readdir(targetDir, { withFileTypes: true });
1257
+ } catch (err) {
1258
+ if (err.code === "ENOENT") return pruned;
1259
+ throw err;
1260
+ }
1261
+ for (const entry of entries) {
1262
+ const name = entry.name;
1263
+ if (name.includes(".")) continue; // has an extension (or is a dotfile) — never touch
1264
+ if (!entry.isSymbolicLink()) continue; // only manage symlinks here — real dirs/files are left alone
1265
+ const full = join(targetDir, name);
1266
+ let linkTarget;
1267
+ try {
1268
+ linkTarget = await readlink(full);
1269
+ } catch {
1270
+ continue; // unreadable link metadata — skip rather than guess
1271
+ }
1272
+ // readlink() returns whatever string was stored at symlink-creation time.
1273
+ // A relative target is defined (POSIX) relative to the symlink's own
1274
+ // containing directory (targetDir, since full = join(targetDir, name)) —
1275
+ // not the process cwd, which is what a raw lstat(linkTarget) would use.
1276
+ //
1277
+ // Two shapes isAbsolute() alone misclassifies as "relative" and would
1278
+ // otherwise be wrongly joined onto targetDir:
1279
+ // - Windows drive-relative ("C:foo") — see WINDOWS_DRIVE_RELATIVE_RE
1280
+ // above; unresolvable here, so the entry is left untouched. Only
1281
+ // meaningful on win32 (review finding forge#2663) — on POSIX, `:` is
1282
+ // just an ordinary filename character, so this check is skipped
1283
+ // there and the target falls through to normal relative resolution.
1284
+ // - Shell-style tilde ("~/foo", "~\foo", or bare "~") — expand against
1285
+ // os.homedir() first, matching shell semantics (review finding on
1286
+ // PR #2658, forge#2660).
1287
+ if (process.platform === "win32" && WINDOWS_DRIVE_RELATIVE_RE.test(linkTarget)) continue; // can't resolve without the drive's own cwd — leave alone
1288
+ let effectiveTarget = linkTarget;
1289
+ if (effectiveTarget === "~" || effectiveTarget.startsWith("~/") || effectiveTarget.startsWith("~\\")) {
1290
+ effectiveTarget = join(os.homedir(), effectiveTarget.slice(1));
1291
+ }
1292
+ const resolvedTarget = isAbsolute(effectiveTarget) ? effectiveTarget : join(targetDir, effectiveTarget);
1293
+ let targetMissing = false;
1294
+ try {
1295
+ await lstat(resolvedTarget);
1296
+ } catch (err) {
1297
+ if (err.code !== "ENOENT") continue; // unexpected error — skip to be safe
1298
+ targetMissing = true;
1299
+ }
1300
+ if (targetMissing || isEphemeralCachePath(linkTarget)) {
1301
+ try {
1302
+ await unlink(full);
1303
+ pruned++;
1304
+ } catch (err) {
1305
+ if (err.code !== "ENOENT") throw err;
1306
+ }
1307
+ }
1308
+ }
1309
+ return pruned;
1310
+ }
1311
+
944
1312
  /**
945
1313
  * Detect whether `p` sits inside a known ephemeral npm/npx/pnpm/yarn cache
946
1314
  * directory rather than a durable install location (global npm install,
@@ -1378,12 +1746,7 @@ async function linkPipelineScripts(ctx) {
1378
1746
 
1379
1747
  let linked = false;
1380
1748
  if (wantSymlink) {
1381
- try {
1382
- await symlink(file, target);
1383
- linked = true;
1384
- } catch (linkErr) {
1385
- if (!isLinkPermissionError(linkErr)) throw linkErr;
1386
- }
1749
+ linked = (await atomicSymlinkInstall(file, target)) === "linked";
1387
1750
  }
1388
1751
  if (!linked) {
1389
1752
  await copyFile(file, target);
@@ -1520,208 +1883,272 @@ export function detectCrossEnvInstall(ctx, envInfo, deps = {}) {
1520
1883
  }
1521
1884
 
1522
1885
  /**
1523
- * Act II: link commands into ~/.claude/commands with a molten progress line,
1524
- * then register the SessionStart hook.
1525
- * Symlink semantics preserved verbatim from the original install():
1526
- * regular files are skipped with a warning; changed links updated atomically.
1527
- * On Windows without Developer Mode (symlink → EPERM/EACCES) each command is
1528
- * copied instead, and recorded in a manifest so re-runs can tell ForgeDock's
1529
- * own copies apart from user-owned files. ctx.linkStrategy ("symlink" default)
1530
- * can be set to "copy" to skip all symlink attempts (deterministic tests).
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.
1531
1890
  */
1532
- export async function forge(ctx) {
1533
- const { stdout: w } = ctx;
1534
- const commandsDir = join(ctx.forgeHome, "commands");
1535
- const targetDir = join(ctx.home, ".claude", "commands");
1536
- const manifestPath = join(ctx.home, ".claude", "forgedock", "copied-commands.json");
1537
- const wantSymlink = ctx.linkStrategy !== "copy";
1538
-
1539
- w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
1540
- await mkdir(targetDir, { recursive: true });
1541
-
1542
- const manifest = await loadCopiedManifest(manifestPath);
1543
- let manifestChanged = false;
1544
-
1545
- const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
1546
- let installed = 0, updated = 0, skipped = 0, copied = 0;
1547
- const barWidth = 24;
1548
- let barShown = false;
1549
-
1550
- const recordCopy = (rel) => {
1551
- if (!manifest.files[rel]) {
1552
- manifest.files[rel] = true;
1553
- manifestChanged = true;
1554
- }
1555
- };
1556
-
1557
- for (let i = 0; i < files.length; i++) {
1558
- const file = files[i];
1559
- const rel = relative(commandsDir, file);
1560
- const target = join(targetDir, rel);
1561
- await mkdir(pathDirname(target), { recursive: true });
1562
-
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);
1563
1913
  try {
1564
- const stats = await lstat(target);
1565
- if (stats.isSymbolicLink()) {
1566
- const current = await readlink(target);
1567
- if (current === file) {
1568
- skipped++;
1914
+ const mergedManifest = await loadCopiedManifest(manifestPath);
1915
+ for (const { rel, op } of manifestOps) {
1916
+ if (op === "add") {
1917
+ mergedManifest.files[rel] = true;
1569
1918
  } else {
1570
- let relinked = false;
1571
- if (wantSymlink) {
1572
- try {
1573
- await symlink(file, target + ".tmp");
1574
- try {
1575
- await rename(target + ".tmp", target);
1576
- } catch (renameErr) {
1577
- await unlink(target + ".tmp").catch(() => {});
1578
- throw renameErr;
1579
- }
1580
- updated++;
1581
- relinked = true;
1582
- } catch (linkErr) {
1583
- if (!isLinkPermissionError(linkErr)) throw linkErr;
1584
- }
1585
- }
1586
- if (!relinked) {
1587
- // Can't (or shouldn't) re-link — replace the managed link with a copy.
1588
- // unlink first: copyFile onto a symlink writes THROUGH the link.
1589
- await unlink(target);
1590
- await copyFile(file, target);
1591
- updated++; // replaces an existing managed entry
1592
- recordCopy(rel);
1593
- }
1594
- }
1595
- } else if (manifest.files[rel]) {
1596
- // A regular file we copied on a previous run — ours to manage.
1597
- // First try upgrading it to a symlink (Developer Mode enabled since).
1598
- let upgraded = false;
1599
- if (wantSymlink) {
1600
- try {
1601
- await symlink(file, target + ".tmp");
1602
- try {
1603
- await rename(target + ".tmp", target); // rename replaces existing files on Windows
1604
- } catch (renameErr) {
1605
- await unlink(target + ".tmp").catch(() => {});
1606
- throw renameErr;
1607
- }
1608
- updated++;
1609
- delete manifest.files[rel];
1610
- manifestChanged = true;
1611
- upgraded = true;
1612
- } catch (linkErr) {
1613
- if (!isLinkPermissionError(linkErr)) throw linkErr;
1614
- }
1615
- }
1616
- if (!upgraded) {
1617
- const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1618
- if (src.equals(dst)) {
1619
- skipped++;
1620
- } else {
1621
- await copyFile(file, target);
1622
- updated++;
1623
- }
1624
- }
1625
- } else {
1626
- // Regular file not in manifest — could be a copy from a pre-manifest
1627
- // ForgeDock version, or a user-owned file. Content-compare: if it
1628
- // matches source exactly, adopt it into the manifest silently (it was
1629
- // ours). If it differs, leave it untouched — it may be user-customized.
1630
- let adopted = false;
1631
- try {
1632
- const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1633
- if (src.equals(dst)) {
1634
- recordCopy(rel);
1635
- skipped++;
1636
- adopted = true;
1637
- }
1638
- } catch { /* readFile failure — fall through to warning */ }
1639
- if (!adopted) {
1640
- if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
1641
- w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
1642
- skipped++;
1643
- }
1644
- }
1645
- } catch (err) {
1646
- if (err.code !== "ENOENT") throw err;
1647
- let linked = false;
1648
- if (wantSymlink) {
1649
- try {
1650
- await symlink(file, target);
1651
- installed++;
1652
- linked = true;
1653
- if (manifest.files[rel]) {
1654
- // Symlinks work now and the old copy is gone — drop the stale record.
1655
- delete manifest.files[rel];
1656
- manifestChanged = true;
1657
- }
1658
- } catch (linkErr) {
1659
- if (!isLinkPermissionError(linkErr)) throw linkErr;
1919
+ delete mergedManifest.files[rel];
1660
1920
  }
1661
1921
  }
1662
- if (!linked) {
1663
- // Windows without Developer Mode/admin — fall back to a copy.
1664
- await copyFile(file, target);
1665
- copied++;
1666
- recordCopy(rel);
1667
- }
1922
+ await saveCopiedManifest(manifestPath, mergedManifest);
1923
+ } finally {
1924
+ await releaseManifestLock(manifestPath, lockHandle);
1668
1925
  }
1926
+ return true;
1927
+ } catch {
1928
+ return false;
1929
+ }
1930
+ }
1669
1931
 
1670
- if (ctx.motion) {
1671
- if (barShown) w.write("\x1b[1A\x1b[2K");
1672
- w.write(` ${moltenBar(i + 1, files.length, { width: barWidth, mode: ctx.mode })} ${i + 1}/${files.length} ${dimLine(ctx, "/" + rel.replace(/\.md$/, ""))}\n`);
1673
- barShown = true;
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;
1674
1957
  }
1958
+ // "denied" — no symlink installed; fall through to content-compare.
1675
1959
  }
1676
- if (barShown) w.write("\x1b[1A\x1b[2K");
1677
-
1678
- // Prune orphaned symlinks: links that point into commandsDir but whose
1679
- // target file no longer exists (e.g. after a command is renamed or deleted).
1680
- const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
1681
-
1682
- // Link the small set of universal pipeline-agent scripts (forge#1885).
1683
- const scriptsResult = await linkPipelineScripts(ctx);
1684
-
1685
- const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
1686
- const settingsPath = join(ctx.home, ".claude", "settings.json");
1687
- const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
1688
-
1689
- // Install enforcement hooks (#1250): PreToolUse (branch/label validation)
1690
- // and SubagentStop interactive engine adapter. Both are idempotent and
1691
- // always installed (fail-open if settings.json is malformed, same
1692
- // contract as SessionStart hook).
1693
- const preToolUseScript = join(ctx.forgeHome, "bin", "hooks", "pre-tool-use.mjs");
1694
- const subagentStopScript = join(ctx.forgeHome, "bin", "hooks", "interactive-engine.mjs");
1695
- const { status: preToolUseStatus } = installPreToolUseHook(settingsPath, preToolUseScript);
1696
- installSubagentStopHook(settingsPath, subagentStopScript);
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
+ }
1697
1970
 
1698
- // SubagentStop annotation-verifier hook (#1250) is NOT installed: its
1699
- // trigger condition (a `FORGE:PHASE_START` marker in the transcript) is
1700
- // never emitted anywhere in the pipeline, so it always exits 0 with zero
1701
- // enforcement effect while still spawning a process + reading the
1702
- // transcript on every SubagentStop (forge#1527). Actively clean up any
1703
- // prior installation instead of installing it.
1704
- const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
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
+ }
1705
2101
 
1706
- // Housekeeping — must never abort the receipt or the hook.
1707
- let manifestSaveFailed = false;
1708
- if (manifestChanged) {
1709
- try {
1710
- await saveCopiedManifest(manifestPath, manifest);
1711
- } catch {
1712
- manifestSaveFailed = true;
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);
1713
2115
  }
1714
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;
1715
2138
 
1716
2139
  const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
1717
2140
  const headlineVerb = copied > 0 ? "installed" : "linked";
2141
+ const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
1718
2142
  const headlineDetail = copied > 0
1719
- ? `(new ${installed}, copied ${copied}, updated ${updated}, unchanged ${skipped})`
1720
- : `(new ${installed}, updated ${updated}, unchanged ${skipped})`;
1721
- w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
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`);
1722
2146
  if (pruned > 0) {
1723
2147
  w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
1724
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
+ }
1725
2152
  if (copied > 0) {
1726
2153
  w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
1727
2154
  }
@@ -1811,8 +2238,178 @@ export async function forge(ctx) {
1811
2238
  if (subagentStopEnforceStatus === "removed") {
1812
2239
  w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
1813
2240
  }
2241
+ }
2242
+
2243
+ /**
2244
+ * Act II: link commands into ~/.claude/commands with a molten progress line,
2245
+ * then register the SessionStart hook.
2246
+ * Symlink semantics preserved verbatim from the original install():
2247
+ * regular files are skipped with a warning; changed links updated atomically.
2248
+ * On Windows without Developer Mode (symlink → EPERM/EACCES) each command is
2249
+ * copied instead, and recorded in a manifest so re-runs can tell ForgeDock's
2250
+ * own copies apart from user-owned files. ctx.linkStrategy ("symlink" default)
2251
+ * can be set to "copy" to skip all symlink attempts (deterministic tests).
2252
+ */
2253
+ export async function forge(ctx) {
2254
+ const { stdout: w } = ctx;
2255
+ const commandsDir = join(ctx.forgeHome, "commands");
2256
+ const targetDir = join(ctx.home, ".claude", "commands");
2257
+ const manifestPath = join(ctx.home, ".claude", "forgedock", "copied-commands.json");
2258
+ const wantSymlink = ctx.linkStrategy !== "copy";
2259
+
2260
+ w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
2261
+ await mkdir(targetDir, { recursive: true });
2262
+
2263
+ // Clear stale extensionless entries from prior `npx forgedock` runs before
2264
+ // the install loop below can collide with them (forge#2620).
2265
+ const staleExtensionlessPruned = await pruneStaleExtensionlessEntries(targetDir);
2266
+
2267
+ const manifest = await loadCopiedManifest(manifestPath);
2268
+ // Reclaim crash-orphaned pid-suffixed tmp siblings left by prior hard-killed
2269
+ // runs (forge#2612). Best-effort — must never abort the forge receipt/hook.
2270
+ await sweepStaleManifestTmps(manifestPath).catch(() => {});
2271
+ let manifestChanged = false;
2272
+ // This run's own manifest mutations, tracked as a replayable diff
2273
+ // (forge#2614). #2599/#2609 fixed the tmp-file *write-path* race (two
2274
+ // concurrent forge() runs no longer collide on the same tmp filename) but
2275
+ // left the manifest's logical *content* racy: each run loads the manifest
2276
+ // into memory once, mutates its own copy across the whole run, then writes
2277
+ // the entire in-memory object back at the end — a classic last-writer-wins
2278
+ // race on manifest.files. Recording this run's own adds/deletes here (instead
2279
+ // of relying on the run-start in-memory snapshot) lets the final save re-read
2280
+ // the on-disk manifest and replay only these ops onto it, so a concurrent
2281
+ // run's own adds/deletes made after this run's initial load are preserved
2282
+ // rather than silently overwritten.
2283
+ const manifestOps = [];
2284
+
2285
+ const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
2286
+ let installed = 0, updated = 0, skipped = 0, copied = 0, backedUp = 0;
2287
+ const barWidth = 24;
2288
+ let barShown = false;
2289
+
2290
+ const recordCopy = (rel) => {
2291
+ if (!manifest.files[rel]) {
2292
+ manifest.files[rel] = true;
2293
+ manifestChanged = true;
2294
+ }
2295
+ manifestOps.push({ rel, op: "add" });
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
+ };
2305
+
2306
+ for (let i = 0; i < files.length; i++) {
2307
+ const file = files[i];
2308
+ const rel = relative(commandsDir, file);
2309
+ const target = join(targetDir, rel);
2310
+ await mkdir(pathDirname(target), { recursive: true });
2311
+
2312
+ try {
2313
+ const stats = await lstat(target);
2314
+ if (stats.isSymbolicLink()) {
2315
+ const current = await readlink(target);
2316
+ if (current === file) {
2317
+ skipped++;
2318
+ } else {
2319
+ let relinked = false;
2320
+ if (wantSymlink) {
2321
+ relinked = (await atomicSymlinkInstall(file, target)) === "linked";
2322
+ if (relinked) updated++;
2323
+ }
2324
+ if (!relinked) {
2325
+ // Can't (or shouldn't) re-link — replace the managed link with a copy.
2326
+ // unlink first: copyFile onto a symlink writes THROUGH the link.
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.)
2330
+ await unlink(target).catch((err) => {
2331
+ if (err.code !== "ENOENT") throw err;
2332
+ });
2333
+ await copyFile(file, target);
2334
+ updated++; // replaces an existing managed entry
2335
+ recordCopy(rel);
2336
+ }
2337
+ }
2338
+ } else if (manifest.files[rel]) {
2339
+ const eff = await upgradeManagedCopy(file, target, rel, { wantSymlink, recordDelete });
2340
+ updated += eff.updatedDelta;
2341
+ skipped += eff.skippedDelta;
2342
+ } else {
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.
2350
+ if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
2351
+ w.write(` WARNING: ${rel} could not be repaired — remove it manually to let ForgeDock manage it\n`);
2352
+ skipped++;
2353
+ }
2354
+ }
2355
+ } catch (err) {
2356
+ if (err.code !== "ENOENT") throw err;
2357
+ const eff = await installFreshCommandFile(file, target, rel, { wantSymlink, manifest, recordCopy, recordDelete });
2358
+ installed += eff.installedDelta;
2359
+ copied += eff.copiedDelta;
2360
+ }
2361
+
2362
+ if (ctx.motion) {
2363
+ if (barShown) w.write("\x1b[1A\x1b[2K");
2364
+ w.write(` ${moltenBar(i + 1, files.length, { width: barWidth, mode: ctx.mode })} ${i + 1}/${files.length} ${dimLine(ctx, "/" + rel.replace(/\.md$/, ""))}\n`);
2365
+ barShown = true;
2366
+ }
2367
+ }
2368
+ if (barShown) w.write("\x1b[1A\x1b[2K");
2369
+
2370
+ // Prune orphaned symlinks: links that point into commandsDir but whose
2371
+ // target file no longer exists (e.g. after a command is renamed or deleted).
2372
+ const pruned = await pruneOrphanedSymlinks(targetDir, commandsDir);
2373
+
2374
+ // Link the small set of universal pipeline-agent scripts (forge#1885).
2375
+ const scriptsResult = await linkPipelineScripts(ctx);
2376
+
2377
+ const hookScript = join(ctx.forgeHome, "bin", "hooks", "session-start.mjs");
2378
+ const settingsPath = join(ctx.home, ".claude", "settings.json");
2379
+ const { status: hookStatus } = installSessionStartHook(settingsPath, hookScript);
2380
+
2381
+ // Install enforcement hooks (#1250): PreToolUse (branch/label validation)
2382
+ // and SubagentStop interactive engine adapter. Both are idempotent and
2383
+ // always installed (fail-open if settings.json is malformed, same
2384
+ // contract as SessionStart hook).
2385
+ const preToolUseScript = join(ctx.forgeHome, "bin", "hooks", "pre-tool-use.mjs");
2386
+ const subagentStopScript = join(ctx.forgeHome, "bin", "hooks", "interactive-engine.mjs");
2387
+ const { status: preToolUseStatus } = installPreToolUseHook(settingsPath, preToolUseScript);
2388
+ installSubagentStopHook(settingsPath, subagentStopScript);
2389
+
2390
+ // SubagentStop annotation-verifier hook (#1250) is NOT installed: its
2391
+ // trigger condition (a `FORGE:PHASE_START` marker in the transcript) is
2392
+ // never emitted anywhere in the pipeline, so it always exits 0 with zero
2393
+ // enforcement effect while still spawning a process + reading the
2394
+ // transcript on every SubagentStop (forge#1527). Actively clean up any
2395
+ // prior installation instead of installing it.
2396
+ const { status: subagentStopEnforceStatus } = removeSubagentStopEnforceHook(settingsPath);
2397
+
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.
2401
+ let manifestSaveFailed = false;
2402
+ if (manifestChanged) {
2403
+ manifestSaveFailed = !(await saveManifestWithMerge(manifestPath, manifestOps));
2404
+ }
2405
+
2406
+ renderForgeReceipt(ctx, {
2407
+ installed, updated, skipped, copied, backedUp, pruned, total: files.length,
2408
+ staleExtensionlessPruned, scriptsResult, manifestSaveFailed,
2409
+ hookStatus, settingsPath, preToolUseStatus, subagentStopEnforceStatus,
2410
+ });
1814
2411
 
1815
- return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
2412
+ return { installed, updated, skipped, copied, backedUp, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
1816
2413
  }
1817
2414
 
1818
2415
  // ---------------------------------------------------------------------------