@pilotspace/add 1.15.0 → 1.16.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/cli.js CHANGED
@@ -40,7 +40,7 @@ function parseArgs(argv) {
40
40
  // hint only echoes flags the user actually chose (shortest true command).
41
41
  const args = { _: [], force: false, check: false, noSkill: false, stage: null, name: null,
42
42
  yes: false, nonInteractive: false, global: false, globalData: false,
43
- fromGlobalData: false, ruleFile: false };
43
+ fromGlobalData: false, ruleFile: false, lockTimeout: null };
44
44
  for (let i = 0; i < argv.length; i++) {
45
45
  const a = argv[i];
46
46
  if (a === "--force") args.force = true;
@@ -72,6 +72,14 @@ function parseArgs(argv) {
72
72
  if (v == null || v.startsWith("--")) fail(a + " requires a value");
73
73
  if (a === "--stage") args.stage = v; else args.name = v;
74
74
  }
75
+ // --lock-timeout <seconds>: (--global only) opt into a bounded wait for a LIVE contended
76
+ // home lock before failing "update_in_progress" (default null = today's immediate fail-fast;
77
+ // a STALE lock always self-heals regardless). SAME "requires a value" idiom as --stage/--name.
78
+ else if (a === "--lock-timeout") {
79
+ const v = argv[++i];
80
+ if (v == null || v.startsWith("--")) fail(a + " requires a value");
81
+ args.lockTimeout = Number(v);
82
+ }
75
83
  else if (a.startsWith("--")) warn("ignoring unknown flag " + a);
76
84
  else args._.push(a);
77
85
  }
@@ -719,6 +727,14 @@ async function cmdInit(args) {
719
727
  "from; run `init --global-data` on a source checkout first");
720
728
  }
721
729
  }
730
+ // Project-scope lock (project-scope-install-lock): keyed on chosenTarget's FINAL value (after
731
+ // any interactive redirect above) — acquired BEFORE the as_global sub-block, held through the
732
+ // function's end. Independent of, and acquired BEFORE, the home-scoped acquireUpdateLock that
733
+ // installGlobal() below nests inside (M11 — never the reverse nesting).
734
+ const addDir = path.join(chosenTarget, ".add");
735
+ acquireProjectLock(addDir); // registers its own process.on("exit", release) — no explicit
736
+ // release()/finally needed at the call site (mirrors
737
+ // acquireUpdateLock's own usage at cmdUpdateGlobal)
722
738
  // OPT-IN global home, BEFORE the per-project drop (fail-closed if the home is unwritable
723
739
  // or its registry is corrupt — the package + the self-contained default stay usable).
724
740
  if (args.global) installGlobal(args, chosenTarget);
@@ -735,6 +751,7 @@ async function cmdInit(args) {
735
751
  // tasks, or archive (user data). Pure file-copy (npm <-> pip parity with _installer.py).
736
752
  const MANAGED = [
737
753
  ["skill/add", [".claude", "skills", "add"], false],
754
+ ["agents", [".claude", "agents"], false],
738
755
  ["tooling", [".add", "tooling"], true],
739
756
  ["docs", [".add", "docs"], false],
740
757
  ["personas-teacher", [".add", "personas-teacher"], false],
@@ -742,10 +759,42 @@ const MANAGED = [
742
759
  // Optional managed trees: an ENHANCEMENT the persona phase reads, not core runtime. The real
743
760
  // package always ships these (guarded by test_packaging); a malformed/older package missing one
744
761
  // must NOT abort the install — the core lands and the optional tree is soft-skipped. Twin of
745
- // _installer.py:OPTIONAL. Design-for-failure.
746
- const OPTIONAL = new Set(["personas-teacher"]);
762
+ // _installer.py:OPTIONAL. Design-for-failure. `agents` joins here (roster-install-drift): the
763
+ // phase-agent roster is a spawn-acceleration enhancement, not core runtime.
764
+ const OPTIONAL = new Set(["personas-teacher", "agents"]);
747
765
  const STAMP_FILE = ".add-version";
748
766
  const LOCK_FILE = ".update.lock"; // the `update --global` home lock (never user-data)
767
+ const LOCK_STALE_DEFAULT = 600; // seconds (10 min); ADD_LOCK_STALE_SECONDS env-overridable
768
+ const LOCK_POLL_INTERVAL_MS = 50; // ms between polls while waiting out a --lock-timeout
769
+ const LOCK_TICKET_STALE_SECONDS = 5; // a leaked per-generation reclaim ticket (its own holder
770
+ // crashed between winning it and its own best-effort
771
+ // cleanup) self-heals after this long — deliberately far
772
+ // shorter than LOCK_STALE_DEFAULT's own 600s: a ticket's
773
+ // own critical section is a small, fixed handful of
774
+ // syscalls (close/stat/unlink), microseconds under normal
775
+ // operation, so a multi-second margin is generous, not
776
+ // tight (global-lock-followups' own leaked-ticket-livelock
777
+ // fix — independent of, not shared with, acquireProjectLock's
778
+ // own PROJECT_LOCK_TICKET_STALE_SECONDS below).
779
+ const PROJECT_LOCK_FILE = ".install.lock"; // the project-scope init()/update() lock (never user-data)
780
+ const PROJECT_LOCK_STALE_DEFAULT = 120; // seconds (2 min); ADD_PROJECT_LOCK_STALE_SECONDS env-overridable —
781
+ // deliberately SHORTER than LOCK_STALE_DEFAULT's own 600s (see
782
+ // _installer.py's _PROJECT_LOCK_STALE_DEFAULT for the reasoning)
783
+ const PROJECT_LOCK_TICKET_STALE_SECONDS = 5; // a leaked per-generation reclaim ticket self-heals
784
+ // after this long — independent of, but numerically
785
+ // identical to, acquireUpdateLock's own
786
+ // LOCK_TICKET_STALE_SECONDS: a ticket's own critical
787
+ // section is the same small, fixed handful of
788
+ // syscalls regardless of which lock it guards
789
+ // (project-scope-install-lock's own
790
+ // leaked-ticket-wedge fix)
791
+
792
+ // A synchronous sleep via Atomics.wait on a throwaway SharedArrayBuffer — builtin, no new
793
+ // dependency; Node's MAIN thread (unlike a browser's) is allowed to block on Atomics.wait.
794
+ function sleepSync(ms) {
795
+ const ia = new Int32Array(new SharedArrayBuffer(4));
796
+ Atomics.wait(ia, 0, 0, ms);
797
+ }
749
798
 
750
799
  function pkgVersion() {
751
800
  try { return require(path.join(PKG_ROOT, "package.json")).version; }
@@ -782,29 +831,93 @@ function treeFiles(root) {
782
831
  return out;
783
832
  }
784
833
 
834
+ // Crash-safe stage-then-swap: project-scope-atomic-reconcile (TASK.md v1). Replaces the
835
+ // old wipe-then-copy (rmSync(dest) then cpSync onto dest directly) with a stage-into-a-
836
+ // sibling + two-rename commit, so dest is NEVER observed half-old/half-new (a random
837
+ // partial mix) — the achievable guarantee is "never observed half-composed", not "never
838
+ // observed absent for an instant" (a single-syscall atomic replace of an EXISTING
839
+ // non-empty directory is not portable; the sub-instant window between the two commit
840
+ // renames is closed by the NEXT call's own self-heal, not this one).
841
+ //
785
842
  // Returns a file-level roll-up of the heal: { restored, refreshed } where `restored` = a
786
- // file in the FINAL tree whose relative path was ABSENT before the wipe (fresh or
843
+ // file in the FINAL tree whose relative path was ABSENT before the call (fresh or
787
844
  // partially-gutted trees heal these), `refreshed` = a final file that was PRESENT before
788
845
  // (re-materialized). Orphans (present before, gone after) are swept, counted as neither.
789
846
  // Pure observation — copy semantics unchanged. Mirror of _installer.py:_clean_replace.
790
847
  function cleanReplaceTree(src, dest, stripTests) {
791
848
  if (!fs.existsSync(src)) fail("missing packaged source: " + src);
792
- const before = treeFiles(dest); // snapshot BEFORE the wipe, or it's always ∅
793
- fs.mkdirSync(path.dirname(dest), { recursive: true });
794
- if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
795
- fs.cpSync(src, dest, { recursive: true });
796
- if (stripTests) {
797
- fs.rmSync(path.join(dest, "__pycache__"), { recursive: true, force: true });
798
- for (const entry of fs.readdirSync(dest)) {
799
- if (/^test_.*\.py$/.test(entry)) fs.rmSync(path.join(dest, entry), { force: true });
849
+ const destParent = path.dirname(dest);
850
+ const destName = path.basename(dest);
851
+ fs.mkdirSync(destParent, { recursive: true });
852
+
853
+ // -- self-heal -- (before this call's own work): recover/discard whatever a PRIOR,
854
+ // interrupted call left behind. A stale backup found while dest is absent is the last
855
+ // known-good tree restore it first (a cheap rename), minimizing how long dest stays
856
+ // broken; any other stale sibling (a stage, or a backup found while dest is present) is
857
+ // discarded outright — its content is never merged or reused.
858
+ const siblings = fs.readdirSync(destParent);
859
+ const tmpStales = siblings.filter((n) => n.startsWith(destName + ".add-tmp-"));
860
+ const bakStales = siblings.filter((n) => n.startsWith(destName + ".add-bak-"))
861
+ .map((n) => path.join(destParent, n))
862
+ .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs);
863
+ let remainingBaks = bakStales;
864
+ if (!fs.existsSync(dest) && bakStales.length > 0) {
865
+ const winner = bakStales[bakStales.length - 1]; // most-recently-modified is authoritative
866
+ fs.renameSync(winner, dest);
867
+ remainingBaks = bakStales.slice(0, -1);
868
+ }
869
+ for (const stale of tmpStales.map((n) => path.join(destParent, n)).concat(remainingBaks)) {
870
+ fs.rmSync(stale, { recursive: true, force: true });
871
+ }
872
+
873
+ const before = treeFiles(dest); // snapshot BEFORE this call's own work
874
+
875
+ // -- stage -- : copy src into a fresh, uniquely-named sibling of dest, in dest's own
876
+ // parent (same filesystem, so the commit renames below are a genuine atomic move). dest
877
+ // itself is never opened for writing or deletion during this step.
878
+ const staged = fs.mkdtempSync(path.join(destParent, destName + ".add-tmp-"));
879
+ try {
880
+ fs.cpSync(src, staged, { recursive: true });
881
+ if (stripTests) {
882
+ fs.rmSync(path.join(staged, "__pycache__"), { recursive: true, force: true });
883
+ for (const entry of fs.readdirSync(staged)) {
884
+ if (/^test_.*\.py$/.test(entry)) fs.rmSync(path.join(staged, entry), { force: true });
885
+ }
886
+ }
887
+ } catch (e) {
888
+ fs.rmSync(staged, { recursive: true, force: true }); // dest untouched — it was never opened
889
+ throw e;
890
+ }
891
+
892
+ // -- commit -- : two same-parent renames, NEITHER targets an already-existing name.
893
+ const token = path.basename(staged).slice((destName + ".add-tmp-").length);
894
+ let bak = null;
895
+ if (fs.existsSync(dest)) {
896
+ bak = path.join(destParent, destName + ".add-bak-" + token);
897
+ try {
898
+ fs.renameSync(dest, bak); // (a) vacate dest, aside
899
+ } catch (e) {
900
+ fs.rmSync(staged, { recursive: true, force: true }); // dest untouched — the rename never happened
901
+ throw e;
800
902
  }
801
903
  }
904
+ try {
905
+ fs.renameSync(staged, dest); // (b) land the new generation
906
+ } catch (e) {
907
+ if (bak !== null) fs.renameSync(bak, dest); // roll back: restore the original
908
+ fs.rmSync(staged, { recursive: true, force: true });
909
+ throw e;
910
+ }
911
+
912
+ // -- sweep -- : remove the backup sibling — ONLY after (b) has landed the new dest.
913
+ if (bak !== null) fs.rmSync(bak, { recursive: true, force: true });
914
+
802
915
  let restored = 0, refreshed = 0;
803
916
  for (const f of treeFiles(dest)) (before.has(f) ? refreshed++ : restored++);
804
917
  return { restored: restored, refreshed: refreshed };
805
918
  }
806
919
 
807
- const TREE_LABEL = { "skill/add": "skill", "tooling": "tooling", "docs": "docs", "personas-teacher": "personas" };
920
+ const TREE_LABEL = { "skill/add": "skill", "agents": "agents", "tooling": "tooling", "docs": "docs", "personas-teacher": "personas" };
808
921
 
809
922
  // Per managed tree: "missing" (dest absent OR empty) or "present".
810
923
  function managedStatus(target) {
@@ -918,7 +1031,7 @@ function reconcileGlobal(home, claudeDir, noSkill) {
918
1031
  // --- global DATA: an OPT-IN per-project user-data snapshot under <home>/data/<key> ----------
919
1032
  // Strictly additive; copies ONLY user-data (managed trees + transient excluded), clean-replaced,
920
1033
  // one-way (project->home). Mirror of _installer.py (identical key + include/exclude rule).
921
- const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE]; // managed trees + meta + lock
1034
+ const DATA_EXCLUDE = ["tooling", "docs", ".update-cache", STAMP_FILE, LOCK_FILE, PROJECT_LOCK_FILE]; // managed trees + meta + both locks
922
1035
 
923
1036
  // data_key twin: <sanitized-basename>-<sha1(abspath_utf8)[:12]>. Pure · total · separator-free.
924
1037
  function dataKey(projectAbspath) {
@@ -928,28 +1041,102 @@ function dataKey(projectAbspath) {
928
1041
  return base + "-" + digest;
929
1042
  }
930
1043
 
931
- // A top-level .add/ entry is user-data unless it is a managed tree or a transient artifact.
1044
+ // A top-level .add/ entry is user-data unless it is a managed tree, a transient artifact, a
1045
+ // scratch-staging sibling left by an interrupted persist/restore/clean-replace call (the shared
1046
+ // .add-tmp-/.add-bak- infix convention — crash-safe-persist-restore v1 §3 M11), or a
1047
+ // per-generation reclaim-ticket sibling a lock's own stale-reclaim mechanism may transiently (or,
1048
+ // if leaked by a crash, semi-persistently) leave next to it (the .reclaim-<inode> infix
1049
+ // convention — project-scope-install-lock / global-lock-followups' leaked-ticket self-heal fix).
932
1050
  function isUserData(name) {
933
1051
  if (DATA_EXCLUDE.includes(name)) return false;
934
1052
  if (name.startsWith("scope-snapshot")) return false;
935
1053
  if (name.includes("pre-archive-bak")) return false;
936
1054
  if (name.endsWith(".bak.json")) return false;
1055
+ if (name.includes(".add-tmp-") || name.includes(".add-bak-")) return false;
1056
+ if (name.includes(".reclaim-")) return false;
937
1057
  return true;
938
1058
  }
939
1059
 
940
- // Clean-replace a project's USER-DATA into <home>/data/<key>. true=persisted, false=skipped
941
- // (no .add or no user-data an honest skip). Throws if the data dir can't be written.
1060
+ // Best-effort removal of a scratch sibling (staging dir/file, or a whole-tree backup dir);
1061
+ // tolerates it already being gone. A sweep failure here is hygiene, not a correctness gap
1062
+ // self-heal simply retries it on the next call touching this target.
1063
+ function sweepScratch(p) {
1064
+ try { fs.rmSync(p, { recursive: true, force: true }); } catch (_e) { /* best-effort */ }
1065
+ }
1066
+
1067
+ // Crash-safe clean-replace of a project's USER-DATA into <home>/data/<key>: self-heal any
1068
+ // scratch sibling left by an earlier interrupted call, then a WHOLE-TREE stage-then-commit
1069
+ // (mirrors cleanReplaceTree's own pattern) — dest is never opened for writing or deletion until
1070
+ // its replacement has FULLY landed. true=persisted, false=skipped (no .add or no user-data — an
1071
+ // honest skip, dest — including any EXISTING stale snapshot — left completely untouched).
1072
+ // Throws if the data dir can't be written.
942
1073
  function persistData(home, projectAbspath) {
1074
+ const key = dataKey(projectAbspath);
1075
+ const dataRoot = path.join(home, "data");
1076
+ const dest = path.join(dataRoot, key);
1077
+
1078
+ // step 0 -- self-heal: a stale backup found while dest is currently ABSENT means a PRIOR call
1079
+ // crashed between its two commit renames -- restore it first (the newest wins a >1 tie-break,
1080
+ // a defensive case, not an expected path). Then sweep every remaining scratch sibling for this
1081
+ // key unconditionally (never merged/reused).
1082
+ if (fs.existsSync(dataRoot)) {
1083
+ const siblings = fs.readdirSync(dataRoot);
1084
+ const tmpStale = siblings.filter((n) => n.startsWith(key + ".add-tmp-"));
1085
+ let bakStale = siblings.filter((n) => n.startsWith(key + ".add-bak-"));
1086
+ if (!fs.existsSync(dest) && bakStale.length > 0) {
1087
+ let newest = bakStale[0];
1088
+ let newestMtime = fs.statSync(path.join(dataRoot, newest)).mtimeMs;
1089
+ for (const n of bakStale.slice(1)) {
1090
+ const t = fs.statSync(path.join(dataRoot, n)).mtimeMs;
1091
+ if (t > newestMtime) { newest = n; newestMtime = t; }
1092
+ }
1093
+ fs.renameSync(path.join(dataRoot, newest), dest);
1094
+ bakStale = bakStale.filter((n) => n !== newest);
1095
+ }
1096
+ for (const n of bakStale) sweepScratch(path.join(dataRoot, n));
1097
+ for (const n of tmpStale) sweepScratch(path.join(dataRoot, n));
1098
+ }
1099
+
943
1100
  const addDir = path.join(projectAbspath, ".add");
944
1101
  if (!fs.existsSync(addDir)) return false;
945
1102
  const entries = fs.readdirSync(addDir).filter(isUserData);
946
- if (entries.length === 0) return false;
947
- const dest = path.join(home, "data", dataKey(projectAbspath));
948
- if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
949
- fs.mkdirSync(dest, { recursive: true });
950
- for (const e of entries) {
951
- fs.cpSync(path.join(addDir, e), path.join(dest, e), { recursive: true });
1103
+ if (entries.length === 0) return false; // UNCHANGED: an existing stale snapshot is left as-is
1104
+
1105
+ // step 1 -- stage: copy every filtered entry into a fresh, uniquely-named sibling of dest, IN
1106
+ // home/data/. dest itself is never opened for writing during this step.
1107
+ fs.mkdirSync(dataRoot, { recursive: true });
1108
+ const staged = fs.mkdtempSync(path.join(dataRoot, key + ".add-tmp-"));
1109
+ try {
1110
+ for (const e of entries) {
1111
+ fs.cpSync(path.join(addDir, e), path.join(staged, e), { recursive: true });
1112
+ }
1113
+ } catch (e) {
1114
+ sweepScratch(staged);
1115
+ throw e;
1116
+ }
1117
+
1118
+ // step 2 -- commit: two same-parent renames, neither ever targets an already-existing name.
1119
+ const backup = path.join(dataRoot, key + ".add-bak-" + crypto.randomBytes(6).toString("hex"));
1120
+ let asideLanded = false;
1121
+ if (fs.existsSync(dest)) {
1122
+ try {
1123
+ fs.renameSync(dest, backup);
1124
+ asideLanded = true;
1125
+ } catch (e) {
1126
+ sweepScratch(staged);
1127
+ throw e;
1128
+ }
952
1129
  }
1130
+ try {
1131
+ fs.renameSync(staged, dest);
1132
+ } catch (e) {
1133
+ if (asideLanded) fs.renameSync(backup, dest); // roll back: dest ends where it started
1134
+ sweepScratch(staged);
1135
+ throw e;
1136
+ }
1137
+
1138
+ // step 3 -- sweep: the old backup is removed only now that the new dest has landed.
1139
+ if (asideLanded) sweepScratch(backup);
953
1140
  return true;
954
1141
  }
955
1142
 
@@ -974,27 +1161,71 @@ function isSymlink(p) {
974
1161
  }
975
1162
 
976
1163
  // Restore USER-DATA from <home>/data/<key> into <project>/.add — the NON-DESTRUCTIVE inverse of
977
- // persistData. FILL-GAPS by default (write only ABSENT entries); force overwrites a present entry,
978
- // writing a <name>.bak FIRST. Copies only isUserData entries; DEREFERENCES symlinks to content
979
- // (cpSync dereference). true if >=1 restored, false if nothing to restore. Throws on an unwritable
980
- // dest -> restore_failed. Mirror of _installer.py:_restore_data (identical key + fill-gaps rule).
1164
+ // persistData, crash-safe via a PER-ENTRY stage-then-commit (.add/ is a SHARED directory most of
1165
+ // which this function must leave alone, so unlike persistData's self-owned whole-tree dest —
1166
+ // only ONE entry stages/commits at a time). FILL-GAPS by default (write only ABSENT entries);
1167
+ // force overwrites a present entry, writing a <name>.bak sidecar of the original FIRST (the SAME
1168
+ // permanent, already-contracted sidecar name as before this task — never the new transient
1169
+ // staging marker). Copies only isUserData entries; DEREFERENCES symlinks to content (cpSync
1170
+ // dereference). true if >=1 restored, false if nothing to restore. Throws on an unwritable dest
1171
+ // -> restore_failed. Mirror of _installer.py:_restore_data.
981
1172
  function restoreData(home, projectAbspath, force) {
1173
+ const addDir = path.join(projectAbspath, ".add");
982
1174
  const src = path.join(home, "data", dataKey(projectAbspath));
1175
+
1176
+ // step 0 -- self-heal: sweep any stale per-entry staging sibling left by an earlier
1177
+ // INTERRUPTED call, unconditionally (never merged/reused/completed) -- the untouched snapshot
1178
+ // at src lets THIS call's own ordinary fill-gaps-or-force logic re-derive the correct end
1179
+ // state; no backup-recovery step is needed here (unlike persistData's step 0).
1180
+ if (fs.existsSync(addDir)) {
1181
+ for (const n of fs.readdirSync(addDir)) {
1182
+ if (n.includes(".add-tmp-")) sweepScratch(path.join(addDir, n));
1183
+ }
1184
+ }
1185
+
983
1186
  if (!fs.existsSync(src)) return false;
984
1187
  const entries = fs.readdirSync(src).filter(isUserData).sort();
985
1188
  if (entries.length === 0) return false;
986
- const addDir = path.join(projectAbspath, ".add");
987
1189
  fs.mkdirSync(addDir, { recursive: true });
988
1190
  let restored = false;
989
1191
  for (const e of entries) {
990
1192
  const dest = path.join(addDir, e);
991
- if (fs.existsSync(dest) || isSymlink(dest)) {
992
- if (!force) continue; // fill-gaps: never clobber a present entry
1193
+ const existed = fs.existsSync(dest) || isSymlink(dest);
1194
+ if (existed && !force) continue; // fill-gaps: never clobber; no staging begins
1195
+
1196
+ // step 1b -- stage: copy this ONE entry into a fresh, uniquely-named sibling of dest, IN
1197
+ // .add/ -- dest's own name is not opened for writing during this step. A reserved unique
1198
+ // name is claimed via mkdtempSync, then either kept as a dir (cpSync merges into the empty
1199
+ // dir) or freed via rmdirSync for a plain-file copy.
1200
+ const staged = fs.mkdtempSync(path.join(addDir, e + ".add-tmp-"));
1201
+ try {
1202
+ const srcEntry = path.join(src, e);
1203
+ if (fs.statSync(srcEntry).isDirectory()) {
1204
+ fs.cpSync(srcEntry, staged, { recursive: true, dereference: true });
1205
+ } else {
1206
+ fs.rmdirSync(staged);
1207
+ fs.cpSync(srcEntry, staged, { dereference: true }); // deref a symlink source
1208
+ }
1209
+ } catch (err) {
1210
+ sweepScratch(staged);
1211
+ throw err;
1212
+ }
1213
+
1214
+ // step 1c -- commit:
1215
+ if (existed) {
993
1216
  const bak = dest + ".bak";
994
- if (fs.existsSync(bak) || isSymlink(bak)) fs.rmSync(bak, { recursive: true, force: true });
995
- fs.renameSync(dest, bak); // back up the original BEFORE replacing
1217
+ if (fs.existsSync(bak) || isSymlink(bak)) sweepScratch(bak); // a stale .bak: replace, don't merge
1218
+ fs.renameSync(dest, bak); // back up the original BEFORE replacing
1219
+ try {
1220
+ fs.renameSync(staged, dest);
1221
+ } catch (err) {
1222
+ fs.renameSync(bak, dest); // roll back: this entry ends where it started
1223
+ sweepScratch(staged);
1224
+ throw err;
1225
+ }
1226
+ } else {
1227
+ fs.renameSync(staged, dest); // fill-gaps: single rename, no backup needed
996
1228
  }
997
- fs.cpSync(path.join(src, e), dest, { recursive: true, dereference: true }); // deref symlinks
998
1229
  restored = true;
999
1230
  }
1000
1231
  return restored;
@@ -1060,9 +1291,14 @@ function cmdPruneData(args) {
1060
1291
 
1061
1292
  // init --global: install the managed layer ONCE to the shared home + register this project,
1062
1293
  // fail-closed BEFORE the per-project drop. Returns the resolved target for the normal drop.
1294
+ // Serialized under the SAME home lock update --global uses (global-lock-followups M3) — a lock
1295
+ // failure aborts BEFORE any home/registry write and BEFORE the per-project drop (dropFiles,
1296
+ // called by cmdInit right after this returns), matching the all-or-nothing precedent every
1297
+ // other as_global-path failure already has.
1063
1298
  function installGlobal(args, chosenTarget) {
1064
1299
  const home = resolveGlobalHome(process.env);
1065
1300
  const claudeDir = claudeSkillsDir(process.env);
1301
+ acquireUpdateLock(home, { timeout: args.lockTimeout }, process.env);
1066
1302
  try { reconcileGlobal(home, claudeDir, args.noSkill); } // home_unwritable
1067
1303
  catch (e) { fail("cannot write global home " + home + " — " + (e && e.message ? e.message : e)); }
1068
1304
  writeStamp(home, pkgVersion(), "global");
@@ -1090,25 +1326,318 @@ function validRegistryPath(p) {
1090
1326
  catch (_e) { return false; }
1091
1327
  }
1092
1328
 
1093
- // Serialize `update --global` with an EXCLUSIVE lockfile (O_CREAT|O_EXCL via the "wx" flag).
1094
- // Already held -> update_in_progress (fail-fast). Released on process exit (normal completion OR
1095
- // fail()'s process.exit), so it never outlives the run; a hard crash may leave a stale lock to
1096
- // remove by hand. pip twin: _installer.py:_update_lock (the SAME O_EXCL lockfile, cross-compatible).
1097
- function acquireUpdateLock(home) {
1329
+ // Serialize `update --global` (and, since global-lock-followups, `init --global`) with an
1330
+ // EXCLUSIVE lockfile (O_CREAT|O_EXCL via the "wx" flag) the SAME mechanism as the pip twin
1331
+ // (_installer.py:_update_lock's os.open(O_EXCL)), so a pip-held lock blocks an npm run and
1332
+ // vice-versa.
1333
+ //
1334
+ // Held AND fresh (age <= ADD_LOCK_STALE_SECONDS, env-overridable, default 600s) -> today's
1335
+ // byte-identical behavior: `timeout` null/0 fails "update_in_progress" immediately; timeout=N>0
1336
+ // polls up to N seconds before failing.
1337
+ //
1338
+ // Held AND stale (age > threshold) -> self-heals: unlinks the stale lockfile and retries the
1339
+ // create. The "wx" create remains the SOLE mutual-exclusion primitive — staleness only ever
1340
+ // decides whether to retry, never bypasses exclusivity. A clock-skewed FUTURE mtime is a
1341
+ // negative age, so it is NEVER treated as stale.
1342
+ //
1343
+ // A successful acquire (fresh or reclaimed) stamps the file "<PID> <ISO ts>\n" — informational
1344
+ // ONLY, never read to decide staleness; a stamp-write failure is swallowed.
1345
+ //
1346
+ // KNOWN PROBLEM this shape works around: fail() calls process.exit(1) DIRECTLY (skips any
1347
+ // pending finally/loop state) — so this retry/self-heal loop uses `continue`/`break` for its
1348
+ // OWN internal control flow and calls fail() **at most once**, only AFTER the loop has
1349
+ // genuinely exhausted every retry/self-heal/wait attempt — never from inside the loop body.
1350
+ //
1351
+ // Released on process exit (normal completion OR fail()'s process.exit), so it never outlives
1352
+ // the run; a hard crash may leave a stale lock — the NEXT acquire self-heals it automatically.
1353
+ function acquireUpdateLock(home, { timeout = null } = {}, env = process.env) {
1098
1354
  fs.mkdirSync(home, { recursive: true });
1099
1355
  const lockPath = path.join(home, LOCK_FILE);
1100
- let fd;
1101
- try { fd = fs.openSync(lockPath, "wx"); }
1102
- catch (e) {
1103
- if (e && e.code === "EEXIST") {
1104
- fail("update_in_progress: another `update --global` is already running — retry shortly " +
1105
- "(remove " + lockPath + " if it is stale)");
1356
+ const staleAfterMs = Number(env.ADD_LOCK_STALE_SECONDS || LOCK_STALE_DEFAULT) * 1000;
1357
+ const deadline = timeout ? (Date.now() + timeout * 1000) : null; // null/0 = byte-identical fail-fast
1358
+
1359
+ let fd = null;
1360
+ let timedOut = false;
1361
+ while (fd === null) {
1362
+ try {
1363
+ fd = fs.openSync(lockPath, "wx");
1364
+ break;
1365
+ } catch (e) {
1366
+ if (!e || e.code !== "EEXIST") throw e;
1367
+ }
1368
+ let st;
1369
+ try { st = fs.statSync(lockPath); }
1370
+ catch (_e) { continue; } // vanished between the failed open and the stat — retry the create
1371
+ let reclaimed = false;
1372
+ if (Date.now() - st.mtimeMs > staleAfterMs) {
1373
+ // Identity-verified reclaim (fixes a TOCTOU race: an unconditional unlink-by-path let a
1374
+ // second racer delete a FIRST racer's already-recreated, live lock). A NAIVE "rename to a
1375
+ // quarantine name" does NOT fix this — a rename is JUST as identity-blind as an unlink: it
1376
+ // operates on whatever currently sits at `lockPath`, so a delayed racer's rename can just
1377
+ // as easily steal a WINNER's brand-new fresh file (empirically reproduced while building
1378
+ // this fix; that attempt made the race MEASURABLY WORSE, not better, by adding an extra
1379
+ // syscall to the vulnerable window). The actual fix: gate entry to the reclaim itself
1380
+ // behind a SEPARATE, per-GENERATION exclusive ticket, keyed to the CURRENT stale file's
1381
+ // own inode number (`st.ino` — stable for this file's lifetime, and a FRESH replacement
1382
+ // file always gets a NEW inode). Two racers that observe the SAME stale file compute the
1383
+ // IDENTICAL ticket name and race an exclusive create on IT — only one wins; every loser
1384
+ // backs off WITHOUT ever touching `lockPath` itself, so nobody can ever unlink/steal a
1385
+ // generation they didn't win the ticket for.
1386
+ const ticketPath = lockPath + ".reclaim-" + st.ino;
1387
+ let ticketFd = null;
1388
+ try {
1389
+ ticketFd = fs.openSync(ticketPath, "wx");
1390
+ } catch (_e) { /* LOST the ticket outright — checked below before giving up on it */ }
1391
+ if (ticketFd === null) {
1392
+ // LOST the per-generation reclaim ticket outright. Before treating this as "someone
1393
+ // else legitimately owns reclaiming THIS stale file right now," check whether THAT
1394
+ // ticket is itself orphaned — leaked by a process that crashed between winning it and
1395
+ // its own best-effort cleanup below. Left unchecked, a leaked ticket wedges this
1396
+ // generation's reclaim FOREVER: the ticket's name is deterministically keyed to
1397
+ // `lockPath`'s own (unchanging) inode, so every future contender recomputes the
1398
+ // IDENTICAL ticket path, loses the identical EEXIST race, and — pre-fix — `continue`d
1399
+ // straight back to the top of this loop without ever reaching the `deadline` check
1400
+ // below: an unbounded livelock no `--lock-timeout` could ever interrupt (found by a
1401
+ // fresh adversarial verify pass; independently reproduced here against the unmodified
1402
+ // code via both a direct call and a real `node cli.js` subprocess, both still spinning
1403
+ // well past their own declared timeout budget).
1404
+ let tst = null;
1405
+ try { tst = fs.statSync(ticketPath); } catch (_e) { /* vanished — retry the create below */ }
1406
+ if (tst === null) {
1407
+ try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {}
1408
+ } else if (Date.now() - tst.mtimeMs > LOCK_TICKET_STALE_SECONDS * 1000) {
1409
+ // The ticket ITSELF is orphaned — reclaim it with the IDENTICAL identity-verified
1410
+ // discipline already used for the main lock just above, one level down: re-stat
1411
+ // immediately before unlinking and compare inode, so a ticket some THIRD, still-
1412
+ // legitimately-in-flight reclaimer freshly (re)created in the gap between our stat
1413
+ // and our unlink is never destroyed. A plain unconditional unlink-by-path here would
1414
+ // reopen the IDENTICAL TOCTOU hole this whole ticket mechanism exists to close, one
1415
+ // level down (e.g. a naive "just unlink if old" would let racer A destroy racer C's
1416
+ // brand-new, not-yet-stale ticket for the SAME generation, so A and C would BOTH
1417
+ // believe they are the sole reclaimer — the exact double-hold bug this ticket
1418
+ // mechanism was built to prevent, reintroduced one level down).
1419
+ let currentTino = null;
1420
+ try { currentTino = fs.statSync(ticketPath).ino; } catch (_e) { /* already gone */ }
1421
+ if (currentTino === tst.ino) {
1422
+ try { fs.unlinkSync(ticketPath); } catch (_e) {} // already gone — harmless
1423
+ }
1424
+ try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {} // someone else won it
1425
+ }
1426
+ // else: the ticket is live and fresh — a genuine, currently-in-flight reclaimer;
1427
+ // ticketFd stays null and falls through to the shared deadline check below (previously
1428
+ // an unconditional `continue` back to the top of this same branch — now routed the same
1429
+ // as any other non-progress iteration, never a special case that could spin unboundedly
1430
+ // on its own).
1431
+ }
1432
+ if (ticketFd !== null) {
1433
+ try {
1434
+ fs.closeSync(ticketFd);
1435
+ // Winning the ticket proves we are the SOLE reclaimer for the generation we observed
1436
+ // (`st.ino`) — it does NOT prove `lockPath` is STILL that generation right now. An
1437
+ // arbitrarily long scheduling gap can separate "we judged st.ino stale" from "we act on
1438
+ // it," and in that gap this SAME path can already have cycled through a full, unrelated
1439
+ // reclaim by someone else (that old inode gone, a fresh one created and now actively
1440
+ // held — empirically observed while building this fix: a late-scheduled racer's ticket
1441
+ // for a long-superseded inode still "won" trivially, since nothing else was contesting
1442
+ // that specific stale ticket name any more, and its UNCONDITIONAL unlink then blindly
1443
+ // destroyed the CURRENT live holder's fresh file). Re-stat immediately before mutating
1444
+ // and compare inodes: only remove the file if it is STILL, right now, the exact
1445
+ // generation we ticketed for; otherwise our ticket is moot — leave the (unrelated,
1446
+ // currently live) file completely alone and let the loop's own open/EEXIST/age logic
1447
+ // re-evaluate reality fresh on the next iteration.
1448
+ let currentIno = null;
1449
+ try { currentIno = fs.statSync(lockPath).ino; } catch (_e) { /* already gone */ }
1450
+ if (currentIno === st.ino) {
1451
+ try { fs.unlinkSync(lockPath); } catch (_e) {} // already gone — harmless
1452
+ }
1453
+ } finally {
1454
+ try { fs.unlinkSync(ticketPath); } catch (_e) {} // best-effort cleanup of our own ticket
1455
+ }
1456
+ reclaimed = true;
1457
+ }
1458
+ }
1459
+ if (reclaimed) {
1460
+ continue; // retry the create immediately (self-heal)
1461
+ }
1462
+ if (deadline !== null && Date.now() < deadline) {
1463
+ sleepSync(LOCK_POLL_INTERVAL_MS);
1464
+ continue; // keep polling a LIVE holder — or a still-contested ticket — until the deadline.
1465
+ // This check is now ALWAYS reachable on every non-reclaiming iteration (the
1466
+ // fix's other half): a wedged/leaked ticket can no longer bypass it by looping
1467
+ // back to the top of the `while` unconditionally, so an explicit --lock-timeout
1468
+ // is honored even while a stale ticket is being self-healed above.
1469
+ }
1470
+ timedOut = true;
1471
+ break;
1472
+ }
1473
+ if (timedOut) {
1474
+ fail("update_in_progress: another `update --global` is already running — retry shortly " +
1475
+ "(remove " + lockPath + " if it is stale)");
1476
+ return () => {}; // unreachable once fail() exits — keeps the function's return shape honest
1477
+ }
1478
+ try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
1479
+ catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
1480
+ const release = () => {
1481
+ try { fs.closeSync(fd); } catch (_e) {}
1482
+ try { fs.unlinkSync(lockPath); } catch (_e) {}
1483
+ };
1484
+ process.on("exit", release); // covers normal completion AND fail()'s process.exit
1485
+ return release;
1486
+ }
1487
+
1488
+ // project-scope lock (project-scope-install-lock) — serializes cmdInit()/cmdUpdate() (non-
1489
+ // `--global` path) against the SAME target's own .add/ tree. A NEW, INDEPENDENT primitive that
1490
+ // mirrors (but never calls into or shares code with) acquireUpdateLock's own proven shape:
1491
+ // different function, different file, different default threshold, zero shared code (the two
1492
+ // locks guard genuinely different-shaped resources — see _installer.py:_project_lock).
1493
+ //
1494
+ // Held AND fresh -> fails IMMEDIATELY with "install_in_progress". UNLIKE acquireUpdateLock,
1495
+ // there is NO bounded-wait/poll mode (a live contention never waits, never polls — M7).
1496
+ //
1497
+ // Held AND stale (age > ADD_PROJECT_LOCK_STALE_SECONDS, default 120s) -> self-heals: unlinks
1498
+ // the stale lockfile and retries the create EXACTLY once before falling through to fail-fast.
1499
+ // A clock-skewed FUTURE mtime is NEVER treated as stale.
1500
+ //
1501
+ // KNOWN PROBLEM this shape works around: fail() calls process.exit(1) DIRECTLY (skips any
1502
+ // pending finally) — release is wired via process.on("exit", release), never a plain
1503
+ // try/finally at the call site (mirrors acquireUpdateLock's own already-solved precedent).
1504
+ //
1505
+ // If addDir did not exist yet (a virgin target — the lock file needs somewhere to live), it is
1506
+ // created here; on release, an addDir THIS call created is removed again iff it is still
1507
+ // completely empty (the lock file was its only occupant) — e.g. an --global failure (a held
1508
+ // home lock, an unwritable home) that aborts before the per-project drop leaves NOTHING behind,
1509
+ // exactly as before this lock existed. A non-empty addDir (the real drop landed, or it
1510
+ // pre-existed) is never touched.
1511
+ function acquireProjectLock(addDir, env = process.env) {
1512
+ const createdDir = !fs.existsSync(addDir);
1513
+ fs.mkdirSync(addDir, { recursive: true });
1514
+ const lockPath = path.join(addDir, PROJECT_LOCK_FILE);
1515
+ const staleAfterMs = Number(env.ADD_PROJECT_LOCK_STALE_SECONDS || PROJECT_LOCK_STALE_DEFAULT) * 1000;
1516
+
1517
+ let fd = null;
1518
+ try {
1519
+ fd = fs.openSync(lockPath, "wx");
1520
+ } catch (e) {
1521
+ if (!e || e.code !== "EEXIST") throw e;
1522
+ let st = null;
1523
+ try { st = fs.statSync(lockPath); } catch (_e) { /* vanished — treat as reclaimable now */ }
1524
+ if (st === null) {
1525
+ // vanished between the failed open and the stat — nothing to quarantine; retry a plain
1526
+ // create directly (safe unconditionally: O_EXCL is still the sole arbiter — this can
1527
+ // never clobber a legitimate concurrent holder's fresh file, it can only succeed if the
1528
+ // path is genuinely vacant right now).
1529
+ try {
1530
+ fd = fs.openSync(lockPath, "wx");
1531
+ } catch (e2) {
1532
+ if (!e2 || e2.code !== "EEXIST") throw e2;
1533
+ }
1534
+ } else if (Date.now() - st.mtimeMs > staleAfterMs) {
1535
+ // Identity-verified reclaim (fixes a TOCTOU race: an unconditional unlink-by-path let a
1536
+ // second racer delete a FIRST racer's already-recreated, live lock). A NAIVE "rename to a
1537
+ // quarantine name" does NOT fix this — a rename is JUST as identity-blind as an unlink: it
1538
+ // operates on whatever currently sits at `lockPath`, so a delayed racer's rename can just
1539
+ // as easily steal a WINNER's brand-new fresh file (empirically reproduced while building
1540
+ // this fix; that attempt made the race MEASURABLY WORSE, not better, by adding an extra
1541
+ // syscall to the vulnerable window). The actual fix: gate entry to the reclaim itself
1542
+ // behind a SEPARATE, per-GENERATION exclusive ticket, keyed to the CURRENT stale file's
1543
+ // own inode number (`st.ino` — stable for this file's lifetime, and a FRESH replacement
1544
+ // file always gets a NEW inode). Two racers that observe the SAME stale file compute the
1545
+ // IDENTICAL ticket name and race an exclusive create on IT — only one wins; every loser
1546
+ // backs off WITHOUT ever touching `lockPath` itself, so nobody can ever unlink/steal a
1547
+ // generation they didn't win the ticket for.
1548
+ const ticketPath = lockPath + ".reclaim-" + st.ino;
1549
+ let ticketFd = null;
1550
+ try {
1551
+ ticketFd = fs.openSync(ticketPath, "wx");
1552
+ } catch (_e) {
1553
+ // LOST the per-generation reclaim ticket outright -- checked below before treating this
1554
+ // as "someone else legitimately owns reclaiming THIS stale file right now."
1555
+ }
1556
+ if (ticketFd === null) {
1557
+ // LOST the ticket outright. A leaked ticket -- its own holder crashed between winning
1558
+ // it and its own best-effort cleanup below -- would otherwise wedge this generation's
1559
+ // reclaim PERMANENTLY: the ticket's name is deterministically keyed to `lockPath`'s own
1560
+ // (unchanging) inode, so every future contender recomputes the IDENTICAL ticket path and
1561
+ // loses the identical EEXIST race forever (found by a fresh adversarial verify pass;
1562
+ // independently reproduced here against the unmodified code).
1563
+ let tst = null;
1564
+ try { tst = fs.statSync(ticketPath); } catch (_e) { /* vanished — retry the create below */ }
1565
+ if (tst === null) {
1566
+ try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {}
1567
+ } else if (Date.now() - tst.mtimeMs > PROJECT_LOCK_TICKET_STALE_SECONDS * 1000) {
1568
+ // The ticket ITSELF is orphaned — reclaim it with the IDENTICAL identity-verified
1569
+ // discipline already used for the main lock just above, one level down: re-stat
1570
+ // immediately before unlinking and compare inode, so a ticket some THIRD, still-
1571
+ // legitimately-in-flight reclaimer freshly (re)created in the gap between our stat and
1572
+ // our unlink is never destroyed. A plain unconditional unlink-by-path here would
1573
+ // reopen the IDENTICAL TOCTOU hole this whole ticket mechanism exists to close, one
1574
+ // level down (e.g. a naive "just unlink if old" would let racer A destroy racer C's
1575
+ // brand-new, not-yet-stale ticket for the SAME generation, so A and C would BOTH
1576
+ // believe they are the sole reclaimer — the exact double-hold bug this ticket
1577
+ // mechanism was built to prevent, reintroduced one level down). Exactly one extra
1578
+ // self-heal attempt — never a second, matching M7's own "no poll, ever" discipline
1579
+ // already governing the main lock's own reclaim above.
1580
+ let currentTino = null;
1581
+ try { currentTino = fs.statSync(ticketPath).ino; } catch (_e) { /* already gone */ }
1582
+ if (currentTino === tst.ino) {
1583
+ try { fs.unlinkSync(ticketPath); } catch (_e) {} // already gone — harmless
1584
+ }
1585
+ try { ticketFd = fs.openSync(ticketPath, "wx"); } catch (_e) {} // someone else won it
1586
+ }
1587
+ // else: the ticket is live and fresh — a genuine, currently-in-flight reclaimer;
1588
+ // ticketFd stays null, falls through to the SAME fail-fast below (M7 — this lock never
1589
+ // polls a live holder, whether it is the main lock or a contested ticket).
1590
+ }
1591
+ if (ticketFd !== null) {
1592
+ try {
1593
+ fs.closeSync(ticketFd);
1594
+ // Winning the ticket proves we are the SOLE reclaimer for the generation we observed
1595
+ // (`st.ino`) — it does NOT prove `lockPath` is STILL that generation right now. An
1596
+ // arbitrarily long scheduling gap can separate "we judged st.ino stale" from "we act
1597
+ // on it," and in that gap this SAME path can already have cycled through a full,
1598
+ // unrelated reclaim by someone else (that old inode gone, a fresh one created and now
1599
+ // actively held — empirically observed while building this fix: a late-scheduled
1600
+ // racer's ticket for a long-superseded inode still "won" trivially, since nothing else
1601
+ // was contesting that specific stale ticket name any more, and its UNCONDITIONAL
1602
+ // unlink then blindly destroyed the CURRENT live holder's fresh file). Re-stat
1603
+ // immediately before mutating and compare inodes: only remove the file if it is
1604
+ // STILL, right now, the exact generation we ticketed for; otherwise our ticket is
1605
+ // moot — leave the (unrelated, currently live) file completely alone and let the
1606
+ // single retry below observe reality fresh (EEXIST -> fail-fast, per M7 — this lock
1607
+ // never polls a live holder).
1608
+ let currentIno = null;
1609
+ try { currentIno = fs.statSync(lockPath).ino; } catch (_e) { /* already gone */ }
1610
+ if (currentIno === st.ino) {
1611
+ try { fs.unlinkSync(lockPath); } catch (_e) {} // already gone — harmless
1612
+ }
1613
+ } finally {
1614
+ try { fs.unlinkSync(ticketPath); } catch (_e) {} // best-effort cleanup of our own ticket
1615
+ }
1616
+ try {
1617
+ fd = fs.openSync(lockPath, "wx");
1618
+ } catch (e2) {
1619
+ if (!e2 || e2.code !== "EEXIST") throw e2;
1620
+ // someone else created a fresh file at the just-vacated path before we did (e.g. a
1621
+ // brand-new, never-raced contender's own first attempt landed in the gap) -> fail-fast,
1622
+ // exactly once — never a second reclaim attempt, never a poll/wait (M7).
1623
+ }
1624
+ }
1625
+ }
1626
+ if (fd === null) {
1627
+ fail("install_in_progress: another install/update is already running against " +
1628
+ path.dirname(addDir) + " — retry shortly (remove " + lockPath + " if stale)");
1629
+ return () => {}; // unreachable once fail() exits — keeps the function's return shape honest
1106
1630
  }
1107
- throw e;
1108
1631
  }
1632
+ try { fs.writeSync(fd, process.pid + " " + new Date().toISOString() + "\n"); }
1633
+ catch (_e) {} // diagnostics are best-effort — never fail an acquired lock over this
1109
1634
  const release = () => {
1110
1635
  try { fs.closeSync(fd); } catch (_e) {}
1111
1636
  try { fs.unlinkSync(lockPath); } catch (_e) {}
1637
+ if (createdDir) {
1638
+ try { if (fs.readdirSync(addDir).length === 0) fs.rmdirSync(addDir); } catch (_e) {}
1639
+ // non-empty (the real drop landed) or already gone — leave it alone either way
1640
+ }
1112
1641
  };
1113
1642
  process.on("exit", release); // covers normal completion AND fail()'s process.exit
1114
1643
  return release;
@@ -1120,7 +1649,9 @@ function cmdUpdateGlobal(args) {
1120
1649
  if (!fs.existsSync(path.join(home, STAMP_FILE))) {
1121
1650
  fail("no_global_home: no global ADD install at " + home + " (.add-version not found) — run `init --global` first");
1122
1651
  }
1123
- acquireUpdateLock(home); // exclusive; EEXIST -> update_in_progress; released on process exit
1652
+ // exclusive; self-heals a stale lock; a LIVE lock -> update_in_progress (immediate, or after
1653
+ // waiting up to --lock-timeout seconds); released on process exit
1654
+ acquireUpdateLock(home, { timeout: args.lockTimeout }, process.env);
1124
1655
  // Read the registry BEFORE refreshing the home — a corrupt registry fails closed with ZERO
1125
1656
  // writes (never a silent empty-list no-op), leaving the file for the user to fix or delete.
1126
1657
  let reg;
@@ -1176,6 +1707,10 @@ function cmdUpdate(args) {
1176
1707
  else log("ADD update available: project on " + cur + ", package is " + version + ". Run `update`.");
1177
1708
  return;
1178
1709
  }
1710
+ // Project-scope lock (project-scope-install-lock): acquired AFTER the read-only --check
1711
+ // report (a JS-only carve-out — see TASK.md §0/M3), held from here through the function's
1712
+ // end — INCLUDING the same-version no-op check below, so a retried call re-evaluates it fresh.
1713
+ acquireProjectLock(addDir);
1179
1714
  // same-version no-op ONLY when nothing is missing — a missing managed tree HEALS
1180
1715
  // even at the current version (heal-reconcile).
1181
1716
  const status = managedStatus(target);