@agfpd/iapeer-memory 0.1.12 → 0.2.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.
package/src/slot.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Memory-provider slot declaration — the iapeer memory-slot contract (FINAL,
3
- * iapeer docs fc68c54/e2195a7/c968219). The slot file tells the core that
4
- * the three public surfaces (layer-5 fragments / MCP tools / daemon under a
5
- * notifier watcher) are occupied:
2
+ * Memory-provider slot declaration — the iapeer memory-slot contract (FINAL
3
+ * base, iapeer docs fc68c54/e2195a7/c968219; v1.2 revision agreed 11.06).
4
+ * The slot file tells the core that the three public surfaces (layer-5
5
+ * fragments / MCP tools / daemon under a notifier watcher) are occupied:
6
6
  *
7
7
  * - the PROVIDER writes and removes the file (our init/uninstall), atomic
8
8
  * temp+rename; the core only reads it (absent/unreadable = empty slot);
@@ -12,14 +12,21 @@
12
12
  * marker, ADR-010); our `update` re-writes it (P4 obligation);
13
13
  * - `heartbeat` (optional) = the absolute path whose mtime memoryd touches —
14
14
  * the core may show staleness in `iapeer status`, never acts on it;
15
- * - `plugin` (v1.1, agreed 10.06 + live in iapeer 0.2.25) = the marketplace
16
- * identity of the session plugin. The core DERIVES installs from this block:
17
- * birth-hook installs for new peers, `iapeer memory-plugin on|off (--peer|
18
- * --all)` is the operator verb (built-in marketplace ensure + stale-cache
19
- * retry). Reference reader: iapeer src/status/index.ts parsePluginBlock
20
- * all three fields required non-empty, anything less = treated as a v1
21
- * declaration (no install). marketplaceRef matches iapeer onboard's
22
- * MARKETPLACE_REF for the distribution default.
15
+ * - `provision`/`unprovision` (v1.2, ADR-009 v1.2 boris's birth-joint
16
+ * inversion, schema fixed with the core 11.06): the PROVIDER's OWN command
17
+ * the core shells into at peer birth / verb sweeps / peer removal. The
18
+ * core never learns the surface forms; placeholders {cwd} {runtime}
19
+ * {personality} {occasion} substitute PER-ARGUMENT (argv spawn, no shell,
20
+ * 120s timeout, best-effort + loud warn). Precedence at the core:
21
+ * provision > plugin with NO runtime fallback;
22
+ * - `plugin` (v1.1, deprecated by v1.2): we no longer WRITE it — holding
23
+ * both blocks would make an old core re-install the plugin we swept
24
+ * (agreed 11.06). An old core reads our v1.2 slot as «provider without a
25
+ * plugin» and honestly skips the birth install; the newborn is picked up
26
+ * by the verify --repair sweep. RELEASE ORDER closes even that window on
27
+ * this host: the core ships its v1.2 parser FIRST, our release follows.
28
+ * The type keeps the field so uninstall/update can MIGRATE old slots
29
+ * (plugin off --all while the block is still readable).
23
30
  */
24
31
 
25
32
  import fs from "node:fs";
@@ -28,7 +35,8 @@ import path from "node:path";
28
35
  export const SLOT_PROVIDER = "iapeer-memory";
29
36
  export const SLOT_PACKAGE = "@agfpd/iapeer-memory";
30
37
 
31
- /** Mirror of iapeer's MemoryProviderPlugin (src/status/index.ts). */
38
+ /** Mirror of iapeer's MemoryProviderPlugin (src/status/index.ts). v1.1
39
+ * legacy: READ-only here (migration off-path); v1.2 slots no longer carry it. */
32
40
  export type MemoryProviderPlugin = {
33
41
  /** Plugin id in the marketplace (forms `<name>@<marketplace>`). */
34
42
  name: string;
@@ -38,19 +46,54 @@ export type MemoryProviderPlugin = {
38
46
  marketplaceRef: string;
39
47
  };
40
48
 
41
- export const SLOT_PLUGIN: MemoryProviderPlugin = {
42
- name: "iapeer-memory",
43
- marketplace: "agfpd",
44
- marketplaceRef: "agfpd/agfpd-marketplace",
49
+ /** v1.2 provision command block — argv form (§7 req 1: per-argument
50
+ * placeholder substitution, spawn without a shell). */
51
+ export type MemoryProviderCommand = {
52
+ /** Absolute path (§7 req 2: birth-hooks live in a minimal launchd PATH). */
53
+ command: string;
54
+ args: string[];
45
55
  };
46
56
 
57
+ /** The provision/unprovision blocks of OUR slot — built around the stable
58
+ * installed binary (the same path the hooks/watcher rely on). */
59
+ export function slotProvisionBlocks(binaryPath: string): {
60
+ provision: MemoryProviderCommand;
61
+ unprovision: MemoryProviderCommand;
62
+ } {
63
+ return {
64
+ provision: {
65
+ command: binaryPath,
66
+ args: [
67
+ "provision-peer",
68
+ "--cwd", "{cwd}",
69
+ "--runtime", "{runtime}",
70
+ "--personality", "{personality}",
71
+ "--occasion", "{occasion}",
72
+ ],
73
+ },
74
+ unprovision: {
75
+ command: binaryPath,
76
+ args: [
77
+ "unprovision-peer",
78
+ "--cwd", "{cwd}",
79
+ "--runtime", "{runtime}",
80
+ "--occasion", "{occasion}",
81
+ ],
82
+ },
83
+ };
84
+ }
85
+
47
86
  export type MemoryProviderSlot = {
48
87
  provider: string;
49
88
  package: string;
50
89
  version: string;
51
90
  registeredAt: string;
52
91
  heartbeat?: string;
92
+ /** v1.1 legacy (read for migration; never written by v1.2 code). */
53
93
  plugin?: MemoryProviderPlugin;
94
+ /** v1.2 (ADR-009 v1.2). */
95
+ provision?: MemoryProviderCommand;
96
+ unprovision?: MemoryProviderCommand;
54
97
  };
55
98
 
56
99
  /** Never throws: missing / unreadable / malformed → null (empty slot). */
@@ -72,6 +115,8 @@ export type SlotWriteResult = {
72
115
  export function writeSlot(opts: {
73
116
  slotPath: string;
74
117
  version: string;
118
+ /** Absolute path of the installed binary — the provision command carrier. */
119
+ binaryPath: string;
75
120
  heartbeat?: string;
76
121
  /** Injectable for tests. */
77
122
  nowIso?: string;
@@ -80,15 +125,15 @@ export function writeSlot(opts: {
80
125
  if (existing && existing.provider !== SLOT_PROVIDER) {
81
126
  return { action: "refused-foreign", existing };
82
127
  }
128
+ const blocks = slotProvisionBlocks(opts.binaryPath);
83
129
  if (
84
130
  existing &&
85
131
  existing.version === opts.version &&
86
132
  existing.heartbeat === opts.heartbeat &&
87
133
  existing.package === SLOT_PACKAGE &&
88
- existing.plugin &&
89
- existing.plugin.name === SLOT_PLUGIN.name &&
90
- existing.plugin.marketplace === SLOT_PLUGIN.marketplace &&
91
- existing.plugin.marketplaceRef === SLOT_PLUGIN.marketplaceRef
134
+ existing.plugin === undefined && // a v1.1 slot (plugin block) must MIGRATE to the v1.2 form
135
+ JSON.stringify(existing.provision) === JSON.stringify(blocks.provision) &&
136
+ JSON.stringify(existing.unprovision) === JSON.stringify(blocks.unprovision)
92
137
  ) {
93
138
  return { action: "identical", existing }; // idempotent re-init: no churn
94
139
  }
@@ -98,7 +143,7 @@ export function writeSlot(opts: {
98
143
  version: opts.version,
99
144
  registeredAt: opts.nowIso ?? new Date().toISOString(),
100
145
  ...(opts.heartbeat ? { heartbeat: opts.heartbeat } : {}),
101
- plugin: SLOT_PLUGIN,
146
+ ...blocks,
102
147
  };
103
148
  fs.mkdirSync(path.dirname(opts.slotPath), { recursive: true });
104
149
  const tmp = `${opts.slotPath}.tmp`;
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Direct claude session surfaces — ADR-009 v1.2 (решение Артура 10.06:
3
+ * прямые per-peer поверхности вместо плагина-розетки). Three surfaces are
4
+ * merged into the PEER's cwd; nothing is written host-globally (требование
5
+ * №3) and nothing of the user's is ever clobbered (требование №1 — only our
6
+ * own keys/entries are read-merge-written, atomically):
7
+ *
8
+ * 1. hooks — entries merged into `<cwd>/.claude/settings.json`
9
+ * (PostToolUse Write|Edit|MultiEdit + SessionStart). The hook
10
+ * command is the ABSOLUTE path of our materialised shim —
11
+ * ownership lives IN THE DATA (boris design input): an entry
12
+ * whose command path contains `/iapeer-memory/hooks/` is ours,
13
+ * everything else in the file is somebody else's (the file
14
+ * also carries statusline-injector, totp-presence, the core's
15
+ * autoMemoryEnabled — the «last writer rolls back» class is
16
+ * closed by patching ONLY our entries);
17
+ * 2. mcp — `mcpServers["iapeer-memory"]` merged into `<cwd>/.mcp.json`,
18
+ * LITERAL url + identity header (the BATTLE-TESTED direct form:
19
+ * the core's writeClaudeMcpConfig writes the same shape for its
20
+ * own 8765 server on the whole fleet; the plugin's env-
21
+ * substitution form has NO live precedent in the direct project
22
+ * scope — D1's env-form decision was reversed on this fact);
23
+ * 3. skills — `<cwd>/.claude/skills/iapeer-memory-<name>/SKILL.md`
24
+ * (embedded bodies, bytes-compare; the directory prefix is OUR
25
+ * namespace — unprovision removes every match).
26
+ *
27
+ * Pickup semantics (documented, boris design input): a live session does NOT
28
+ * re-read these files — surfaces land on the peer's NEXT session start
29
+ * (same semantics the plugin form had).
30
+ *
31
+ * Idempotent by construction: same version re-run → `already` on every
32
+ * surface; drift (a mangled/deleted entry) → re-written. The remove path is
33
+ * the exact mirror: our entries/keys/directories only, empty containers are
34
+ * swept (an empty `hooks: {}` left behind would be OUR litter in the user's
35
+ * file).
36
+ */
37
+
38
+ import fs from "node:fs";
39
+ import path from "node:path";
40
+ import { SKILL_BODIES, SKILL_DIR_PREFIX, SKILL_NAMES } from "../templates/skills.js";
41
+
42
+ export const MCP_SERVER_KEY = "iapeer-memory";
43
+ /**
44
+ * In-data ownership of our hook entries — the namespace lives in the shim
45
+ * FILE NAME (`iapeer-memory.<verb>.sh`), NOT in the directory path. D4 live
46
+ * catch 11.06: the first form keyed on the `/iapeer-memory/hooks/` directory
47
+ * segment, which is DERIVED from the config-file location — a custom
48
+ * IAPEER_MEMORY_CONFIG_FILE produced a hooksDir without the segment, so our
49
+ * own entries read as foreign (duplicated on every update, false drift on
50
+ * every check). A basename namespace is invariant under EVERY path
51
+ * configuration by construction. Matching is deliberately WIDER than the
52
+ * current expected path: an entry pointing at a STALE shim location is
53
+ * still ours — drift to repair, not a foreign entry to preserve.
54
+ */
55
+ export const HOOK_SHIM_PREFIX = "iapeer-memory.";
56
+
57
+ export function isOurHookCommand(command: string): boolean {
58
+ const base = command.split("/").pop() ?? "";
59
+ return base.startsWith(HOOK_SHIM_PREFIX) && base.endsWith(".sh");
60
+ }
61
+
62
+ export type SurfaceAction = "written" | "already" | "removed" | "absent" | "failed";
63
+ export type SurfaceOutcome = {
64
+ surface: "hooks" | "mcp" | "skills";
65
+ action: SurfaceAction;
66
+ path: string;
67
+ detail?: string;
68
+ };
69
+
70
+ // ── shims (fail-open bash, materialised into OUR territory) ──────────────────
71
+
72
+ function shimContent(verb: "post-write" | "session-start"): string {
73
+ return [
74
+ "#!/usr/bin/env bash",
75
+ `# iapeer-memory hook shim — ALL logic lives in the package CLI`,
76
+ `# (\`iapeer-memory hook ${verb}\`; testable TS, ADR-009 v1.2 direct form).`,
77
+ "# Fail-open: no CLI on this host → silent exit 0.",
78
+ "set -euo pipefail",
79
+ 'CLI="$(command -v iapeer-memory || true)"',
80
+ '[ -n "$CLI" ] || CLI="$HOME/.local/bin/iapeer-memory"',
81
+ '[ -x "$CLI" ] || exit 0',
82
+ `exec "$CLI" hook ${verb}`,
83
+ "",
84
+ ].join("\n");
85
+ }
86
+
87
+ function writeFileAtomic(filePath: string, content: string, mode?: number): void {
88
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
89
+ const tmp = `${filePath}.tmp`;
90
+ fs.writeFileSync(tmp, content, "utf-8");
91
+ if (mode !== undefined) fs.chmodSync(tmp, mode);
92
+ fs.renameSync(tmp, filePath);
93
+ }
94
+
95
+ /** Materialise both hook shims (package-owned, bytes-compare). provision
96
+ * always runs this first — the merged settings entries must never point at
97
+ * a void (the core's birth-hook may call provision-peer on a host where
98
+ * init ran long ago and the shims were swept by hand). */
99
+ export function shimPath(hooksDir: string, verb: "post-write" | "session-start"): string {
100
+ return path.join(hooksDir, `${HOOK_SHIM_PREFIX}${verb}.sh`); // namespace IN the basename
101
+ }
102
+
103
+ export function materialiseShims(hooksDir: string): "written" | "identical" {
104
+ let wrote = false;
105
+ for (const verb of ["post-write", "session-start"] as const) {
106
+ const p = shimPath(hooksDir, verb);
107
+ const content = shimContent(verb);
108
+ try {
109
+ if (fs.readFileSync(p, "utf-8") === content) continue;
110
+ } catch {
111
+ // missing → write
112
+ }
113
+ writeFileAtomic(p, content, 0o755);
114
+ wrote = true;
115
+ }
116
+ return wrote ? "written" : "identical";
117
+ }
118
+
119
+ // ── expected forms ───────────────────────────────────────────────────────────
120
+
121
+ /** LITERAL url + identity header — byte-isomorphic to the core's own battle
122
+ * form (writeClaudeMcpConfig: every fleet peer carries
123
+ * `"X-IAPeer-Identity": "claude-<personality>"` against the 8765 daemon,
124
+ * proven live at cold-start 08.06). Port and personality are HOST FACTS
125
+ * baked at provision time; drift (port change, peer rename) is repaired by
126
+ * the update/verify sweep against fleet.json (требование №2). */
127
+ export function expectedMcpEntry(opts: {
128
+ port: number;
129
+ personality: string;
130
+ }): Record<string, unknown> {
131
+ return {
132
+ type: "http",
133
+ url: `http://127.0.0.1:${opts.port}/mcp`,
134
+ headers: {
135
+ "X-IAPeer-Identity": `claude-${opts.personality}`,
136
+ },
137
+ };
138
+ }
139
+
140
+ type HookEntry = {
141
+ matcher?: string;
142
+ hooks: Array<{ type: string; command: string }>;
143
+ };
144
+
145
+ export function expectedHookEntries(
146
+ hooksDir: string,
147
+ ): Record<"PostToolUse" | "SessionStart", HookEntry> {
148
+ return {
149
+ PostToolUse: {
150
+ matcher: "Write|Edit|MultiEdit",
151
+ hooks: [{ type: "command", command: shimPath(hooksDir, "post-write") }],
152
+ },
153
+ SessionStart: {
154
+ hooks: [{ type: "command", command: shimPath(hooksDir, "session-start") }],
155
+ },
156
+ };
157
+ }
158
+
159
+ // ── json plumbing ────────────────────────────────────────────────────────────
160
+
161
+ type JsonObject = Record<string, unknown>;
162
+
163
+ /** null = unreadable-as-object (refuse to clobber); {} when absent. */
164
+ function readJsonObject(filePath: string): JsonObject | null | "absent" {
165
+ let raw: string;
166
+ try {
167
+ raw = fs.readFileSync(filePath, "utf-8");
168
+ } catch {
169
+ return "absent";
170
+ }
171
+ try {
172
+ const parsed = JSON.parse(raw) as unknown;
173
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
174
+ return parsed as JsonObject;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ function sameJson(a: unknown, b: unknown): boolean {
181
+ return JSON.stringify(a) === JSON.stringify(b);
182
+ }
183
+
184
+ function isOurHookEntry(entry: unknown): boolean {
185
+ if (!entry || typeof entry !== "object") return false;
186
+ const hooks = (entry as HookEntry).hooks;
187
+ if (!Array.isArray(hooks)) return false;
188
+ return hooks.some(
189
+ (h) => typeof h?.command === "string" && isOurHookCommand(h.command),
190
+ );
191
+ }
192
+
193
+ // ── hooks surface ────────────────────────────────────────────────────────────
194
+
195
+ export function mergeClaudeHooks(opts: {
196
+ cwd: string;
197
+ hooksDir: string;
198
+ }): SurfaceOutcome {
199
+ const settingsPath = path.join(opts.cwd, ".claude", "settings.json");
200
+ const current = readJsonObject(settingsPath);
201
+ if (current === null) {
202
+ return {
203
+ surface: "hooks",
204
+ action: "failed",
205
+ path: settingsPath,
206
+ detail: "settings.json is not a JSON object — refusing to clobber",
207
+ };
208
+ }
209
+ const obj: JsonObject = current === "absent" ? {} : current;
210
+ const hooksRaw = obj.hooks;
211
+ if (hooksRaw !== undefined && (typeof hooksRaw !== "object" || Array.isArray(hooksRaw) || hooksRaw === null)) {
212
+ return {
213
+ surface: "hooks",
214
+ action: "failed",
215
+ path: settingsPath,
216
+ detail: "settings.json `hooks` is not an object — refusing to clobber",
217
+ };
218
+ }
219
+ const hooks: JsonObject = (hooksRaw as JsonObject | undefined) ?? {};
220
+ const expected = expectedHookEntries(opts.hooksDir);
221
+ let changed = false;
222
+ for (const event of ["PostToolUse", "SessionStart"] as const) {
223
+ const listRaw = hooks[event];
224
+ const list: unknown[] = Array.isArray(listRaw) ? listRaw : [];
225
+ const ours = list.filter(isOurHookEntry);
226
+ if (ours.length === 1 && sameJson(ours[0], expected[event])) continue;
227
+ const foreign = list.filter((e) => !isOurHookEntry(e));
228
+ hooks[event] = [...foreign, expected[event]];
229
+ changed = true;
230
+ }
231
+ if (!changed) {
232
+ return { surface: "hooks", action: "already", path: settingsPath };
233
+ }
234
+ obj.hooks = hooks;
235
+ writeFileAtomic(settingsPath, `${JSON.stringify(obj, null, 2)}\n`);
236
+ return { surface: "hooks", action: "written", path: settingsPath };
237
+ }
238
+
239
+ export function removeClaudeHooks(opts: { cwd: string }): SurfaceOutcome {
240
+ const settingsPath = path.join(opts.cwd, ".claude", "settings.json");
241
+ const current = readJsonObject(settingsPath);
242
+ if (current === "absent") {
243
+ return { surface: "hooks", action: "absent", path: settingsPath };
244
+ }
245
+ if (current === null) {
246
+ return {
247
+ surface: "hooks",
248
+ action: "failed",
249
+ path: settingsPath,
250
+ detail: "settings.json is not a JSON object — refusing to touch",
251
+ };
252
+ }
253
+ const hooksRaw = current.hooks;
254
+ if (!hooksRaw || typeof hooksRaw !== "object" || Array.isArray(hooksRaw)) {
255
+ return { surface: "hooks", action: "absent", path: settingsPath };
256
+ }
257
+ const hooks = hooksRaw as JsonObject;
258
+ let changed = false;
259
+ for (const event of Object.keys(hooks)) {
260
+ const list = hooks[event];
261
+ if (!Array.isArray(list)) continue;
262
+ const kept = list
263
+ .map((entry) => {
264
+ if (!isOurHookEntry(entry)) return entry;
265
+ // an entry may THEORETICALLY mix our hook with a foreign one in the
266
+ // same matcher block — strip only our hook elements, keep the rest
267
+ const e = entry as HookEntry;
268
+ const foreignHooks = e.hooks.filter(
269
+ (h) => !(typeof h?.command === "string" && isOurHookCommand(h.command)),
270
+ );
271
+ if (foreignHooks.length === 0) return null; // entirely ours → drop
272
+ return { ...e, hooks: foreignHooks };
273
+ })
274
+ .filter((e): e is NonNullable<typeof e> => e !== null);
275
+ if (kept.length !== list.length || !sameJson(kept, list)) changed = true;
276
+ if (kept.length === 0) {
277
+ delete hooks[event];
278
+ } else {
279
+ hooks[event] = kept;
280
+ }
281
+ }
282
+ if (!changed) {
283
+ return { surface: "hooks", action: "absent", path: settingsPath };
284
+ }
285
+ if (Object.keys(hooks).length === 0) {
286
+ delete current.hooks; // our litter, not the user's — sweep the container
287
+ }
288
+ writeFileAtomic(settingsPath, `${JSON.stringify(current, null, 2)}\n`);
289
+ return { surface: "hooks", action: "removed", path: settingsPath };
290
+ }
291
+
292
+ // ── mcp surface ──────────────────────────────────────────────────────────────
293
+
294
+ export function mergeClaudeMcp(opts: {
295
+ cwd: string;
296
+ port: number;
297
+ personality: string;
298
+ }): SurfaceOutcome {
299
+ const mcpPath = path.join(opts.cwd, ".mcp.json");
300
+ const current = readJsonObject(mcpPath);
301
+ if (current === null) {
302
+ return {
303
+ surface: "mcp",
304
+ action: "failed",
305
+ path: mcpPath,
306
+ detail: ".mcp.json is not a JSON object — refusing to clobber",
307
+ };
308
+ }
309
+ const obj: JsonObject = current === "absent" ? {} : current;
310
+ const serversRaw = obj.mcpServers;
311
+ if (serversRaw !== undefined && (typeof serversRaw !== "object" || Array.isArray(serversRaw) || serversRaw === null)) {
312
+ return {
313
+ surface: "mcp",
314
+ action: "failed",
315
+ path: mcpPath,
316
+ detail: ".mcp.json `mcpServers` is not an object — refusing to clobber",
317
+ };
318
+ }
319
+ const servers: JsonObject = (serversRaw as JsonObject | undefined) ?? {};
320
+ const expected = expectedMcpEntry({ port: opts.port, personality: opts.personality });
321
+ if (sameJson(servers[MCP_SERVER_KEY], expected)) {
322
+ return { surface: "mcp", action: "already", path: mcpPath };
323
+ }
324
+ servers[MCP_SERVER_KEY] = expected;
325
+ obj.mcpServers = servers;
326
+ writeFileAtomic(mcpPath, `${JSON.stringify(obj, null, 2)}\n`);
327
+ return { surface: "mcp", action: "written", path: mcpPath };
328
+ }
329
+
330
+ export function removeClaudeMcp(opts: { cwd: string }): SurfaceOutcome {
331
+ const mcpPath = path.join(opts.cwd, ".mcp.json");
332
+ const current = readJsonObject(mcpPath);
333
+ if (current === "absent") {
334
+ return { surface: "mcp", action: "absent", path: mcpPath };
335
+ }
336
+ if (current === null) {
337
+ return {
338
+ surface: "mcp",
339
+ action: "failed",
340
+ path: mcpPath,
341
+ detail: ".mcp.json is not a JSON object — refusing to touch",
342
+ };
343
+ }
344
+ const servers = current.mcpServers;
345
+ if (!servers || typeof servers !== "object" || Array.isArray(servers) ||
346
+ !(MCP_SERVER_KEY in (servers as JsonObject))) {
347
+ return { surface: "mcp", action: "absent", path: mcpPath };
348
+ }
349
+ delete (servers as JsonObject)[MCP_SERVER_KEY];
350
+ const serversEmpty = Object.keys(servers as JsonObject).length === 0;
351
+ const onlyServersKey = Object.keys(current).length === 1;
352
+ if (serversEmpty && onlyServersKey) {
353
+ // semantically empty file — with our key gone it says nothing; a file we
354
+ // most likely created. Removing it leaves the cwd exactly as found.
355
+ fs.unlinkSync(mcpPath);
356
+ return { surface: "mcp", action: "removed", path: mcpPath, detail: "file removed (empty after our key)" };
357
+ }
358
+ writeFileAtomic(mcpPath, `${JSON.stringify(current, null, 2)}\n`);
359
+ return { surface: "mcp", action: "removed", path: mcpPath };
360
+ }
361
+
362
+ // ── skills surface ───────────────────────────────────────────────────────────
363
+
364
+ export function mergeClaudeSkills(opts: { cwd: string }): SurfaceOutcome {
365
+ const skillsDir = path.join(opts.cwd, ".claude", "skills");
366
+ let wrote = 0;
367
+ try {
368
+ for (const name of SKILL_NAMES) {
369
+ const p = path.join(skillsDir, name, "SKILL.md");
370
+ const body = SKILL_BODIES[name];
371
+ try {
372
+ if (fs.readFileSync(p, "utf-8") === body) continue;
373
+ } catch {
374
+ // missing → write
375
+ }
376
+ writeFileAtomic(p, body);
377
+ wrote++;
378
+ }
379
+ } catch (err) {
380
+ return { surface: "skills", action: "failed", path: skillsDir, detail: String(err) };
381
+ }
382
+ return wrote
383
+ ? { surface: "skills", action: "written", path: skillsDir, detail: `${wrote}/${SKILL_NAMES.length} skill(s) written` }
384
+ : { surface: "skills", action: "already", path: skillsDir };
385
+ }
386
+
387
+ export function removeClaudeSkills(opts: { cwd: string }): SurfaceOutcome {
388
+ const skillsDir = path.join(opts.cwd, ".claude", "skills");
389
+ let entries: string[];
390
+ try {
391
+ entries = fs.readdirSync(skillsDir);
392
+ } catch {
393
+ return { surface: "skills", action: "absent", path: skillsDir };
394
+ }
395
+ // the `iapeer-memory-` prefix is OUR namespace (the naming promise): every
396
+ // matching directory is ours — including stale names of older versions
397
+ const ours = entries.filter((e) => e.startsWith(SKILL_DIR_PREFIX));
398
+ if (ours.length === 0) {
399
+ return { surface: "skills", action: "absent", path: skillsDir };
400
+ }
401
+ for (const e of ours) {
402
+ fs.rmSync(path.join(skillsDir, e), { recursive: true, force: true });
403
+ }
404
+ // sweep empty containers we may have created (skills/ then .claude/)
405
+ for (const dir of [skillsDir, path.dirname(skillsDir)]) {
406
+ try {
407
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
408
+ } catch {
409
+ break;
410
+ }
411
+ }
412
+ return { surface: "skills", action: "removed", path: skillsDir, detail: `${ours.length} skill dir(s) removed` };
413
+ }
414
+
415
+ // ── the per-peer verbs ───────────────────────────────────────────────────────
416
+
417
+ export function provisionClaudePeer(opts: {
418
+ cwd: string;
419
+ hooksDir: string;
420
+ port: number;
421
+ personality: string;
422
+ }): SurfaceOutcome[] {
423
+ materialiseShims(opts.hooksDir);
424
+ return [
425
+ mergeClaudeHooks({ cwd: opts.cwd, hooksDir: opts.hooksDir }),
426
+ mergeClaudeMcp({ cwd: opts.cwd, port: opts.port, personality: opts.personality }),
427
+ mergeClaudeSkills({ cwd: opts.cwd }),
428
+ ];
429
+ }
430
+
431
+ export function unprovisionClaudePeer(opts: { cwd: string }): SurfaceOutcome[] {
432
+ return [
433
+ removeClaudeHooks({ cwd: opts.cwd }),
434
+ removeClaudeMcp({ cwd: opts.cwd }),
435
+ removeClaudeSkills({ cwd: opts.cwd }),
436
+ ];
437
+ }
438
+
439
+ /** Read-only drift check of one peer's claude surfaces (verify's eye; D3
440
+ * wires it into `verify [--repair]` across the fleet map). */
441
+ export function checkClaudePeer(opts: {
442
+ cwd: string;
443
+ hooksDir: string;
444
+ port: number;
445
+ personality: string;
446
+ }): Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> {
447
+ const out: Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> = [];
448
+
449
+ const settingsPath = path.join(opts.cwd, ".claude", "settings.json");
450
+ const settings = readJsonObject(settingsPath);
451
+ const expected = expectedHookEntries(opts.hooksDir);
452
+ if (settings === "absent" || settings === null) {
453
+ out.push({ surface: "hooks", ok: false, detail: `no readable settings at ${settingsPath}` });
454
+ } else {
455
+ const hooks = (settings.hooks ?? {}) as JsonObject;
456
+ const missing = (["PostToolUse", "SessionStart"] as const).filter((event) => {
457
+ const list = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : [];
458
+ const ours = list.filter(isOurHookEntry);
459
+ return !(ours.length === 1 && sameJson(ours[0], expected[event]));
460
+ });
461
+ out.push(
462
+ missing.length === 0
463
+ ? { surface: "hooks", ok: true, detail: "both hook entries in place" }
464
+ : { surface: "hooks", ok: false, detail: `drifted/missing: ${missing.join(", ")}` },
465
+ );
466
+ }
467
+
468
+ const mcp = readJsonObject(path.join(opts.cwd, ".mcp.json"));
469
+ const entry =
470
+ mcp !== "absent" && mcp !== null
471
+ ? ((mcp.mcpServers as JsonObject | undefined) ?? {})[MCP_SERVER_KEY]
472
+ : undefined;
473
+ out.push(
474
+ sameJson(entry, expectedMcpEntry({ port: opts.port, personality: opts.personality }))
475
+ ? { surface: "mcp", ok: true, detail: `${MCP_SERVER_KEY} server entry in place` }
476
+ : { surface: "mcp", ok: false, detail: `mcpServers["${MCP_SERVER_KEY}"] missing or drifted in ${opts.cwd}/.mcp.json` },
477
+ );
478
+
479
+ const skillsDir = path.join(opts.cwd, ".claude", "skills");
480
+ const drifted = SKILL_NAMES.filter((name) => {
481
+ try {
482
+ return fs.readFileSync(path.join(skillsDir, name, "SKILL.md"), "utf-8") !== SKILL_BODIES[name];
483
+ } catch {
484
+ return true;
485
+ }
486
+ });
487
+ out.push(
488
+ drifted.length === 0
489
+ ? { surface: "skills", ok: true, detail: `${SKILL_NAMES.length} skills in place` }
490
+ : { surface: "skills", ok: false, detail: `missing/drifted: ${drifted.join(", ")}` },
491
+ );
492
+
493
+ return out;
494
+ }