forgedock 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/bin/batch-runner.mjs +31 -2
  2. package/bin/engine/admission.mjs +294 -0
  3. package/bin/engine/admission.test.mjs +265 -0
  4. package/bin/engine/invariants.mjs +17 -4
  5. package/bin/engine/phases.mjs +201 -35
  6. package/bin/engine/resolve.mjs +256 -0
  7. package/bin/engine/resolve.test.mjs +149 -0
  8. package/bin/engine/runlog.mjs +11 -1
  9. package/bin/engine/state.mjs +20 -28
  10. package/bin/engine-cli.mjs +86 -5
  11. package/bin/engine.mjs +543 -36
  12. package/bin/forgedock.mjs +397 -59
  13. package/bin/hooks/interactive-engine.mjs +50 -24
  14. package/bin/hooks/pre-tool-use.mjs +191 -13
  15. package/bin/journey.mjs +586 -44
  16. package/bin/labels.json +5 -0
  17. package/bin/runner.mjs +478 -10
  18. package/bin/watch-utils.mjs +258 -0
  19. package/commands/cleanup.md +34 -5
  20. package/commands/issue.md +14 -4
  21. package/commands/orchestrate/config.md +43 -1
  22. package/commands/orchestrate/phase-1-resolve.md +348 -11
  23. package/commands/orchestrate/phase-3-dependency.md +272 -49
  24. package/commands/orchestrate/phase-4-execution.md +622 -60
  25. package/commands/orchestrate/phase-5-cleanup.md +2 -0
  26. package/commands/quality-gate.md +6 -6
  27. package/commands/review-pr-staging.md +51 -8
  28. package/commands/review-pr.md +120 -39
  29. package/commands/upgrade-deps.md +7 -2
  30. package/commands/work-on/decompose.md +2 -0
  31. package/commands/work-on/investigate.md +22 -4
  32. package/commands/work-on/remediate.md +55 -4
  33. package/commands/work-on/review.md +40 -1
  34. package/commands/work-on.md +19 -4
  35. package/docs/CONFIG.md +120 -0
  36. package/docs/spec/eval-run-result.md +8 -4
  37. package/docs/spec/label-state-machine.md +9 -0
  38. package/package.json +2 -2
  39. package/packages/protocol/src/emit.js +5 -22
  40. package/packages/protocol/src/html-comment-escape.js +68 -0
  41. package/packages/protocol/src/index.js +1 -0
  42. package/packages/protocol/src/parse.js +5 -6
  43. package/packages/protocol/src/phases.js +105 -0
  44. package/packages/protocol/src/types.js +17 -0
  45. package/packages/protocol/src/validate.js +24 -1
  46. package/scripts/check-admission-parity.mjs +167 -0
  47. package/scripts/check-command-side-effects.sh +181 -66
  48. package/scripts/check-phase-registry-drift.mjs +118 -0
  49. package/scripts/check-spec-markers.sh +3 -0
  50. package/scripts/classify-lane.sh +29 -3
  51. package/scripts/derive-finding-milestone.sh +184 -0
  52. package/scripts/doctor-pipeline-state.sh +1 -0
  53. package/scripts/extract-affected-files.sh +232 -0
  54. package/scripts/extract-affected-files.test.sh +331 -0
  55. package/scripts/forge-run.sh +39 -4
  56. package/scripts/issue-dedup.sh +69 -8
  57. package/scripts/issue-dedup.test.sh +142 -0
  58. package/scripts/severity-to-priority.sh +81 -0
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,211 @@ 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
+
864
1063
  /**
865
1064
  * Allowlist of universal pipeline-agent scripts installed to
866
1065
  * ~/.claude/scripts/ by forge() (linkPipelineScripts()) and cleaned up by
@@ -941,6 +1140,108 @@ async function pruneOrphanedSymlinks(targetDir, commandsDir) {
941
1140
  return pruned;
942
1141
  }
943
1142
 
1143
+ /**
1144
+ * Remove leftover extensionless entries at the top level of targetDir left
1145
+ * behind by prior `npx forgedock <command>` runs (forge#2620). Those older
1146
+ * ephemeral invocations created extensionless symlinks/directories (e.g.
1147
+ * `orchestrate`, `pipeline-health`) pointing into the npx download cache
1148
+ * (`%LocalAppData%\npm-cache\_npx\...` / `~/.npm/_npx/...`). Those cache
1149
+ * targets are routinely evicted by npx/npm cache cleanup or OS temp
1150
+ * cleanup, leaving dead entries that collide on the base command name with
1151
+ * the real `{name}.md` file this installer is about to create —
1152
+ * `mkdir(..., {recursive:true})` throws when a same-named non-directory
1153
+ * file already occupies that path segment.
1154
+ *
1155
+ * Conservative by design — must run BEFORE the main install loop so stale
1156
+ * entries are cleared before any mkdir/symlink attempt can collide with
1157
+ * them this run (pruneOrphanedSymlinks above only catches entries that
1158
+ * point *into commandsDir*, and runs after the loop; this catches the
1159
+ * separate npx-cache-origin case pre-emptively). Only removes a top-level
1160
+ * entry when its basename has no file extension (`.md` files and dotfiles
1161
+ * are never touched) AND it is a symlink whose target either no longer
1162
+ * exists or resolves into a known ephemeral cache path
1163
+ * (`isEphemeralCachePath`). Real command directories (e.g. `work-on/`)
1164
+ * created by this installer are plain directories containing `.md` files,
1165
+ * never symlinks — untouched by either condition.
1166
+ *
1167
+ * @param {string} targetDir - ~/.claude/commands
1168
+ * @returns {Promise<number>} Number of stale entries removed.
1169
+ */
1170
+ // Matches a Windows drive-relative path like "C:foo" (drive letter + colon,
1171
+ // NOT followed by a path separator) — as opposed to a drive-absolute path
1172
+ // like "C:\foo" or "C:/foo", which path.isAbsolute() already recognizes
1173
+ // correctly. A drive-relative target resolves relative to that drive's own
1174
+ // current working directory, which Node has no API to query — there is no
1175
+ // way to compute the correct absolute path here (review finding on PR #2658,
1176
+ // forge#2659). Deliberately narrow: only fires when isAbsolute() has already
1177
+ // said "no" and the string still starts with `<letter>:` immediately
1178
+ // followed by a non-separator character. Platform-gated (review finding
1179
+ // forge#2663): on POSIX, `:` has no special meaning in filenames, so a
1180
+ // literal relative target like `C:foo` is a perfectly valid path component
1181
+ // and must NOT be misclassified as an unresolvable Windows drive-relative
1182
+ // path — only apply this heuristic on win32.
1183
+ const WINDOWS_DRIVE_RELATIVE_RE = /^[A-Za-z]:(?![\\/])/;
1184
+
1185
+ export async function pruneStaleExtensionlessEntries(targetDir) {
1186
+ let pruned = 0;
1187
+ let entries;
1188
+ try {
1189
+ entries = await readdir(targetDir, { withFileTypes: true });
1190
+ } catch (err) {
1191
+ if (err.code === "ENOENT") return pruned;
1192
+ throw err;
1193
+ }
1194
+ for (const entry of entries) {
1195
+ const name = entry.name;
1196
+ if (name.includes(".")) continue; // has an extension (or is a dotfile) — never touch
1197
+ if (!entry.isSymbolicLink()) continue; // only manage symlinks here — real dirs/files are left alone
1198
+ const full = join(targetDir, name);
1199
+ let linkTarget;
1200
+ try {
1201
+ linkTarget = await readlink(full);
1202
+ } catch {
1203
+ continue; // unreadable link metadata — skip rather than guess
1204
+ }
1205
+ // readlink() returns whatever string was stored at symlink-creation time.
1206
+ // A relative target is defined (POSIX) relative to the symlink's own
1207
+ // containing directory (targetDir, since full = join(targetDir, name)) —
1208
+ // not the process cwd, which is what a raw lstat(linkTarget) would use.
1209
+ //
1210
+ // Two shapes isAbsolute() alone misclassifies as "relative" and would
1211
+ // otherwise be wrongly joined onto targetDir:
1212
+ // - Windows drive-relative ("C:foo") — see WINDOWS_DRIVE_RELATIVE_RE
1213
+ // above; unresolvable here, so the entry is left untouched. Only
1214
+ // meaningful on win32 (review finding forge#2663) — on POSIX, `:` is
1215
+ // just an ordinary filename character, so this check is skipped
1216
+ // there and the target falls through to normal relative resolution.
1217
+ // - Shell-style tilde ("~/foo", "~\foo", or bare "~") — expand against
1218
+ // os.homedir() first, matching shell semantics (review finding on
1219
+ // PR #2658, forge#2660).
1220
+ if (process.platform === "win32" && WINDOWS_DRIVE_RELATIVE_RE.test(linkTarget)) continue; // can't resolve without the drive's own cwd — leave alone
1221
+ let effectiveTarget = linkTarget;
1222
+ if (effectiveTarget === "~" || effectiveTarget.startsWith("~/") || effectiveTarget.startsWith("~\\")) {
1223
+ effectiveTarget = join(os.homedir(), effectiveTarget.slice(1));
1224
+ }
1225
+ const resolvedTarget = isAbsolute(effectiveTarget) ? effectiveTarget : join(targetDir, effectiveTarget);
1226
+ let targetMissing = false;
1227
+ try {
1228
+ await lstat(resolvedTarget);
1229
+ } catch (err) {
1230
+ if (err.code !== "ENOENT") continue; // unexpected error — skip to be safe
1231
+ targetMissing = true;
1232
+ }
1233
+ if (targetMissing || isEphemeralCachePath(linkTarget)) {
1234
+ try {
1235
+ await unlink(full);
1236
+ pruned++;
1237
+ } catch (err) {
1238
+ if (err.code !== "ENOENT") throw err;
1239
+ }
1240
+ }
1241
+ }
1242
+ return pruned;
1243
+ }
1244
+
944
1245
  /**
945
1246
  * Detect whether `p` sits inside a known ephemeral npm/npx/pnpm/yarn cache
946
1247
  * directory rather than a durable install location (global npm install,
@@ -1101,8 +1402,14 @@ async function removeOrphans(srcDir, destDir) {
1101
1402
  const srcEntries = await readdir(srcDir, { withFileTypes: true });
1102
1403
  srcNames = new Set(srcEntries.map((e) => e.name));
1103
1404
  } catch (err) {
1104
- if (err.code !== "ENOENT") throw err;
1105
- // srcDir missing entirely — every dest entry is an orphan.
1405
+ if (err.code !== "ENOENT" && err.code !== "ENOTDIR") throw err;
1406
+ // srcDir missing entirely (ENOENT), or the corresponding source path changed
1407
+ // type and is no longer a directory — e.g. a symlink-to-file replacing what
1408
+ // used to be a real directory (ENOTDIR) — every dest entry is an orphan.
1409
+ // Treating both the same way keeps this a per-path fail-open instead of
1410
+ // letting an uncaught ENOTDIR escape to persistHome()'s single outer catch,
1411
+ // which would otherwise silently skip the *entire* persist for one bad path
1412
+ // (forge#2227).
1106
1413
  srcNames = new Set();
1107
1414
  }
1108
1415
 
@@ -1372,9 +1679,24 @@ async function linkPipelineScripts(ctx) {
1372
1679
 
1373
1680
  let linked = false;
1374
1681
  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";
1375
1690
  try {
1376
- await symlink(file, target);
1377
- linked = true;
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(() => {});
1378
1700
  } catch (linkErr) {
1379
1701
  if (!isLinkPermissionError(linkErr)) throw linkErr;
1380
1702
  }
@@ -1533,11 +1855,30 @@ export async function forge(ctx) {
1533
1855
  w.write("\n " + ember("Forging commands", ctx.mode) + " " + dimLine(ctx, `into ${targetDir}`) + "\n\n");
1534
1856
  await mkdir(targetDir, { recursive: true });
1535
1857
 
1858
+ // Clear stale extensionless entries from prior `npx forgedock` runs before
1859
+ // the install loop below can collide with them (forge#2620).
1860
+ const staleExtensionlessPruned = await pruneStaleExtensionlessEntries(targetDir);
1861
+
1536
1862
  const manifest = await loadCopiedManifest(manifestPath);
1863
+ // Reclaim crash-orphaned pid-suffixed tmp siblings left by prior hard-killed
1864
+ // runs (forge#2612). Best-effort — must never abort the forge receipt/hook.
1865
+ await sweepStaleManifestTmps(manifestPath).catch(() => {});
1537
1866
  let manifestChanged = false;
1867
+ // This run's own manifest mutations, tracked as a replayable diff
1868
+ // (forge#2614). #2599/#2609 fixed the tmp-file *write-path* race (two
1869
+ // concurrent forge() runs no longer collide on the same tmp filename) but
1870
+ // left the manifest's logical *content* racy: each run loads the manifest
1871
+ // into memory once, mutates its own copy across the whole run, then writes
1872
+ // the entire in-memory object back at the end — a classic last-writer-wins
1873
+ // race on manifest.files. Recording this run's own adds/deletes here (instead
1874
+ // of relying on the run-start in-memory snapshot) lets the final save re-read
1875
+ // the on-disk manifest and replay only these ops onto it, so a concurrent
1876
+ // run's own adds/deletes made after this run's initial load are preserved
1877
+ // rather than silently overwritten.
1878
+ const manifestOps = [];
1538
1879
 
1539
1880
  const files = await findMarkdownFiles(commandsDir, { includeExtras: !!ctx.includeExtras });
1540
- let installed = 0, updated = 0, skipped = 0, copied = 0;
1881
+ let installed = 0, updated = 0, skipped = 0, copied = 0, backedUp = 0;
1541
1882
  const barWidth = 24;
1542
1883
  let barShown = false;
1543
1884
 
@@ -1546,6 +1887,7 @@ export async function forge(ctx) {
1546
1887
  manifest.files[rel] = true;
1547
1888
  manifestChanged = true;
1548
1889
  }
1890
+ manifestOps.push({ rel, op: "add" });
1549
1891
  };
1550
1892
 
1551
1893
  for (let i = 0; i < files.length; i++) {
@@ -1563,16 +1905,31 @@ export async function forge(ctx) {
1563
1905
  } else {
1564
1906
  let relinked = false;
1565
1907
  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";
1566
1916
  try {
1567
- await symlink(file, target + ".tmp");
1917
+ await symlink(file, tmpTarget);
1568
1918
  try {
1569
- await rename(target + ".tmp", target);
1919
+ await rename(tmpTarget, target);
1570
1920
  } catch (renameErr) {
1571
- await unlink(target + ".tmp").catch(() => {});
1921
+ await unlink(tmpTarget).catch(() => {});
1572
1922
  throw renameErr;
1573
1923
  }
1574
- updated++;
1575
- relinked = true;
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
+ }
1576
1933
  } catch (linkErr) {
1577
1934
  if (!isLinkPermissionError(linkErr)) throw linkErr;
1578
1935
  }
@@ -1580,7 +1937,11 @@ export async function forge(ctx) {
1580
1937
  if (!relinked) {
1581
1938
  // Can't (or shouldn't) re-link — replace the managed link with a copy.
1582
1939
  // unlink first: copyFile onto a symlink writes THROUGH the link.
1583
- await unlink(target);
1940
+ // (target may already be gone here if isSymlinkTraversable() failed
1941
+ // and its own cleanup already removed it — tolerate ENOENT.)
1942
+ await unlink(target).catch((err) => {
1943
+ if (err.code !== "ENOENT") throw err;
1944
+ });
1584
1945
  await copyFile(file, target);
1585
1946
  updated++; // replaces an existing managed entry
1586
1947
  recordCopy(rel);
@@ -1590,24 +1951,43 @@ export async function forge(ctx) {
1590
1951
  // A regular file we copied on a previous run — ours to manage.
1591
1952
  // First try upgrading it to a symlink (Developer Mode enabled since).
1592
1953
  let upgraded = false;
1954
+ let restoredAsCopy = false;
1593
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";
1594
1960
  try {
1595
- await symlink(file, target + ".tmp");
1961
+ await symlink(file, tmpTarget);
1596
1962
  try {
1597
- await rename(target + ".tmp", target); // rename replaces existing files on Windows
1963
+ await rename(tmpTarget, target); // rename replaces existing files on Windows
1598
1964
  } catch (renameErr) {
1599
- await unlink(target + ".tmp").catch(() => {});
1965
+ await unlink(tmpTarget).catch(() => {});
1600
1966
  throw renameErr;
1601
1967
  }
1602
- updated++;
1603
- delete manifest.files[rel];
1604
- manifestChanged = true;
1605
- upgraded = true;
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
+ }
1606
1986
  } catch (linkErr) {
1607
1987
  if (!isLinkPermissionError(linkErr)) throw linkErr;
1608
1988
  }
1609
1989
  }
1610
- if (!upgraded) {
1990
+ if (!upgraded && !restoredAsCopy) {
1611
1991
  const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1612
1992
  if (src.equals(dst)) {
1613
1993
  skipped++;
@@ -1617,22 +1997,127 @@ export async function forge(ctx) {
1617
1997
  }
1618
1998
  }
1619
1999
  } else {
1620
- // Regular file not in manifest — could be a copy from a pre-manifest
1621
- // ForgeDock version, or a user-owned file. Content-compare: if it
1622
- // matches source exactly, adopt it into the manifest silently (it was
1623
- // ours). If it differs, leave it untouched — it may be user-customized.
1624
- let adopted = false;
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;
1625
2011
  try {
1626
2012
  const [src, dst] = await Promise.all([readFile(file), readFile(target)]);
1627
2013
  if (src.equals(dst)) {
1628
2014
  recordCopy(rel);
1629
2015
  skipped++;
1630
- adopted = true;
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;
1631
2116
  }
1632
- } catch { /* readFile failure — fall through to warning */ }
1633
- if (!adopted) {
2117
+ } catch { /* readFile/copyFile/rename failure — fall through to warning */ }
2118
+ if (!handled) {
1634
2119
  if (barShown) { w.write("\x1b[1A\x1b[2K"); barShown = false; }
1635
- w.write(` WARNING: ${rel} is a regular file — skipping (remove it manually to let ForgeDock manage it)\n`);
2120
+ w.write(` WARNING: ${rel} could not be repaired — remove it manually to let ForgeDock manage it\n`);
1636
2121
  skipped++;
1637
2122
  }
1638
2123
  }
@@ -1640,21 +2125,42 @@ export async function forge(ctx) {
1640
2125
  if (err.code !== "ENOENT") throw err;
1641
2126
  let linked = false;
1642
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";
1643
2135
  try {
1644
- await symlink(file, target);
1645
- installed++;
1646
- linked = true;
1647
- if (manifest.files[rel]) {
1648
- // Symlinks work now and the old copy is gone — drop the stale record.
1649
- delete manifest.files[rel];
1650
- manifestChanged = true;
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(() => {});
1651
2157
  }
1652
2158
  } catch (linkErr) {
1653
2159
  if (!isLinkPermissionError(linkErr)) throw linkErr;
1654
2160
  }
1655
2161
  }
1656
2162
  if (!linked) {
1657
- // Windows without Developer Mode/admin — fall back to a copy.
2163
+ // Windows without Developer Mode/admin, or symlink unreadable — copy.
1658
2164
  await copyFile(file, target);
1659
2165
  copied++;
1660
2166
  recordCopy(rel);
@@ -1701,7 +2207,39 @@ export async function forge(ctx) {
1701
2207
  let manifestSaveFailed = false;
1702
2208
  if (manifestChanged) {
1703
2209
  try {
1704
- await saveCopiedManifest(manifestPath, manifest);
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
+ }
1705
2243
  } catch {
1706
2244
  manifestSaveFailed = true;
1707
2245
  }
@@ -1709,13 +2247,17 @@ export async function forge(ctx) {
1709
2247
 
1710
2248
  const glyph = (ok) => (ctx.mode === "none" ? (ok ? "✔" : "!") : `\x1b[38;2;255;179;71m${ok ? "✔" : "!"}\x1b[0m`);
1711
2249
  const headlineVerb = copied > 0 ? "installed" : "linked";
2250
+ const backupNote = backedUp > 0 ? `, ${backedUp} backed up` : "";
1712
2251
  const headlineDetail = copied > 0
1713
- ? `(new ${installed}, copied ${copied}, updated ${updated}, unchanged ${skipped})`
1714
- : `(new ${installed}, updated ${updated}, unchanged ${skipped})`;
2252
+ ? `(new ${installed}, copied ${copied}, updated ${updated}${backupNote}, unchanged ${skipped})`
2253
+ : `(new ${installed}, updated ${updated}${backupNote}, unchanged ${skipped})`;
1715
2254
  w.write(` ${glyph(true)} ${files.length} slash commands ${headlineVerb} ${dimLine(ctx, headlineDetail)}\n`);
1716
2255
  if (pruned > 0) {
1717
2256
  w.write(` ${glyph(true)} ${pruned} orphaned symlink${pruned === 1 ? "" : "s"} removed ${dimLine(ctx, "(commands deleted or renamed since last install)")}\n`);
1718
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
+ }
1719
2261
  if (copied > 0) {
1720
2262
  w.write(` ${glyph(false)} ${copied} copied (not linked) ${dimLine(ctx, "— enable Windows Developer Mode for live-updating links")}\n`);
1721
2263
  }
@@ -1806,7 +2348,7 @@ export async function forge(ctx) {
1806
2348
  w.write(` ${glyph(true)} SubagentStop enforcement hook removed ${dimLine(ctx, "(non-functional — see forge#1527)")}\n`);
1807
2349
  }
1808
2350
 
1809
- return { installed, updated, skipped, copied, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
2351
+ return { installed, updated, skipped, copied, backedUp, pruned, total: files.length, hookStatus, preToolUseStatus, subagentStopEnforceStatus, scriptsResult };
1810
2352
  }
1811
2353
 
1812
2354
  // ---------------------------------------------------------------------------