@aron98/load-lock 1.5.0 → 1.6.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 (66) hide show
  1. package/README.md +61 -14
  2. package/dist/cli/copies.d.ts +3 -2
  3. package/dist/cli/copies.js +32 -3
  4. package/dist/cli/hook.js +48 -4
  5. package/dist/cli/install.d.ts +2 -0
  6. package/dist/cli/install.js +67 -13
  7. package/dist/cli/main.js +62 -5
  8. package/dist/cli/setup.d.ts +131 -0
  9. package/dist/cli/setup.js +942 -0
  10. package/dist/cli/update.d.ts +5 -1
  11. package/dist/cli/update.js +16 -3
  12. package/dist/core/daemon.d.ts +29 -3
  13. package/dist/core/daemon.js +111 -11
  14. package/dist/core/persist.d.ts +3 -0
  15. package/dist/core/persist.js +49 -10
  16. package/dist/core/state.d.ts +9 -0
  17. package/dist/core/state.js +41 -3
  18. package/dist/mcp/server.d.ts +4 -3
  19. package/dist/mcp/server.js +29 -6
  20. package/dist/policy/text.d.ts +13 -3
  21. package/dist/policy/text.js +14 -3
  22. package/dist/shared/agent-guide.d.ts +3 -0
  23. package/dist/shared/agent-guide.js +16 -0
  24. package/dist/shared/client.js +5 -3
  25. package/dist/shared/drift.d.ts +4 -2
  26. package/dist/shared/drift.js +24 -6
  27. package/dist/shared/identity.d.ts +51 -0
  28. package/dist/shared/identity.js +116 -0
  29. package/dist/shared/pkginfo.d.ts +9 -0
  30. package/dist/shared/pkginfo.js +16 -0
  31. package/dist/shared/protocol.d.ts +22 -0
  32. package/dist/shared/protocol.js +15 -0
  33. package/dist/shared/runtimes.d.ts +41 -0
  34. package/dist/shared/runtimes.js +81 -0
  35. package/dist/shared/version.d.ts +6 -0
  36. package/dist/shared/version.js +9 -1
  37. package/dist/test/ace-mcp.test.d.ts +1 -0
  38. package/dist/test/ace-mcp.test.js +157 -0
  39. package/dist/test/ace-opencode.test.d.ts +1 -0
  40. package/dist/test/ace-opencode.test.js +287 -0
  41. package/dist/test/check-pins.test.d.ts +1 -0
  42. package/dist/test/check-pins.test.js +108 -0
  43. package/dist/test/compat.test.d.ts +1 -0
  44. package/dist/test/compat.test.js +190 -0
  45. package/dist/test/hook.test.js +179 -1
  46. package/dist/test/identity.test.d.ts +1 -0
  47. package/dist/test/identity.test.js +155 -0
  48. package/dist/test/install.test.js +135 -1
  49. package/dist/test/launcher.test.d.ts +1 -0
  50. package/dist/test/launcher.test.js +222 -0
  51. package/dist/test/mcp.test.js +76 -7
  52. package/dist/test/plugin.test.d.ts +1 -0
  53. package/dist/test/plugin.test.js +144 -0
  54. package/dist/test/runtimes.test.d.ts +1 -0
  55. package/dist/test/runtimes.test.js +282 -0
  56. package/dist/test/setup.test.d.ts +1 -0
  57. package/dist/test/setup.test.js +354 -0
  58. package/dist/test/update-command.test.js +71 -0
  59. package/dist/test/update.test.js +33 -1
  60. package/docs/ADAPTERS.md +3 -2
  61. package/docs/AGENT_GUIDE.md +36 -2
  62. package/docs/ARCHITECTURE.md +122 -7
  63. package/docs/COMPATIBILITY.md +142 -13
  64. package/docs/OPERATIONS.md +292 -14
  65. package/package.json +3 -2
  66. package/skills/load-lock/SKILL.md +62 -0
@@ -0,0 +1,354 @@
1
+ /**
2
+ * `load-lock setup` inventory (issue #58), run against a temp HOME / project /
3
+ * state dir so real user config is never read or damaged. Fixtures are
4
+ * planted with the real installers (`load-lock install --harness ...`) plus
5
+ * hand-written plugin / ACE / foreign entries.
6
+ */
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { mkdtempSync, rmSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join, dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { spawn } from "node:child_process";
14
+ const HERE = dirname(fileURLToPath(import.meta.url));
15
+ const cliMain = join(HERE, "..", "cli", "main.js");
16
+ const repoRoot = resolve(HERE, "..", "..");
17
+ const TMP_BASE = process.env.LL_TEST_TMP ?? tmpdir();
18
+ mkdirSync(TMP_BASE, { recursive: true });
19
+ function sandbox() {
20
+ const root = mkdtempSync(join(TMP_BASE, "ll-setup-"));
21
+ const home = join(root, "home");
22
+ const project = join(root, "proj");
23
+ mkdirSync(home, { recursive: true });
24
+ mkdirSync(project, { recursive: true });
25
+ const env = {};
26
+ for (const [k, v] of Object.entries(process.env)) {
27
+ if (v !== undefined && !["XDG_CONFIG_HOME", "CODEX_HOME", "CLINE_MCP_SETTINGS_PATH", "LOAD_LOCK_SOCKET"].includes(k))
28
+ env[k] = v;
29
+ }
30
+ Object.assign(env, {
31
+ HOME: home,
32
+ LOAD_LOCK_STATE_DIR: join(root, "state"),
33
+ XDG_CACHE_HOME: join(root, "cache"),
34
+ npm_config_cache: join(root, "npm-cache"),
35
+ });
36
+ return { root, home, project, env };
37
+ }
38
+ function runCli(sb, args) {
39
+ return new Promise((res, rej) => {
40
+ const child = spawn(process.execPath, [cliMain, ...args], { env: sb.env, cwd: sb.project });
41
+ let stdout = "";
42
+ let stderr = "";
43
+ child.stdout.on("data", (b) => (stdout += b));
44
+ child.stderr.on("data", (b) => (stderr += b));
45
+ child.stdin.end();
46
+ child.once("exit", (code) => res({ code, stdout, stderr }));
47
+ child.once("error", rej);
48
+ });
49
+ }
50
+ /** `setup --json` output: { inventory, plan }. */
51
+ async function setupOut(sb) {
52
+ const r = await runCli(sb, ["setup", "--json", "--project", sb.project]);
53
+ assert.equal(r.code, 0, `setup failed: ${r.stderr}`);
54
+ return JSON.parse(r.stdout);
55
+ }
56
+ async function setupJson(sb) {
57
+ return (await setupOut(sb)).inventory;
58
+ }
59
+ async function install(sb, harness, ...extra) {
60
+ const r = await runCli(sb, ["install", "--harness", harness, "--project", sb.project, ...extra]);
61
+ assert.equal(r.code, 0, `install ${harness} failed: ${r.stderr}`);
62
+ }
63
+ /** Every file under dir with its bytes (to prove setup never writes). */
64
+ function snapshot(dir, out = {}) {
65
+ for (const n of readdirSync(dir)) {
66
+ const p = join(dir, n);
67
+ if (statSync(p).isDirectory())
68
+ snapshot(p, out);
69
+ else
70
+ out[p] = readFileSync(p, "utf8");
71
+ }
72
+ return out;
73
+ }
74
+ function harness(inv, name) {
75
+ const h = inv.harnesses.find((x) => x.harness === name);
76
+ assert.ok(h, `harness ${name} missing from inventory`);
77
+ return h;
78
+ }
79
+ function writeJsonFile(file, data) {
80
+ mkdirSync(dirname(file), { recursive: true });
81
+ writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
82
+ }
83
+ const ALL = ["claude-code", "opencode", "codex", "cursor", "gemini-cli", "copilot-cli", "cline", "roo-code", "goose", "omac"];
84
+ test("setup: bare HOME lists every supported harness as not installed and writes nothing", async () => {
85
+ const sb = sandbox();
86
+ const before = snapshot(sb.root);
87
+ const inv = await setupJson(sb);
88
+ assert.deepEqual(inv.harnesses.map((h) => h.harness), ALL);
89
+ for (const h of inv.harnesses) {
90
+ assert.equal(h.rung, "none", `${h.harness} rung`);
91
+ assert.deepEqual(h.integrations, []);
92
+ assert.deepEqual(h.findings, []);
93
+ }
94
+ assert.ok(inv.runtime.copies.some((c) => c.source === "cli" && c.path === repoRoot), "lists the running copy");
95
+ const human = await runCli(sb, ["setup", "--project", sb.project]);
96
+ assert.equal(human.code, 0);
97
+ assert.match(human.stdout, /inventory \(read-only\)/);
98
+ assert.match(human.stdout, /goose\s+not installed/);
99
+ assert.deepEqual(snapshot(sb.root), before, "setup must not write anything");
100
+ rmSync(sb.root, { recursive: true, force: true });
101
+ });
102
+ test("setup: each installer's wiring is inventoried with its rung, owner and copy; setup stays read-only", async () => {
103
+ const sb = sandbox();
104
+ for (const h of ALL)
105
+ await install(sb, h);
106
+ const before = snapshot(sb.root);
107
+ const inv = await setupJson(sb);
108
+ const expect = {
109
+ "claude-code": { rung: "loose", kinds: ["hooks", "mcp", "policy", "skill"] },
110
+ opencode: { rung: "plugin", kinds: ["plugin"] },
111
+ codex: { rung: "loose", kinds: ["hooks", "mcp", "policy"] },
112
+ cursor: { rung: "loose", kinds: ["hooks", "mcp", "policy"] },
113
+ "gemini-cli": { rung: "loose", kinds: ["mcp", "policy"] },
114
+ "copilot-cli": { rung: "loose", kinds: ["mcp", "policy"] },
115
+ cline: { rung: "loose", kinds: ["mcp", "policy"] },
116
+ "roo-code": { rung: "loose", kinds: ["mcp", "policy"] },
117
+ goose: { rung: "loose", kinds: ["mcp", "policy"] },
118
+ omac: { rung: "loose", kinds: ["grants"] },
119
+ };
120
+ for (const [name, e] of Object.entries(expect)) {
121
+ const h = harness(inv, name);
122
+ assert.equal(h.rung, e.rung, `${name} rung`);
123
+ assert.deepEqual([...new Set(h.integrations.map((i) => i.kind))].sort(), e.kinds, `${name} kinds`);
124
+ for (const i of h.integrations) {
125
+ assert.equal(i.owner, "installer", `${name} ${i.kind} owner`);
126
+ if (i.copy !== undefined) {
127
+ assert.equal(i.copy, repoRoot, `${name} ${i.kind} points at this copy`);
128
+ assert.equal(i.stale, false);
129
+ }
130
+ }
131
+ assert.deepEqual(h.findings, [], `${name}: a clean install has no findings`);
132
+ }
133
+ const cc = harness(inv, "claude-code");
134
+ assert.deepEqual(cc.integrations.find((i) => i.kind === "hooks").detail.split(", ").sort(), ["SessionStart:await-offer", "SessionStart:session-start", "Stop:stop"]);
135
+ assert.deepEqual(snapshot(sb.root), before, "setup must not write anything");
136
+ rmSync(sb.root, { recursive: true, force: true });
137
+ });
138
+ test("setup: flags loose + plugin double install and MCP registered more than once (Claude Code)", async () => {
139
+ const sb = sandbox();
140
+ await install(sb, "claude-code");
141
+ const settingsFile = join(sb.home, ".claude", "settings.json");
142
+ const s = JSON.parse(readFileSync(settingsFile, "utf8"));
143
+ s.enabledPlugins = { "load-lock-plugin@load-lock": true, "other@market": true };
144
+ writeJsonFile(settingsFile, s);
145
+ // an ACE-style project MCP patch (`servers` key, pinned npx)
146
+ writeJsonFile(join(sb.project, ".mcp.json"), {
147
+ servers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } },
148
+ });
149
+ const cc = harness(await setupJson(sb), "claude-code");
150
+ assert.equal(cc.rung, "plugin");
151
+ const plugin = cc.integrations.find((i) => i.kind === "plugin");
152
+ assert.equal(plugin.detail, 'enabledPlugins["load-lock-plugin@load-lock"]');
153
+ assert.equal(plugin.owner, "harness");
154
+ assert.equal(plugin.enabled, true);
155
+ const ace = cc.integrations.find((i) => i.kind === "mcp" && i.scope === "project");
156
+ assert.equal(ace.owner, "other");
157
+ assert.equal(ace.detail, "servers.load-lock");
158
+ assert.ok(cc.findings.some((f) => f.startsWith("loose + plugin double install")), cc.findings.join("\n"));
159
+ assert.ok(cc.findings.some((f) => f.startsWith("MCP registered 3 times")), cc.findings.join("\n"));
160
+ // a disabled plugin does not count as the active rung
161
+ s.enabledPlugins["load-lock-plugin@load-lock"] = false;
162
+ writeJsonFile(settingsFile, s);
163
+ const cc2 = harness(await setupJson(sb), "claude-code");
164
+ assert.equal(cc2.rung, "loose");
165
+ assert.ok(cc2.findings.some((f) => /installed but disabled/.test(f)));
166
+ rmSync(sb.root, { recursive: true, force: true });
167
+ });
168
+ test("setup: hooks from a second (missing) copy are flagged as stale duplicates", async () => {
169
+ const sb = sandbox();
170
+ await install(sb, "codex");
171
+ const hooksFile = join(sb.home, ".codex", "hooks.json");
172
+ const h = JSON.parse(readFileSync(hooksFile, "utf8"));
173
+ const gone = join(sb.root, "gone", "node_modules", "@aron98", "load-lock", "dist", "cli", "main.js");
174
+ h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(gone)} hook codex stop` }] });
175
+ writeJsonFile(hooksFile, h);
176
+ const cx = harness(await setupJson(sb), "codex");
177
+ const hooks = cx.integrations.filter((i) => i.kind === "hooks");
178
+ assert.equal(hooks.length, 2, "one hooks line per copy");
179
+ const staleOne = hooks.find((i) => i.stale);
180
+ assert.equal(staleOne.copy, resolve(gone, "..", "..", ".."));
181
+ assert.equal(staleOne.owner, "installer");
182
+ assert.ok(cx.findings.some((f) => f.startsWith("hooks from 2 different sources")), cx.findings.join("\n"));
183
+ assert.ok(cx.findings.some((f) => f.startsWith("stale hooks entry")), cx.findings.join("\n"));
184
+ rmSync(sb.root, { recursive: true, force: true });
185
+ });
186
+ test("setup: OpenCode plugin listed twice plus an MCP entry; ACE skill and skills-dir plugin are attributed", async () => {
187
+ const sb = sandbox();
188
+ await install(sb, "opencode");
189
+ const cfgFile = join(sb.home, ".config", "opencode", "opencode.json");
190
+ const cfg = JSON.parse(readFileSync(cfgFile, "utf8"));
191
+ cfg.plugin = ["@aron98/load-lock@1.5.0"];
192
+ cfg.mcp = { "load-lock": { type: "local", command: ["npx", "-y", "@aron98/load-lock@1.5.0", "mcp"] }, other: { type: "local", command: ["x"] } };
193
+ writeJsonFile(cfgFile, cfg);
194
+ // ACE-installed skill and Claude plugin (each with an .ace.json stamp)
195
+ const skills = join(sb.home, ".claude", "skills");
196
+ mkdirSync(join(skills, "load-lock"), { recursive: true });
197
+ writeFileSync(join(skills, "load-lock", "SKILL.md"), "---\nname: load-lock\ndescription: x\n---\n# load-lock\n");
198
+ writeFileSync(join(skills, "load-lock", ".ace.json"), "{}\n");
199
+ writeJsonFile(join(skills, "load-lock-plugin", ".claude-plugin", "plugin.json"), { name: "load-lock-plugin" });
200
+ writeFileSync(join(skills, "load-lock-plugin", ".ace.json"), "{}\n");
201
+ const inv = await setupJson(sb);
202
+ const oc = harness(inv, "opencode");
203
+ assert.equal(oc.rung, "plugin");
204
+ assert.equal(oc.integrations.filter((i) => i.kind === "plugin").length, 2);
205
+ assert.equal(oc.integrations.filter((i) => i.kind === "mcp").length, 1, "only the load-lock MCP entry");
206
+ assert.ok(oc.findings.some((f) => f.startsWith("plugin registered 2 times")), oc.findings.join("\n"));
207
+ assert.ok(oc.findings.some((f) => f.startsWith("MCP registered 2 times")), oc.findings.join("\n"));
208
+ const cc = harness(inv, "claude-code");
209
+ assert.equal(cc.rung, "skill", "an ACE skills-dir plugin is not counted as an active plugin (unverified, #63)");
210
+ assert.equal(cc.integrations.find((i) => i.kind === "skill").owner, "ace");
211
+ assert.equal(cc.integrations.find((i) => i.kind === "plugin").owner, "ace");
212
+ assert.ok(cc.findings.some((f) => /ACE skills-dir plugin/.test(f)));
213
+ rmSync(sb.root, { recursive: true, force: true });
214
+ });
215
+ // ------------------------------------------------------------ converge (#59)
216
+ async function setupYes(sb) {
217
+ const r = await runCli(sb, ["setup", "--yes", "--json", "--project", sb.project]);
218
+ assert.equal(r.code, 0, `setup --yes failed: ${r.stderr}`);
219
+ return JSON.parse(r.stdout);
220
+ }
221
+ async function uninstall(sb, h) {
222
+ const r = await runCli(sb, ["uninstall", "--harness", h, "--project", sb.project]);
223
+ assert.equal(r.code, 0, `uninstall ${h} failed: ${r.stderr}`);
224
+ }
225
+ const steps = (out, h) => out.plan.steps.filter((s) => s.harness === h).map((s) => s.action);
226
+ test("setup converge: dry run is the default and writes nothing", async () => {
227
+ const sb = sandbox();
228
+ await install(sb, "codex");
229
+ const hooksFile = join(sb.home, ".codex", "hooks.json");
230
+ const h = JSON.parse(readFileSync(hooksFile, "utf8"));
231
+ h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(join(sb.root, "gone", "dist", "cli", "main.js"))} hook codex stop` }] });
232
+ writeJsonFile(hooksFile, h);
233
+ const before = snapshot(sb.root);
234
+ const r = await runCli(sb, ["setup", "--project", sb.project]);
235
+ assert.equal(r.code, 0, r.stderr);
236
+ assert.match(r.stdout, /plan \(dry run, re-run with --yes to apply\)/);
237
+ assert.match(r.stdout, /codex\s+install\s+load-lock install --harness codex/);
238
+ const j = await setupOut(sb);
239
+ assert.deepEqual(steps(j, "codex"), ["install"]);
240
+ assert.deepEqual(snapshot(sb.root), before, "a dry run must not write anything");
241
+ rmSync(sb.root, { recursive: true, force: true });
242
+ });
243
+ test("setup converge: Claude Code plugin + loose install -> loose removed (skill kept); install/uninstall reverse it", async () => {
244
+ const sb = sandbox();
245
+ const claudeDir = join(sb.home, ".claude");
246
+ // the user's own config, including the plugin they enabled with /plugin
247
+ writeJsonFile(join(sb.home, ".claude.json"), { mcpServers: { fetch: { type: "stdio", command: "uvx", args: ["mcp-server-fetch"] } } });
248
+ writeJsonFile(join(claudeDir, "settings.json"), {
249
+ theme: "dark",
250
+ enabledPlugins: { "load-lock-plugin@load-lock": true },
251
+ hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] },
252
+ });
253
+ writeFileSync(join(claudeDir, "CLAUDE.md"), "# My rules\n");
254
+ // an ACE MCP patch the user merged into the project
255
+ writeJsonFile(join(sb.project, ".mcp.json"), { servers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } } });
256
+ const original = snapshot(sb.root);
257
+ // with the plugin enabled, only --legacy writes the loose wiring (#62)
258
+ await install(sb, "claude-code", "--legacy");
259
+ const out = await setupYes(sb);
260
+ assert.deepEqual(steps(out, "claude-code"), ["remove-loose", "manual"]);
261
+ assert.match(out.plan.steps.find((s) => s.action === "manual").detail, /servers\.load-lock/);
262
+ const cc = harness(out.inventory, "claude-code");
263
+ assert.equal(cc.rung, "plugin");
264
+ assert.deepEqual(cc.integrations.map((i) => `${i.kind}:${i.owner}`).sort(), ["mcp:other", "plugin:harness", "skill:installer"], "only the plugin, the skill and the foreign project entry remain");
265
+ assert.ok(!cc.findings.some((f) => f.startsWith("loose + plugin")));
266
+ const s = JSON.parse(readFileSync(join(claudeDir, "settings.json"), "utf8"));
267
+ assert.deepEqual(s.enabledPlugins, { "load-lock-plugin@load-lock": true }, "harness-owned plugin state untouched");
268
+ assert.equal(readFileSync(join(sb.project, ".mcp.json"), "utf8"), original[join(sb.project, ".mcp.json")], "foreign entry untouched");
269
+ // idempotent: a second run has only the manual step left
270
+ assert.deepEqual(steps(await setupYes(sb), "claude-code"), ["manual"]);
271
+ // reversible: install --legacy brings the loose wiring back ...
272
+ await install(sb, "claude-code", "--legacy");
273
+ assert.equal(harness(await setupJson(sb), "claude-code").integrations.filter((i) => i.owner === "installer").length, 4);
274
+ // ... and uninstall restores the user's original bytes
275
+ await uninstall(sb, "claude-code");
276
+ assert.deepEqual(snapshot(sb.root), original);
277
+ rmSync(sb.root, { recursive: true, force: true });
278
+ });
279
+ test("setup converge: codex with a second copy's hooks and a stale MCP block -> one current set; uninstall restores", async () => {
280
+ const sb = sandbox();
281
+ const codexDir = join(sb.home, ".codex");
282
+ writeJsonFile(join(codexDir, "hooks.json"), { hooks: { Stop: [{ hooks: [{ type: "command", command: "/usr/local/bin/user-stop" }] }] } });
283
+ mkdirSync(codexDir, { recursive: true });
284
+ writeFileSync(join(codexDir, "config.toml"), 'model = "o3"\n');
285
+ writeFileSync(join(codexDir, "AGENTS.md"), "# Mine\n");
286
+ const original = snapshot(sb.root);
287
+ await install(sb, "codex");
288
+ const gone = join(sb.root, "gone", "node_modules", "@aron98", "load-lock");
289
+ const hooksFile = join(codexDir, "hooks.json");
290
+ const h = JSON.parse(readFileSync(hooksFile, "utf8"));
291
+ h.hooks.Stop.push({ hooks: [{ type: "command", command: `/usr/bin/node ${JSON.stringify(join(gone, "dist", "cli", "main.js"))} hook codex stop` }] });
292
+ writeJsonFile(hooksFile, h);
293
+ const toml = join(codexDir, "config.toml");
294
+ const mcpMain = join(repoRoot, "dist", "mcp", "main.js");
295
+ writeFileSync(toml, readFileSync(toml, "utf8").replace(JSON.stringify(mcpMain), JSON.stringify(join(gone, "dist", "mcp", "main.js"))));
296
+ assert.ok(harness(await setupJson(sb), "codex").findings.length >= 2, "messy state is flagged");
297
+ const out = await setupYes(sb);
298
+ assert.deepEqual(steps(out, "codex"), ["install"]);
299
+ const cx = harness(out.inventory, "codex");
300
+ assert.deepEqual(cx.findings, [], cx.findings.join("\n"));
301
+ for (const i of cx.integrations.filter((x) => x.copy))
302
+ assert.equal(i.copy, repoRoot);
303
+ assert.equal(cx.integrations.filter((i) => i.kind === "hooks").length, 1);
304
+ assert.equal(readFileSync(toml, "utf8").split("[mcp_servers.load-lock]").length, 2, "exactly one MCP block");
305
+ await uninstall(sb, "codex");
306
+ assert.deepEqual(snapshot(sb.root), original);
307
+ rmSync(sb.root, { recursive: true, force: true });
308
+ });
309
+ test("setup converge: MCP-only cline is upgraded to the installer's wiring; the foreign entry is left for the user", async () => {
310
+ const sb = sandbox();
311
+ const file = join(sb.home, ".cline", "data", "settings", "cline_mcp_settings.json");
312
+ writeJsonFile(file, {
313
+ mcpServers: {
314
+ ll: { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] },
315
+ fetch: { command: "uvx", args: ["mcp-server-fetch"] },
316
+ },
317
+ });
318
+ const original = snapshot(sb.root);
319
+ assert.equal(harness(await setupJson(sb), "cline").rung, "mcp");
320
+ const out = await setupYes(sb);
321
+ assert.deepEqual(steps(out, "cline"), ["install", "manual"]);
322
+ assert.match(out.plan.steps.find((s) => s.action === "manual").detail, /remove mcpServers\.ll/);
323
+ const cl = harness(out.inventory, "cline");
324
+ assert.equal(cl.rung, "loose");
325
+ assert.ok(cl.integrations.some((i) => i.detail === "mcpServers.ll"), "foreign entry untouched");
326
+ await uninstall(sb, "cline");
327
+ assert.deepEqual(snapshot(sb.root), original);
328
+ rmSync(sb.root, { recursive: true, force: true });
329
+ });
330
+ test("setup converge: never overwrites a foreign entry in the installer's slot, an ACE skill, or from a cache copy", async () => {
331
+ const sb = sandbox();
332
+ writeJsonFile(join(sb.home, ".gemini", "settings.json"), {
333
+ mcpServers: { "load-lock": { command: "npx", args: ["-y", "@aron98/load-lock@1.5.0", "mcp"] } },
334
+ });
335
+ const skill = join(sb.home, ".claude", "skills", "load-lock");
336
+ mkdirSync(skill, { recursive: true });
337
+ writeFileSync(join(skill, "SKILL.md"), "---\nname: load-lock\ndescription: x\n---\n");
338
+ writeFileSync(join(skill, ".ace.json"), "{}\n");
339
+ const before = snapshot(sb.root);
340
+ const out = await setupYes(sb);
341
+ assert.deepEqual(steps(out, "gemini-cli"), ["manual"]);
342
+ assert.deepEqual(steps(out, "claude-code"), ["manual"]);
343
+ assert.deepEqual(out.applied, []);
344
+ assert.deepEqual(snapshot(sb.root), before);
345
+ // a copy running from the npx cache never wires harnesses to itself
346
+ const { inventory, planConverge } = await import("../cli/setup.js");
347
+ writeJsonFile(join(sb.home, ".copilot", "mcp-config.json"), { mcpServers: { x: { command: "npx", args: ["@aron98/load-lock", "mcp"] } } });
348
+ const inv = inventory({ project: sb.project, home: sb.home, env: sb.env });
349
+ const plan = planConverge(inv, { selfCopy: "/c/_npx/abc/node_modules/@aron98/load-lock", selfEphemeral: true });
350
+ const cp = plan.steps.filter((s) => s.harness === "copilot-cli");
351
+ assert.deepEqual(cp.map((s) => s.action), ["manual"]);
352
+ assert.match(cp[0].detail, /cache copy/);
353
+ rmSync(sb.root, { recursive: true, force: true });
354
+ });
@@ -214,6 +214,24 @@ test("planUpdate: install argvs for bun/npm; honest refusals otherwise", () => {
214
214
  assert.equal(unknown.action, "refuse");
215
215
  assert.ok(unknown.reason.includes("cannot tell"), "unknown refusal admits it cannot tell");
216
216
  });
217
+ test("classifySelf + planUpdate: npx-cache copy refuses, naming the pin (issue #57)", () => {
218
+ const base = mkdtempSync(join(TMP_BASE, "classify-npx-"));
219
+ try {
220
+ const npxRoot = join(base, "home", ".npm", "_npx");
221
+ const ctx = { home: join(base, "home"), npmGlobalRoot: join(base, "npm-root"), cacheRoot: join(base, "cache"), npxRoot };
222
+ const loc = classifySelf(join(npxRoot, "0123abcd", "node_modules", "@aron98", "load-lock"), ctx);
223
+ assert.equal(loc.kind, "npx-cache");
224
+ const plan = planUpdate(loc);
225
+ assert.equal(plan.action, "refuse");
226
+ assert.ok(plan.action === "refuse" && plan.reason.includes("npx cache"), "refusal names the npx cache");
227
+ assert.ok(plan.action === "refuse" && plan.reason.includes("pinned version"), "refusal says to bump the pin");
228
+ // A sibling of the npx root is not misclassified.
229
+ assert.notEqual(classifySelf(join(base, "home", ".npm", "_npx-other", "x"), ctx).kind, "npx-cache");
230
+ }
231
+ finally {
232
+ rmSync(base, { recursive: true, force: true });
233
+ }
234
+ });
217
235
  // --------------------------------------------------------- detectCliDrift (pure)
218
236
  test("detectCliDrift: older PATH copy -> one notice with the exact command", () => {
219
237
  const base = mkdtempSync(join(TMP_BASE, "drift-"));
@@ -231,6 +249,26 @@ test("detectCliDrift: older PATH copy -> one notice with the exact command", ()
231
249
  rmSync(base, { recursive: true, force: true });
232
250
  }
233
251
  });
252
+ test("detectCliDrift: newer npx-cache copy vs older PATH CLI -> notice names the npx copy (issue #57)", () => {
253
+ const base = mkdtempSync(join(TMP_BASE, "drift-npx-"));
254
+ try {
255
+ const binDir = plantBinCopy(join(base, "copy"), "0.9.0");
256
+ const cache = join(base, "npm-cache");
257
+ const ownPath = join(cache, "_npx", "0123abcd", "node_modules", "@aron98", "load-lock");
258
+ const d = detectCliDrift("9.9.9", { PATH: binDir, npm_config_cache: cache }, "linux", ownPath);
259
+ assert.ok(d, "older PATH CLI is drift");
260
+ assert.equal(d.ownSource, "npx-cache");
261
+ assert.ok(d.message.includes("npx"), `notice names the npx copy: ${d.message}`);
262
+ assert.ok(d.message.includes("load-lock update"), "notice names the exact command");
263
+ // The same skew from a non-npx copy keeps the adapter wording.
264
+ const other = detectCliDrift("9.9.9", { PATH: binDir, npm_config_cache: cache }, "linux", join(base, "elsewhere"));
265
+ assert.equal(other.ownSource, "adapter");
266
+ assert.ok(!other.message.includes("npx"));
267
+ }
268
+ finally {
269
+ rmSync(base, { recursive: true, force: true });
270
+ }
271
+ });
234
272
  test("detectCliDrift: same/newer PATH copy, no PATH entry, foreign bin -> no drift", () => {
235
273
  const base = mkdtempSync(join(TMP_BASE, "nodrift-"));
236
274
  try {
@@ -404,6 +442,39 @@ test("update: npm-global apply installs via the stub and restarts the service",
404
442
  rmSync(base, { recursive: true, force: true });
405
443
  }
406
444
  });
445
+ test("update: run from a planted npx-cache copy refuses with a clear message (issue #57)", async () => {
446
+ const base = mkdtempSync(join(TMP_BASE, "upd-npx-"));
447
+ try {
448
+ const home = join(base, "home");
449
+ const plantedCli = plantCopy(join(home, ".npm", "_npx", "0123abcd", "node_modules", "@aron98", "load-lock"), packageVersion());
450
+ makeStubNpm(join(base, "bin"));
451
+ const mark = join(base, "marker");
452
+ const env = {
453
+ ...process.env,
454
+ HOME: home,
455
+ PATH: join(base, "bin") + ":" + (process.env.PATH ?? ""),
456
+ STUB_VERSION: "9.9.9",
457
+ STUB_ROOT: join(base, "no-such-npm-root"),
458
+ STUB_MARK: mark,
459
+ };
460
+ // Resolve the npx cache from the temp HOME, never the real npm cache.
461
+ delete env.npm_config_cache;
462
+ delete env.NPM_CONFIG_CACHE;
463
+ const check = await runCliEnv(env, ["update", "--check", "--json"], plantedCli);
464
+ assert.equal(check.code, 1, `an update exists; this copy cannot self-apply it; stderr: ${check.stderr}`);
465
+ const report = JSON.parse(check.stdout);
466
+ assert.equal(report.state, "refused");
467
+ assert.equal(report.location.kind, "npx-cache");
468
+ assert.ok(report.reason.includes("npx cache") && report.reason.includes("pinned version"), report.reason);
469
+ const apply = await runCliEnv(env, ["update"], plantedCli);
470
+ assert.equal(apply.code, 70, "refusal in apply mode is a failure exit");
471
+ assert.ok(apply.stderr.includes("update refused") && apply.stderr.includes("npx cache"), apply.stderr);
472
+ assert.ok(!existsSync(mark), "no package manager ran");
473
+ }
474
+ finally {
475
+ rmSync(base, { recursive: true, force: true });
476
+ }
477
+ });
407
478
  // ------------------------------------------------------------- service restart
408
479
  test("service restart: running daemon -> stop + start, new daemon answers", async () => {
409
480
  const d = await startDaemon();
@@ -9,7 +9,7 @@
9
9
  * - `service stop` waits bounded, escalates to SIGKILL, verifies the
10
10
  * socket is free, and reports honestly
11
11
  * - `service start` refusals name the conflicting pid
12
- * - `doctor` reports installed copies and warns on drift
12
+ * - `doctor` reports installed copies (incl. npx-cache, #57) and warns on drift
13
13
  *
14
14
  * Modeled after daemon.test.ts: real daemons over real sockets in temp
15
15
  * state dirs (LL_TEST_TMP / $TMPDIR/opencode — never the user's real dir).
@@ -453,3 +453,35 @@ test("doctor: reports installed copies, versions, daemon skew, and copy drift",
453
453
  rmSync(fakePkg, { recursive: true, force: true });
454
454
  }
455
455
  });
456
+ test("doctor: lists a planted npx-cache copy (issue #57)", async () => {
457
+ const d = await startDaemon();
458
+ const home = mkdtempSync(join(TMP_BASE, "ll-npxhome-"));
459
+ try {
460
+ // npx layout under the default npm cache: ~/.npm/_npx/<hash>/node_modules/@aron98/load-lock
461
+ const npxCopy = join(home, ".npm", "_npx", "0123abcd4567ef89", "node_modules", "@aron98", "load-lock");
462
+ mkdirSync(npxCopy, { recursive: true });
463
+ writeFileSync(join(npxCopy, "package.json"), JSON.stringify({ name: "@aron98/load-lock", version: "0.4.2" }));
464
+ // A foreign npx entry is ignored.
465
+ const foreign = join(home, ".npm", "_npx", "ffff", "node_modules", "@aron98", "load-lock");
466
+ mkdirSync(foreign, { recursive: true });
467
+ writeFileSync(join(foreign, "package.json"), JSON.stringify({ name: "something-else", version: "1.0.0" }));
468
+ const env = { ...d.env, HOME: home };
469
+ // npm sets npm_config_cache for scripts it runs (`npm test`); the test
470
+ // must resolve the planted temp HOME's default cache, never the real one.
471
+ delete env.npm_config_cache;
472
+ delete env.NPM_CONFIG_CACHE;
473
+ const r = await runCliEnv(env, ["doctor", "--json"]);
474
+ assert.equal(r.code, 0, `stderr: ${r.stderr}`);
475
+ const report = JSON.parse(r.stdout);
476
+ const copies = report.copies;
477
+ const npx = copies.filter((c) => c.source === "npx-cache");
478
+ assert.equal(npx.length, 1, `exactly the planted npx copy: ${JSON.stringify(copies)}`);
479
+ assert.equal(npx[0].version, "0.4.2");
480
+ assert.equal(npx[0].path, npxCopy);
481
+ assert.ok(report.warnings.some((w) => w.includes("multiple load-lock versions") && w.includes("npx-cache=0.4.2")), `drift warning names the npx copy: ${JSON.stringify(report.warnings)}`);
482
+ }
483
+ finally {
484
+ await d.stop();
485
+ rmSync(home, { recursive: true, force: true });
486
+ }
487
+ });
package/docs/ADAPTERS.md CHANGED
@@ -52,8 +52,8 @@ adapter does — see `src/adapters/opencode/plugin.ts` as the worked example):
52
52
  ## Tier 2 — hooks + MCP
53
53
 
54
54
  For harnesses with lifecycle hooks but no in-process API (Claude Code and
55
- Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary, or
56
- `node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
55
+ Codex CLI use this): keep the stock MCP server (`load-lock-mcp` binary,
56
+ `load-lock mcp`, or `node .../dist/mcp/main.js`) for tools, and add hook handlers that shell out
57
57
  to the CLI:
58
58
 
59
59
  - `load-lock hook <harness> session-start` → prints `POLICY_BRIEF` +
@@ -96,6 +96,7 @@ normally touches:
96
96
  | `heartbeat {agentId}` | keep untracked leases alive |
97
97
  | `watch {agentId\|*}` / `events {sinceEventId, agentId?}` | push/pull event streams (durable ids) |
98
98
  | `offers_for_cwd {cwd}` | for hook scripts without session identity |
99
+ | `hook_once {harness, sessionId, event, occurrence?, ttlMs?, hold?}` | hook dedupe: `{first}` is true only for the first caller per key; keys expire (TTL, default 60s) or, with `hold`, live as long as the connection; in-memory only |
99
100
 
100
101
  ## Lifecycle contract (the part that must not be improvised)
101
102
 
@@ -6,8 +6,42 @@ wide capacity budget. Before resource-intensive work, acquire a permit. The
6
6
  whole thing is cooperative: you decide what is heavy; the budget only works
7
7
  if everyone participates.
8
8
 
9
- This guide is also available as `load-lock agent-guide` and as a skill in
10
- supported harnesses.
9
+ This guide is also available as the `agent_guide` MCP tool, as
10
+ `load-lock agent-guide`, and as a skill in supported harnesses.
11
+
12
+ ## 0. How to reach load-lock
13
+
14
+ Use the first of these that works; they all talk to the same daemon:
15
+
16
+ 1. **load-lock tools** in your harness (MCP tools, or `load_lock_*` in
17
+ OpenCode). The rest of this guide names the tools.
18
+ 2. **`load-lock` on PATH** (check with `load-lock version`).
19
+ 3. **Pinned npx**: `npx -y @aron98/load-lock@<version> <command>`, with the
20
+ version the load-lock skill pins (Node >= 20). Read `load-lock` below as
21
+ this whole prefix.
22
+
23
+ The first call starts the shared daemon; you never start it yourself.
24
+
25
+ **CLI only (skill-only install, no tools).** Prefer `load-lock run`: it
26
+ acquires, runs the command, and releases when it exits, success or failure:
27
+
28
+ ```sh
29
+ load-lock run --reason "full test suite" --op-key test/all -- npm test
30
+ ```
31
+
32
+ Exit code **68** means "not granted yet" (queued, with the request id in
33
+ the message): don't start the work. Keep doing light work; when you run out,
34
+ `load-lock wait --id <request-id>` sleeps until your turn, claims the offer
35
+ and prints a `permit_token` for `load-lock run --permit <permit_token> -- <cmd>`.
36
+ A `wait` that exits 68 again is a checkpoint: call it again. Your own
37
+ requests and offers: `load-lock pending`.
38
+
39
+ Your agent id is derived from your harness session, so all calls from one
40
+ session count as one agent. If `pending` doesn't show a request you made,
41
+ pin the id yourself with `LOAD_LOCK_AGENT=<unique-name>` (or `--agent`) on
42
+ every call. Without tools nothing wakes you while you sit idle and nothing
43
+ re-teaches the policy after context compaction; `load-lock setup` shows how
44
+ to add the harness integration that does.
11
45
 
12
46
  ## 1. Deciding heavy vs light
13
47