@lifeaitools/rdc-skills 0.25.5 → 0.25.10

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 (66) hide show
  1. package/.claude-plugin/plugin.json +1550 -1550
  2. package/.github/workflows/self-test.yml +34 -34
  3. package/CHANGELOG.md +319 -319
  4. package/MANIFEST.md +224 -224
  5. package/README.md +367 -367
  6. package/commands/build.md +181 -181
  7. package/commands/collab.md +180 -180
  8. package/commands/deploy.md +148 -148
  9. package/commands/fixit.md +150 -150
  10. package/commands/handoff.md +173 -173
  11. package/commands/overnight.md +220 -220
  12. package/commands/plan.md +158 -158
  13. package/commands/preplan.md +131 -131
  14. package/commands/prototype.md +145 -145
  15. package/commands/report.md +99 -99
  16. package/commands/review.md +120 -120
  17. package/commands/status.md +86 -86
  18. package/commands/workitems.md +127 -127
  19. package/git-sha.json +1 -1
  20. package/guides/agent-bootstrap.md +195 -195
  21. package/guides/agents/backend.md +102 -102
  22. package/guides/agents/content.md +94 -94
  23. package/guides/agents/cs2.md +56 -56
  24. package/guides/agents/data.md +86 -86
  25. package/guides/agents/design.md +77 -77
  26. package/guides/agents/frontend.md +91 -91
  27. package/guides/agents/infrastructure.md +81 -81
  28. package/guides/agents/setup.md +272 -272
  29. package/guides/agents/verify.md +119 -119
  30. package/guides/agents/viz.md +106 -106
  31. package/hooks/check-rdc-environment.js +378 -164
  32. package/hooks/lib/box-lock.js +207 -0
  33. package/package.json +1 -1
  34. package/scripts/install-rdc-skills.js +97 -8
  35. package/scripts/local-install-with-stop.sh +41 -0
  36. package/scripts/probe-box-lock.mjs +179 -0
  37. package/scripts/probe-installed-hooks.mjs +60 -0
  38. package/scripts/probe-lock-holders.mjs +36 -0
  39. package/scripts/self-test.mjs +1459 -1459
  40. package/scripts/validate-publish-manifests.js +502 -502
  41. package/skills/build/SKILL.md +578 -578
  42. package/skills/channel-formatter/SKILL.md +538 -538
  43. package/skills/collab/SKILL.md +239 -239
  44. package/skills/convert/SKILL.md +138 -138
  45. package/skills/deploy/SKILL.md +541 -541
  46. package/skills/design/SKILL.md +205 -205
  47. package/skills/env/SKILL.md +139 -139
  48. package/skills/fixit/SKILL.md +203 -203
  49. package/skills/handoff/SKILL.md +236 -236
  50. package/skills/housekeeping/SKILL.md +189 -189
  51. package/skills/onramp/SKILL.md +1459 -1459
  52. package/skills/overnight/SKILL.md +251 -251
  53. package/skills/plan/SKILL.md +345 -345
  54. package/skills/preplan/SKILL.md +90 -90
  55. package/skills/prototype/SKILL.md +150 -150
  56. package/skills/regen-media/SKILL.md +94 -94
  57. package/skills/release/SKILL.md +140 -140
  58. package/skills/report/SKILL.md +100 -100
  59. package/skills/review/SKILL.md +151 -151
  60. package/skills/self-test/SKILL.md +108 -108
  61. package/skills/status/SKILL.md +99 -99
  62. package/skills/tests/MATRIX.md +55 -55
  63. package/skills/tests/onramp.test.json +87 -87
  64. package/skills/tests/rdc-regen-media.test.json +29 -29
  65. package/skills/watch/SKILL.md +84 -84
  66. package/skills/workitems/SKILL.md +151 -151
@@ -0,0 +1,207 @@
1
+ 'use strict';
2
+ /**
3
+ * Box-wide single-flight lock — ONE home for the rule.
4
+ *
5
+ * The global rdc-skills package and rdc-skills-mcp are BOX-WIDE (one per machine),
6
+ * but the SessionStart hook runs per session and per worktree. Without
7
+ * coordination, N sessions each conclude "unhealthy" and each run
8
+ * `npm install -g` against the same directory — and on Windows, against a
9
+ * directory a live process is sitting in.
10
+ *
11
+ * This lives in its own module because the verification probe MUST exercise the
12
+ * real implementation. It previously kept a private copy of these functions, and
13
+ * that copy silently drifted to the old algorithm — so the probe reported PASS for
14
+ * code that was not shipping, and later FAIL for code that was already fixed. A
15
+ * test that duplicates its subject tests nothing.
16
+ */
17
+ const fs = require('node:fs');
18
+ const os = require('node:os');
19
+ const path = require('node:path');
20
+
21
+ const LOCK_PATH = path.join(os.tmpdir(), 'rdc-skills-environment-repair.lock');
22
+ const LOCK_STALE_MS = 5 * 60 * 1000;
23
+
24
+ let lockHeld = false;
25
+
26
+ // How long to let a fresh acquisition settle before trusting it. Long enough to
27
+ // cover the remove-then-create gap of a concurrent reclaim, short enough to be
28
+ // invisible on a SessionStart path that already budgets minutes for repair.
29
+ const SETTLE_MS = 60;
30
+
31
+ /** Synchronous sleep — this module runs in a hook with no event loop to yield to. */
32
+ function settle(ms) {
33
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
34
+ }
35
+
36
+ /**
37
+ * Is `pid` a live process?
38
+ *
39
+ * PID 0 is NOT a probe: process.kill(0, 0) signals the CURRENT PROCESS GROUP and
40
+ * succeeds, so a truncated lock written as {"pid":0} would read as alive forever.
41
+ * Reject non-positive/non-integer pids before asking the OS.
42
+ */
43
+ function processAlive(pid) {
44
+ if (!Number.isInteger(pid) || pid <= 0) return false;
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ } catch (err) {
49
+ return Boolean(err && err.code === 'EPERM'); // exists, not ours to signal
50
+ }
51
+ }
52
+
53
+ /**
54
+ * A lock we cannot reason about is stale.
55
+ *
56
+ * A parseable-but-corrupt body ({"pid":0,"ts":"garbage"}) threw nothing, so
57
+ * `NaN > LOCK_STALE_MS` was false and the lock was judged live FOREVER — wedging
58
+ * every session onto the follower path until the tmpdir file was deleted by hand.
59
+ */
60
+ function lockIsStale(held) {
61
+ const pid = Number(held && held.pid);
62
+ const age = Date.now() - Date.parse(held && held.ts);
63
+ if (!Number.isInteger(pid) || pid <= 0 || !Number.isFinite(age)) return true;
64
+ return !processAlive(pid) || age > LOCK_STALE_MS;
65
+ }
66
+
67
+ /**
68
+ * Become the box's updater, or report that someone else already is.
69
+ *
70
+ * Stale locks are reclaimed by RENAME, never unlink-then-create. The naive
71
+ * sequence (read → judge stale → unlink → open 'wx') is a TOCTOU: two sessions can
72
+ * both read the same stale lock, both judge it stale, and the second's unlink can
73
+ * delete the FIRST's freshly created lock — leaving both convinced they own the
74
+ * box-wide update, which is the exact race this module exists to prevent.
75
+ * rename() is atomic, so only one racer can win, and only the winner proceeds.
76
+ */
77
+ function acquireBoxLock() {
78
+ for (let attempt = 0; attempt < 4; attempt++) {
79
+ try {
80
+ const body = JSON.stringify({ pid: process.pid, ts: new Date().toISOString() });
81
+ const fd = fs.openSync(LOCK_PATH, 'wx');
82
+ fs.writeSync(fd, body);
83
+ fs.closeSync(fd);
84
+
85
+ // POST-ACQUIRE VERIFICATION — the part that actually makes this single-flight.
86
+ //
87
+ // O_EXCL guarantees only one process can CREATE the file, but not one OWNER
88
+ // across a delete-and-recreate: reclaiming a stale lock necessarily leaves the
89
+ // path empty for an instant, and a racer arriving in that gap creates it
90
+ // legitimately. Both then believe they lead. No amount of care in the reclaim
91
+ // path closes that window — it is inherent to remove-then-create.
92
+ //
93
+ // So settle briefly and re-read: whoever's bytes are still there owns the box,
94
+ // and everyone else stands down. This converges on exactly one leader instead
95
+ // of trying to make the gap not exist. Without it the race was intermittent —
96
+ // 0/12, 1/12, 2/12 double-leader rounds across runs, which is the worst kind
97
+ // of bug to ship: it passes CI and fails on a busy machine.
98
+ settle(SETTLE_MS);
99
+ // Distinguish "someone else took it" from "the read failed". Standing down on
100
+ // a transient read error can elect ZERO leaders — nobody repairs the box while
101
+ // every session waits and then hard-blocks. Only a genuine byte MISMATCH means
102
+ // another session leads.
103
+ let observed = null;
104
+ let readOk = false;
105
+ for (let r = 0; r < 2 && !readOk; r++) {
106
+ try { observed = fs.readFileSync(LOCK_PATH, 'utf8'); readOk = true; } catch { settle(20); }
107
+ }
108
+ if (!readOk) continue; // could not verify — retry the create
109
+ if (observed !== body) return false; // someone reclaimed it; they lead
110
+
111
+ lockHeld = true;
112
+ return true;
113
+ } catch (err) {
114
+ if (err.code !== 'EEXIST') return false;
115
+
116
+ let heldRaw = null;
117
+ let held = null;
118
+ try {
119
+ heldRaw = fs.readFileSync(LOCK_PATH, 'utf8');
120
+ held = JSON.parse(heldRaw);
121
+ } catch {
122
+ held = null; // unreadable/corrupt → stale
123
+ }
124
+ if (!lockIsStale(held)) return false;
125
+
126
+ // Could not READ the file we are about to judge — it vanished (the winner is
127
+ // mid-reclaim, between its rename and its re-create) or is unreadable. We
128
+ // have no identity to verify against, so reclaiming here would rename away
129
+ // the winner's brand-new lock unverified and elect a second leader. Measured:
130
+ // this window alone produced 2/12 double-leader rounds. Retry the create
131
+ // instead; if the winner has re-created by then we will see a fresh, live
132
+ // lock and correctly stand down.
133
+ //
134
+ // A corrupt-but-readable body (e.g. "not json") still reclaims normally —
135
+ // heldRaw is non-null there, so this does not wedge a genuinely bad lock.
136
+ if (heldRaw === null) continue;
137
+
138
+ // Claim the RIGHT to reclaim. Losing this rename means another session got
139
+ // there first and now owns the update, so we are a follower.
140
+ const claim = `${LOCK_PATH}.${process.pid}.${attempt}.reclaim`;
141
+ try {
142
+ fs.renameSync(LOCK_PATH, claim);
143
+ } catch {
144
+ return false;
145
+ }
146
+
147
+ // VERIFY WE TOOK THE FILE WE JUDGED. rename() moves whatever is at the path,
148
+ // not the bytes we inspected. Without this check a slow racer renames away
149
+ // the WINNER'S BRAND-NEW lock — it judged the old stale body, then moved the
150
+ // fresh one — and both processes become leader. Measured: 12/12 rounds
151
+ // elected two leaders before this check existed (scripts/probe-box-lock.mjs).
152
+ if (heldRaw !== null) {
153
+ let claimRaw = null;
154
+ try { claimRaw = fs.readFileSync(claim, 'utf8'); } catch { claimRaw = null; }
155
+ if (claimRaw !== heldRaw) {
156
+ // Not ours to take — DISCARD the claim and stand down.
157
+ //
158
+ // Do NOT rename it back. renameSync silently OVERWRITES an existing
159
+ // destination, and the bytes we hold are the STALE ones (that is why we
160
+ // judged them reclaimable). Restoring them clobbers the live leader's
161
+ // fresh lock with a stale body, so the next session judges it
162
+ // reclaimable and becomes a SECOND LEADER while the true leader is
163
+ // mid `npm install -g` — the exact race this module exists to prevent,
164
+ // arriving through the restore path. Measured, and invisible to the
165
+ // concurrency probe because it seeds a dead-PID lock, so claimRaw
166
+ // always equals heldRaw and this branch never runs there.
167
+ //
168
+ // Whoever owns the path already owns the box. Losing the stale bytes
169
+ // costs nothing; putting them back can only mislead.
170
+ try { fs.unlinkSync(claim); } catch { /* already gone */ }
171
+ return false;
172
+ }
173
+ }
174
+ try { fs.unlinkSync(claim); } catch { /* already gone */ }
175
+ }
176
+ }
177
+ return false;
178
+ }
179
+
180
+ function releaseBoxLock() {
181
+ if (!lockHeld) return;
182
+ lockHeld = false;
183
+ try { fs.unlinkSync(LOCK_PATH); } catch { /* best effort */ }
184
+ }
185
+
186
+ function holdsBoxLock() {
187
+ return lockHeld;
188
+ }
189
+
190
+ // Safety net. The hook's block() calls process.exit(1), which terminates
191
+ // IMMEDIATELY and does NOT unwind pending finally blocks — so the failure path (the
192
+ // one most likely to run) leaked the lock. A leaked lock usually self-heals via the
193
+ // dead-PID check, but Windows recycles PIDs aggressively: if a live process inherits
194
+ // the dead owner's PID inside the 5-minute window, the lock reads as live and every
195
+ // concurrent session waits then hard-blocks. 'exit' handlers DO run on
196
+ // process.exit(), so this covers exit paths we did not anticipate.
197
+ process.on('exit', releaseBoxLock);
198
+
199
+ module.exports = {
200
+ LOCK_PATH,
201
+ LOCK_STALE_MS,
202
+ acquireBoxLock,
203
+ releaseBoxLock,
204
+ holdsBoxLock,
205
+ lockIsStale,
206
+ processAlive,
207
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/rdc-skills",
3
- "version": "0.25.5",
3
+ "version": "0.25.10",
4
4
  "description": "RDC typed-agent dispatch skill suite for Claude Code - plan, build, review, overnight builds",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -165,18 +165,78 @@ function copyDir(src, dst, ext) {
165
165
  return count;
166
166
  }
167
167
 
168
+ /**
169
+ * Copy hook files, INCLUDING subdirectories.
170
+ *
171
+ * This was flat: readdirSync + a .js/.ps1 filter, skipping anything that was not a
172
+ * file. So `hooks/lib/` was never created at the destination, and a hook that did
173
+ * `require('./lib/box-lock')` died MODULE_NOT_FOUND — a SessionStart hook exiting
174
+ * non-zero with EMPTY stdout on every session, before any health check could run,
175
+ * meaning its own repair path could never recover it. The npm tarball shipped the
176
+ * file correctly; it was lost here, in the install step.
177
+ *
178
+ * `.mjs` is included because hooks/lib/run-evidence-gate.mjs has the same shape and
179
+ * was silently dropped by the old filter too.
180
+ */
168
181
  function copyHookFiles(src, dst) {
169
182
  if (!fs.existsSync(src)) { warn(`Source not found: ${src}`); return 0; }
170
183
  fs.mkdirSync(dst, { recursive: true });
171
- const files = fs.readdirSync(src).filter(f => /\.(?:js|ps1)$/i.test(f));
172
184
  let count = 0;
173
- for (const f of files) {
174
- const s = path.join(src, f);
175
- if (fs.statSync(s).isFile()) { fs.copyFileSync(s, path.join(dst, f)); count++; }
185
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
186
+ const s = path.join(src, entry.name);
187
+ const d = path.join(dst, entry.name);
188
+ if (entry.isDirectory()) {
189
+ count += copyHookFiles(s, d);
190
+ } else if (/\.(?:js|mjs|ps1)$/i.test(entry.name)) {
191
+ fs.copyFileSync(s, d);
192
+ count++;
193
+ }
176
194
  }
177
195
  return count;
178
196
  }
179
197
 
198
+ /**
199
+ * A SessionStart hook that cannot even LOAD must never ship.
200
+ *
201
+ * Verifies the installed tree by actually running the hook, because file-presence
202
+ * checks would not have caught this: the source was fine, the tarball was fine, and
203
+ * only the copied result was broken. A MODULE_NOT_FOUND here is fatal on every
204
+ * session, so it is worth one subprocess at install time.
205
+ */
206
+ function assertHooksLoadable(hooksDst, copiedCount) {
207
+ // A missing hook is FATAL, not a warning. Warn-and-return let the install
208
+ // "succeed" with NO SessionStart hook at all — the one genuinely vacuous pass in
209
+ // this guard, and the copy step is exactly where the last critical was lost.
210
+ if (!copiedCount) throw new Error(`no hook files were copied to ${hooksDst}`);
211
+ const entry = path.join(hooksDst, 'check-rdc-environment.js');
212
+ if (!fs.existsSync(entry)) throw new Error(`entry hook missing after copy: ${entry}`);
213
+
214
+ const res = require('child_process').spawnSync(process.execPath, ['--check', entry], {
215
+ encoding: 'utf8', timeout: 20000,
216
+ });
217
+ const combined = `${res.stdout || ''}${res.stderr || ''}`;
218
+ if (res.status !== 0) throw new Error(`installed hook fails to parse: ${combined.trim().slice(0, 300)}`);
219
+
220
+ // RESOLVE the dependency graph without EXECUTING it. `require(entry)` ran the
221
+ // WHOLE hook during install — a real `npm install -g` plus pm2 stop/restart, and
222
+ // on an unhealthy box a recursive install -> repair -> install loop.
223
+ // require.resolve answers the only question that matters here ("can it find
224
+ // ./lib/box-lock?") with no side effects.
225
+ const probe = [
226
+ 'const { createRequire } = require("node:module");',
227
+ `const r = createRequire(${JSON.stringify(entry.replace(/\\/g, '/'))});`,
228
+ 'for (const dep of ["./lib/box-lock", "./hook-logger"]) r.resolve(dep);',
229
+ 'process.stdout.write("RESOLVED");',
230
+ ].join('\n');
231
+ const load = require('child_process').spawnSync(process.execPath, ['-e', probe], {
232
+ encoding: 'utf8', input: '', timeout: 20000,
233
+ });
234
+ const loadOut = `${load.stdout || ''}${load.stderr || ''}`;
235
+ if (!/RESOLVED/.test(loadOut)) {
236
+ throw new Error(`installed hook cannot resolve its imports: ${loadOut.trim().slice(0, 300)}`);
237
+ }
238
+ }
239
+
180
240
  function copyDirRecursive(src, dst) {
181
241
  if (!fs.existsSync(src)) return;
182
242
  fs.mkdirSync(dst, { recursive: true });
@@ -896,14 +956,31 @@ function buildZip(version) {
896
956
  }
897
957
 
898
958
  // ── Hook config ───────────────────────────────────────────────────────────────
959
+
960
+ /**
961
+ * SessionStart budget for check-rdc-environment.js, DERIVED from the per-step
962
+ * timeouts in hooks/check-rdc-environment.js repair() rather than restated, so the
963
+ * two cannot drift apart.
964
+ *
965
+ * At the harness default (60s) a genuine cold repair was killed mid-install — the
966
+ * leader's own steps alone exceed it — and every follower read that as a crash.
967
+ * The +60s headroom matters: a budget equal to the exact sum kills a leader that
968
+ * legitimately uses its full per-step allowance right at the boundary.
969
+ */
970
+ const REPAIR_NPM_SEC = 120; // npm install -g
971
+ const REPAIR_INSTALL_SEC = 180; // rdc-skills-install
972
+ const REPAIR_PM2_SEC = 60; // pm2 stop + restart
973
+ const RDC_ENV_HOOK_TIMEOUT_SEC = REPAIR_NPM_SEC + REPAIR_INSTALL_SEC + REPAIR_PM2_SEC + 60;
974
+
899
975
  function buildHooksConfig(hooksDir, profile = 'core') {
900
976
  const base = hooksDir.replace(/\\/g, '/');
901
- const cmd = (file, msg) => {
977
+ const cmd = (file, msg, timeoutSec) => {
902
978
  const command = process.platform === 'win32'
903
979
  ? `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "${base}/run-hidden-hook.ps1" "${base}/${file}"`
904
980
  : `node "${base}/${file}"`;
905
981
  const entry = { type: 'command', command };
906
982
  if (msg) entry.statusMessage = msg;
983
+ if (timeoutSec) entry.timeout = timeoutSec;
907
984
  return entry;
908
985
  };
909
986
  const config = {
@@ -937,7 +1014,7 @@ function buildHooksConfig(hooksDir, profile = 'core') {
937
1014
 
938
1015
  if (profile === 'lifeai') {
939
1016
  config.SessionStart = [{ hooks: [
940
- cmd('check-rdc-environment.js', 'Checking RDC skills runtime...'),
1017
+ cmd('check-rdc-environment.js', 'Checking RDC skills runtime...', RDC_ENV_HOOK_TIMEOUT_SEC),
941
1018
  cmd('check-cwd.js'),
942
1019
  cmd('check-stale-work-items.js', 'Checking for stale work items...'),
943
1020
  // Truth Gate 3.0 Layer 6 — gate watchdog (ADVISORY; SessionStart cannot block).
@@ -1280,7 +1357,11 @@ async function main() {
1280
1357
 
1281
1358
  // 3. Hook files
1282
1359
  const hookCount = copyHookFiles(hooksSrc, hooksDst);
1283
- ok(`[3/6] Hook files ${hookCount} file(s) ${hooksDst}`);
1360
+ // Fail the INSTALL rather than every future session: a hook that cannot load
1361
+ // exits non-zero with empty stdout on SessionStart, before its own repair path
1362
+ // can run.
1363
+ assertHooksLoadable(hooksDst, hookCount);
1364
+ ok(`[3/6] Hook files — ${hookCount} file(s) → ${hooksDst} (load-verified)`);
1284
1365
 
1285
1366
  // 4. Hook wiring
1286
1367
  if (skipHooks) {
@@ -1398,4 +1479,12 @@ async function main() {
1398
1479
  console.log('');
1399
1480
  }
1400
1481
 
1401
- main().catch(e => { fail(e.message); process.exit(1); });
1482
+ // Run ONLY when invoked directly. Exporting the copy helpers lets
1483
+ // scripts/probe-installed-hooks.mjs verify the real install path instead of
1484
+ // re-implementing it — a probe that copies its own way proves nothing about what
1485
+ // ships. Without this guard, requiring the module would run a full install.
1486
+ module.exports = { copyHookFiles, assertHooksLoadable, RDC_ENV_HOOK_TIMEOUT_SEC };
1487
+
1488
+ if (require.main === module) {
1489
+ main().catch(e => { fail(e.message); process.exit(1); });
1490
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # Install rdc-skills globally FROM LOCAL SOURCE, using the stop-first sequence.
3
+ #
4
+ # This is the empirical test of the fix: the same `npm install -g` that produced
5
+ # EBUSY should now succeed because the lock holder is stopped first. It is a LOCAL
6
+ # install (a path, not the registry) — nothing is published.
7
+ #
8
+ # The restart is unconditional: leaving the MCP down after a failed install would
9
+ # be worse than the failure being repaired.
10
+ set -u
11
+
12
+ HOLDER=rdc-skills-mcp
13
+
14
+ echo "== before =="
15
+ pm2 jlist | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const p=JSON.parse(s).find(x=>x.name===process.argv[1]);console.log(' '+process.argv[1]+': '+(p?p.pm2_env.status:'absent'));})" "$HOLDER"
16
+ npm ls -g '@lifeaitools/rdc-skills' --depth=0 2>/dev/null | tail -1
17
+
18
+ echo "== stop the lock holder =="
19
+ pm2 stop "$HOLDER" >/dev/null 2>&1 && echo " stopped $HOLDER" || echo " could not stop (may be absent)"
20
+
21
+ echo "== npm install -g from local source =="
22
+ set +e
23
+ npm install -g "C:/Dev/rdc-skills" 2>&1 | tail -5
24
+ INSTALL_RC=${PIPESTATUS[0]}
25
+ set -e
26
+ echo " install exit=$INSTALL_RC"
27
+
28
+ echo "== restart (always) =="
29
+ pm2 restart "$HOLDER" >/dev/null 2>&1 && echo " restarted $HOLDER" || echo " restart FAILED"
30
+
31
+ echo "== after =="
32
+ npm ls -g '@lifeaitools/rdc-skills' --depth=0 2>/dev/null | tail -1
33
+ for i in 1 2 3 4 5 6 7 8; do
34
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:3110/health 2>/dev/null)
35
+ [ "$code" = "200" ] && break
36
+ sleep 1
37
+ done
38
+ echo " health 3110 -> HTTP ${code:-none}"
39
+ curl -s --max-time 3 http://127.0.0.1:3110/health 2>/dev/null | head -c 200
40
+ echo
41
+ exit "$INSTALL_RC"
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Prove the box-wide update is SINGLE-FLIGHT under concurrent session starts.
4
+ *
5
+ * The bug this guards: rdc-skills is one box-wide npm package and one PM2 process,
6
+ * but every session/worktree runs the SessionStart hook. Without a lock, N sessions
7
+ * each conclude "unhealthy" and each run `npm install -g` on the same directory.
8
+ *
9
+ * Imports the REAL implementation from hooks/lib/box-lock.js. An earlier version
10
+ * kept its own copy of these functions, which drifted to the old algorithm — so it
11
+ * reported PASS for code that was not shipping. A test that duplicates its subject
12
+ * tests nothing.
13
+ */
14
+ import { createRequire } from 'node:module';
15
+ import { spawn } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const HOOK_LIB = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hooks', 'lib', 'box-lock.js');
23
+ const { acquireBoxLock, releaseBoxLock, LOCK_PATH, LOCK_STALE_MS } = require(HOOK_LIB);
24
+
25
+ const release = () => { try { fs.unlinkSync(LOCK_PATH); } catch { /* ignore */ } };
26
+
27
+ release(); // clean slate
28
+ let fail = 0;
29
+ const check = (name, ok) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${name}`); if (!ok) fail++; };
30
+
31
+ // 1. Happy path. SEQUENTIAL and in-process, so it proves only that a held lock is
32
+ // not handed out twice — it cannot exercise interleaving. Test 7 does that.
33
+ const winners = Array.from({ length: 10 }, () => acquireBoxLock()).filter(Boolean).length;
34
+ check(`sequential starts: exactly 1 updater of 10 (got ${winners})`, winners === 1);
35
+
36
+ // 2. A live holder is respected — a follower must NOT steal the lock.
37
+ check('live lock respected', acquireBoxLock() === false);
38
+
39
+ // 3. Release hands the box to the next session.
40
+ releaseBoxLock();
41
+ check('after release, next session leads', acquireBoxLock() === true);
42
+ releaseBoxLock();
43
+
44
+ // 4. A dead owner must not wedge every future session forever.
45
+ fs.writeFileSync(LOCK_PATH, JSON.stringify({ pid: 999999, ts: new Date().toISOString() }));
46
+ check('dead owner reclaimed', acquireBoxLock() === true);
47
+ releaseBoxLock();
48
+
49
+ // 5. A stale-but-live lock (crashed mid-install, PID reused) is reclaimed by age.
50
+ fs.writeFileSync(LOCK_PATH, JSON.stringify({
51
+ pid: process.pid, ts: new Date(Date.now() - LOCK_STALE_MS - 1000).toISOString(),
52
+ }));
53
+ check('stale lock reclaimed by age', acquireBoxLock() === true);
54
+ releaseBoxLock();
55
+
56
+ // 6. Corrupt bodies must not wedge the box. `pid: 0` is the sharp one:
57
+ // process.kill(0, 0) signals the CURRENT PROCESS GROUP and succeeds, so a naive
58
+ // liveness check reads it as alive forever; paired with an unparseable ts, the
59
+ // age check is NaN and never trips either.
60
+ for (const body of ['not json', '{"pid":0,"ts":"garbage"}', '{"pid":-1,"ts":null}', '{}']) {
61
+ fs.writeFileSync(LOCK_PATH, body);
62
+ check(`corrupt lock reclaimed: ${body.slice(0, 26)}`, acquireBoxLock() === true);
63
+ releaseBoxLock();
64
+ }
65
+
66
+ // 7. REAL concurrency against a STALE lock — the reclaim TOCTOU.
67
+ //
68
+ // The naive reclaim (read -> judge stale -> unlink -> open 'wx') lets two racers
69
+ // both judge the lock stale, and the loser's unlink deletes the WINNER's fresh
70
+ // lock, so both become leader and both run `npm install -g`. Sequential calls in
71
+ // one process can never surface this; only real processes racing can.
72
+ {
73
+ // A leader HOLDS the lock briefly before exiting. Without the hold, a winner
74
+ // exits (releasing on 'exit') before the next racer even calls acquire, so every
75
+ // racer wins in turn and the result looks like a race that never happened.
76
+ const racer = `
77
+ const { acquireBoxLock } = require(${JSON.stringify(HOOK_LIB)});
78
+ const go = Number(process.argv[2]);
79
+ while (Date.now() < go) { /* spin to a shared start instant */ }
80
+ const won = acquireBoxLock();
81
+ process.stdout.write(won ? 'LEADER' : 'follower');
82
+ if (won) { const until = Date.now() + 600; while (Date.now() < until) {} }
83
+ `;
84
+
85
+ // spawn(), NOT spawnSync(): spawnSync BLOCKS until each child exits, so the
86
+ // "racers" ran strictly one after another and never overlapped. That flaw made
87
+ // this test report 12/12 double-leaders against correct code.
88
+ const runRound = () => new Promise((resolve) => {
89
+ fs.writeFileSync(LOCK_PATH, JSON.stringify({ pid: 999999, ts: new Date().toISOString() }));
90
+ const go = Date.now() + 400;
91
+ const out = [];
92
+ let done = 0;
93
+ for (let i = 0; i < 4; i++) {
94
+ const kid = spawn(process.execPath, ['-e', racer, String(go)], { stdio: ['ignore', 'pipe', 'ignore'] });
95
+ let buf = '';
96
+ kid.stdout.on('data', (d) => { buf += d; });
97
+ kid.on('close', () => {
98
+ out.push(buf);
99
+ if (++done === 4) resolve(out.filter((o) => o.includes('LEADER')).length);
100
+ });
101
+ }
102
+ });
103
+
104
+ const ROUNDS = 12;
105
+ let doubleLeader = 0;
106
+ let noLeader = 0;
107
+ for (let r = 0; r < ROUNDS; r++) {
108
+ const leaders = await runRound();
109
+ if (leaders > 1) doubleLeader++;
110
+ if (leaders === 0) noLeader++;
111
+ release();
112
+ }
113
+ check(`concurrent reclaim: no round elected 2 leaders (${doubleLeader}/${ROUNDS} bad)`, doubleLeader === 0);
114
+ // Every racer bailing would be "safe" but useless — nobody would repair the box.
115
+ check(`concurrent reclaim: a leader was elected each round (${noLeader}/${ROUNDS} empty)`, noLeader === 0);
116
+ }
117
+ release();
118
+
119
+ // 8. NEGATIVE CONTROL — does test 7 actually have teeth?
120
+ //
121
+ // This suite has already produced two false verdicts: a copy of the implementation
122
+ // that drifted (reported PASS for code that was not shipping), and a spawnSync
123
+ // harness whose "racers" never overlapped (reported FAIL for code that was fine).
124
+ // A concurrency test that cannot fail is worse than no test, so run the SAME
125
+ // harness against a deliberately naive lock and require that it catches it.
126
+ {
127
+ const naivePath = path.join(os.tmpdir(), `naive-box-lock-${process.pid}.js`);
128
+ fs.writeFileSync(naivePath, `
129
+ const fs = require('node:fs');
130
+ const LOCK_PATH = ${JSON.stringify(`${LOCK_PATH}.naive`)};
131
+ // The original algorithm: read -> judge stale -> unlink -> open('wx').
132
+ // No rename, no identity check. This is what shipped before the fix.
133
+ function acquireBoxLock() {
134
+ for (let a = 0; a < 2; a++) {
135
+ try { const fd = fs.openSync(LOCK_PATH, 'wx');
136
+ fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: new Date().toISOString() }));
137
+ fs.closeSync(fd); return true;
138
+ } catch (err) {
139
+ if (err.code !== 'EEXIST') return false;
140
+ try { fs.unlinkSync(LOCK_PATH); } catch { return false; }
141
+ }
142
+ }
143
+ return false;
144
+ }
145
+ module.exports = { acquireBoxLock, LOCK_PATH };
146
+ `);
147
+ const naiveLock = `${LOCK_PATH}.naive`;
148
+ const naiveRacer = `
149
+ const { acquireBoxLock } = require(${JSON.stringify(naivePath)});
150
+ const go = Number(process.argv[2]);
151
+ while (Date.now() < go) {}
152
+ const won = acquireBoxLock();
153
+ process.stdout.write(won ? 'LEADER' : 'follower');
154
+ if (won) { const until = Date.now() + 600; while (Date.now() < until) {} }
155
+ `;
156
+ const naiveRound = () => new Promise((resolve) => {
157
+ fs.writeFileSync(naiveLock, JSON.stringify({ pid: 999999, ts: new Date().toISOString() }));
158
+ const go = Date.now() + 400;
159
+ const out = []; let done = 0;
160
+ for (let i = 0; i < 4; i++) {
161
+ const kid = spawn(process.execPath, ['-e', naiveRacer, String(go)], { stdio: ['ignore', 'pipe', 'ignore'] });
162
+ let buf = '';
163
+ kid.stdout.on('data', (d) => { buf += d; });
164
+ kid.on('close', () => { out.push(buf); if (++done === 4) resolve(out.filter((o) => o.includes('LEADER')).length); });
165
+ }
166
+ });
167
+ let caught = 0;
168
+ const ROUNDS = 12;
169
+ for (let r = 0; r < ROUNDS; r++) {
170
+ if (await naiveRound() > 1) caught++;
171
+ try { fs.unlinkSync(naiveLock); } catch { /* ignore */ }
172
+ }
173
+ check(`negative control: harness DETECTS the naive lock (${caught}/${ROUNDS} rounds caught)`, caught > 0);
174
+ try { fs.unlinkSync(naivePath); } catch { /* ignore */ }
175
+ try { fs.unlinkSync(naiveLock); } catch { /* ignore */ }
176
+ }
177
+
178
+ console.log(fail ? `\n${fail} FAILURE(S)` : '\nall box-lock invariants hold');
179
+ process.exit(fail ? 1 : 0);
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Prove the INSTALLED hook tree can actually load.
4
+ *
5
+ * The source was fine, the npm tarball was fine, and only the copied result was
6
+ * broken: copyHookFiles() was flat, so hooks/lib/ was never created at the
7
+ * destination and require('./lib/box-lock') died MODULE_NOT_FOUND — a SessionStart
8
+ * hook exiting non-zero with EMPTY stdout on every session, before any health check
9
+ * could run, so its own repair path could never recover it.
10
+ *
11
+ * File-presence checks in the repo would not have caught that. This replays the
12
+ * real copy into a scratch dir and LOADS the result.
13
+ */
14
+ import { createRequire } from 'node:module';
15
+ import { spawnSync } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const { copyHookFiles } = require(path.join(ROOT, 'scripts', 'install-rdc-skills.js'));
24
+
25
+ let fail = 0;
26
+ const check = (name, ok, detail = '') => {
27
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
28
+ if (!ok) fail++;
29
+ };
30
+
31
+ const dst = fs.mkdtempSync(path.join(os.tmpdir(), 'rdc-hooks-install-'));
32
+ const count = copyHookFiles(path.join(ROOT, 'hooks'), dst);
33
+ check(`copied hook files (${count})`, count > 0);
34
+
35
+ // The exact loss: a nested dir the flat copy skipped.
36
+ check('hooks/lib/ exists at destination', fs.existsSync(path.join(dst, 'lib')));
37
+ check('hooks/lib/box-lock.js copied', fs.existsSync(path.join(dst, 'lib', 'box-lock.js')));
38
+ check('hooks/lib/run-evidence-gate.mjs copied (.mjs was filtered out too)',
39
+ fs.existsSync(path.join(dst, 'lib', 'run-evidence-gate.mjs')));
40
+
41
+ // Load every top-level hook for real. --check only parses; it does not resolve
42
+ // require(), which is precisely what broke.
43
+ const hooks = fs.readdirSync(dst).filter((f) => f.endsWith('.js'));
44
+ const unresolved = [];
45
+ for (const h of hooks) {
46
+ const entry = path.join(dst, h).replace(/\\/g, '/');
47
+ const res = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(entry)})`], {
48
+ encoding: 'utf8', input: '', timeout: 20000,
49
+ });
50
+ const out = `${res.stdout || ''}${res.stderr || ''}`;
51
+ if (/MODULE_NOT_FOUND|Cannot find module/.test(out)) {
52
+ unresolved.push(`${h}: ${(out.match(/Cannot find module '[^']+'/) || [''])[0]}`);
53
+ }
54
+ }
55
+ check(`all ${hooks.length} installed hooks resolve their imports`, unresolved.length === 0,
56
+ unresolved.slice(0, 3).join(' | '));
57
+
58
+ fs.rmSync(dst, { recursive: true, force: true });
59
+ console.log(fail ? `\n${fail} FAILURE(S)` : '\ninstalled hook tree loads cleanly');
60
+ process.exit(fail ? 1 : 0);