@agfpd/iapeer-memory 0.1.13 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,497 @@
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): hooks and MCP land on
28
+ * the peer's NEXT session start — a live session does not re-read them (same
29
+ * semantics the plugin form had). Skills are picked up HOT by live sessions
30
+ * (live observation, боевой E2E 11.06) — «next restart» is the conservative
31
+ * guarantee for all three, exact for hooks/MCP.
32
+ *
33
+ * Idempotent by construction: same version re-run → `already` on every
34
+ * surface; drift (a mangled/deleted entry) → re-written. The remove path is
35
+ * the exact mirror: our entries/keys/directories only, empty containers are
36
+ * swept (an empty `hooks: {}` left behind would be OUR litter in the user's
37
+ * file).
38
+ */
39
+
40
+ import fs from "node:fs";
41
+ import path from "node:path";
42
+ import { SKILL_BODIES, SKILL_DIR_PREFIX, SKILL_NAMES } from "../templates/skills.js";
43
+ import { guardedWriteFileSync, guardedUnlinkSync, guardedRmSync } from "@agfpd/iapeer-memory-core";
44
+
45
+ export const MCP_SERVER_KEY = "iapeer-memory";
46
+ /**
47
+ * In-data ownership of our hook entries — the namespace lives in the shim
48
+ * FILE NAME (`iapeer-memory.<verb>.sh`), NOT in the directory path. D4 live
49
+ * catch 11.06: the first form keyed on the `/iapeer-memory/hooks/` directory
50
+ * segment, which is DERIVED from the config-file location — a custom
51
+ * IAPEER_MEMORY_CONFIG_FILE produced a hooksDir without the segment, so our
52
+ * own entries read as foreign (duplicated on every update, false drift on
53
+ * every check). A basename namespace is invariant under EVERY path
54
+ * configuration by construction. Matching is deliberately WIDER than the
55
+ * current expected path: an entry pointing at a STALE shim location is
56
+ * still ours — drift to repair, not a foreign entry to preserve.
57
+ */
58
+ export const HOOK_SHIM_PREFIX = "iapeer-memory.";
59
+
60
+ export function isOurHookCommand(command: string): boolean {
61
+ const base = command.split("/").pop() ?? "";
62
+ return base.startsWith(HOOK_SHIM_PREFIX) && base.endsWith(".sh");
63
+ }
64
+
65
+ export type SurfaceAction = "written" | "already" | "removed" | "absent" | "failed";
66
+ export type SurfaceOutcome = {
67
+ surface: "hooks" | "mcp" | "skills";
68
+ action: SurfaceAction;
69
+ path: string;
70
+ detail?: string;
71
+ };
72
+
73
+ // ── shims (fail-open bash, materialised into OUR territory) ──────────────────
74
+
75
+ function shimContent(verb: "post-write" | "session-start"): string {
76
+ return [
77
+ "#!/usr/bin/env bash",
78
+ `# iapeer-memory hook shim — ALL logic lives in the package CLI`,
79
+ `# (\`iapeer-memory hook ${verb}\`; testable TS, ADR-009 v1.2 direct form).`,
80
+ "# Fail-open: no CLI on this host → silent exit 0.",
81
+ "set -euo pipefail",
82
+ 'CLI="$(command -v iapeer-memory || true)"',
83
+ '[ -n "$CLI" ] || CLI="$HOME/.local/bin/iapeer-memory"',
84
+ '[ -x "$CLI" ] || exit 0',
85
+ `exec "$CLI" hook ${verb}`,
86
+ "",
87
+ ].join("\n");
88
+ }
89
+
90
+ function writeFileAtomic(filePath: string, content: string, mode?: number): void {
91
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
92
+ const tmp = `${filePath}.tmp`;
93
+ guardedWriteFileSync(tmp, content, "utf-8");
94
+ if (mode !== undefined) fs.chmodSync(tmp, mode);
95
+ fs.renameSync(tmp, filePath);
96
+ }
97
+
98
+ /** Materialise both hook shims (package-owned, bytes-compare). provision
99
+ * always runs this first — the merged settings entries must never point at
100
+ * a void (the core's birth-hook may call provision-peer on a host where
101
+ * init ran long ago and the shims were swept by hand). */
102
+ export function shimPath(hooksDir: string, verb: "post-write" | "session-start"): string {
103
+ return path.join(hooksDir, `${HOOK_SHIM_PREFIX}${verb}.sh`); // namespace IN the basename
104
+ }
105
+
106
+ export function materialiseShims(hooksDir: string): "written" | "identical" {
107
+ let wrote = false;
108
+ for (const verb of ["post-write", "session-start"] as const) {
109
+ const p = shimPath(hooksDir, verb);
110
+ const content = shimContent(verb);
111
+ try {
112
+ if (fs.readFileSync(p, "utf-8") === content) continue;
113
+ } catch {
114
+ // missing → write
115
+ }
116
+ writeFileAtomic(p, content, 0o755);
117
+ wrote = true;
118
+ }
119
+ return wrote ? "written" : "identical";
120
+ }
121
+
122
+ // ── expected forms ───────────────────────────────────────────────────────────
123
+
124
+ /** LITERAL url + identity header — byte-isomorphic to the core's own battle
125
+ * form (writeClaudeMcpConfig: every fleet peer carries
126
+ * `"X-IAPeer-Identity": "claude-<personality>"` against the 8765 daemon,
127
+ * proven live at cold-start 08.06). Port and personality are HOST FACTS
128
+ * baked at provision time; drift (port change, peer rename) is repaired by
129
+ * the update/verify sweep against fleet.json (требование №2). */
130
+ export function expectedMcpEntry(opts: {
131
+ port: number;
132
+ personality: string;
133
+ }): Record<string, unknown> {
134
+ return {
135
+ type: "http",
136
+ url: `http://127.0.0.1:${opts.port}/mcp`,
137
+ headers: {
138
+ "X-IAPeer-Identity": `claude-${opts.personality}`,
139
+ },
140
+ };
141
+ }
142
+
143
+ type HookEntry = {
144
+ matcher?: string;
145
+ hooks: Array<{ type: string; command: string }>;
146
+ };
147
+
148
+ export function expectedHookEntries(
149
+ hooksDir: string,
150
+ ): Record<"PostToolUse" | "SessionStart", HookEntry> {
151
+ return {
152
+ PostToolUse: {
153
+ matcher: "Write|Edit|MultiEdit",
154
+ hooks: [{ type: "command", command: shimPath(hooksDir, "post-write") }],
155
+ },
156
+ SessionStart: {
157
+ hooks: [{ type: "command", command: shimPath(hooksDir, "session-start") }],
158
+ },
159
+ };
160
+ }
161
+
162
+ // ── json plumbing ────────────────────────────────────────────────────────────
163
+
164
+ type JsonObject = Record<string, unknown>;
165
+
166
+ /** null = unreadable-as-object (refuse to clobber); {} when absent. */
167
+ function readJsonObject(filePath: string): JsonObject | null | "absent" {
168
+ let raw: string;
169
+ try {
170
+ raw = fs.readFileSync(filePath, "utf-8");
171
+ } catch {
172
+ return "absent";
173
+ }
174
+ try {
175
+ const parsed = JSON.parse(raw) as unknown;
176
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
177
+ return parsed as JsonObject;
178
+ } catch {
179
+ return null;
180
+ }
181
+ }
182
+
183
+ function sameJson(a: unknown, b: unknown): boolean {
184
+ return JSON.stringify(a) === JSON.stringify(b);
185
+ }
186
+
187
+ function isOurHookEntry(entry: unknown): boolean {
188
+ if (!entry || typeof entry !== "object") return false;
189
+ const hooks = (entry as HookEntry).hooks;
190
+ if (!Array.isArray(hooks)) return false;
191
+ return hooks.some(
192
+ (h) => typeof h?.command === "string" && isOurHookCommand(h.command),
193
+ );
194
+ }
195
+
196
+ // ── hooks surface ────────────────────────────────────────────────────────────
197
+
198
+ export function mergeClaudeHooks(opts: {
199
+ cwd: string;
200
+ hooksDir: string;
201
+ }): SurfaceOutcome {
202
+ const settingsPath = path.join(opts.cwd, ".claude", "settings.json");
203
+ const current = readJsonObject(settingsPath);
204
+ if (current === null) {
205
+ return {
206
+ surface: "hooks",
207
+ action: "failed",
208
+ path: settingsPath,
209
+ detail: "settings.json is not a JSON object — refusing to clobber",
210
+ };
211
+ }
212
+ const obj: JsonObject = current === "absent" ? {} : current;
213
+ const hooksRaw = obj.hooks;
214
+ if (hooksRaw !== undefined && (typeof hooksRaw !== "object" || Array.isArray(hooksRaw) || hooksRaw === null)) {
215
+ return {
216
+ surface: "hooks",
217
+ action: "failed",
218
+ path: settingsPath,
219
+ detail: "settings.json `hooks` is not an object — refusing to clobber",
220
+ };
221
+ }
222
+ const hooks: JsonObject = (hooksRaw as JsonObject | undefined) ?? {};
223
+ const expected = expectedHookEntries(opts.hooksDir);
224
+ let changed = false;
225
+ for (const event of ["PostToolUse", "SessionStart"] as const) {
226
+ const listRaw = hooks[event];
227
+ const list: unknown[] = Array.isArray(listRaw) ? listRaw : [];
228
+ const ours = list.filter(isOurHookEntry);
229
+ if (ours.length === 1 && sameJson(ours[0], expected[event])) continue;
230
+ const foreign = list.filter((e) => !isOurHookEntry(e));
231
+ hooks[event] = [...foreign, expected[event]];
232
+ changed = true;
233
+ }
234
+ if (!changed) {
235
+ return { surface: "hooks", action: "already", path: settingsPath };
236
+ }
237
+ obj.hooks = hooks;
238
+ writeFileAtomic(settingsPath, `${JSON.stringify(obj, null, 2)}\n`);
239
+ return { surface: "hooks", action: "written", path: settingsPath };
240
+ }
241
+
242
+ export function removeClaudeHooks(opts: { cwd: string }): SurfaceOutcome {
243
+ const settingsPath = path.join(opts.cwd, ".claude", "settings.json");
244
+ const current = readJsonObject(settingsPath);
245
+ if (current === "absent") {
246
+ return { surface: "hooks", action: "absent", path: settingsPath };
247
+ }
248
+ if (current === null) {
249
+ return {
250
+ surface: "hooks",
251
+ action: "failed",
252
+ path: settingsPath,
253
+ detail: "settings.json is not a JSON object — refusing to touch",
254
+ };
255
+ }
256
+ const hooksRaw = current.hooks;
257
+ if (!hooksRaw || typeof hooksRaw !== "object" || Array.isArray(hooksRaw)) {
258
+ return { surface: "hooks", action: "absent", path: settingsPath };
259
+ }
260
+ const hooks = hooksRaw as JsonObject;
261
+ let changed = false;
262
+ for (const event of Object.keys(hooks)) {
263
+ const list = hooks[event];
264
+ if (!Array.isArray(list)) continue;
265
+ const kept = list
266
+ .map((entry) => {
267
+ if (!isOurHookEntry(entry)) return entry;
268
+ // an entry may THEORETICALLY mix our hook with a foreign one in the
269
+ // same matcher block — strip only our hook elements, keep the rest
270
+ const e = entry as HookEntry;
271
+ const foreignHooks = e.hooks.filter(
272
+ (h) => !(typeof h?.command === "string" && isOurHookCommand(h.command)),
273
+ );
274
+ if (foreignHooks.length === 0) return null; // entirely ours → drop
275
+ return { ...e, hooks: foreignHooks };
276
+ })
277
+ .filter((e): e is NonNullable<typeof e> => e !== null);
278
+ if (kept.length !== list.length || !sameJson(kept, list)) changed = true;
279
+ if (kept.length === 0) {
280
+ delete hooks[event];
281
+ } else {
282
+ hooks[event] = kept;
283
+ }
284
+ }
285
+ if (!changed) {
286
+ return { surface: "hooks", action: "absent", path: settingsPath };
287
+ }
288
+ if (Object.keys(hooks).length === 0) {
289
+ delete current.hooks; // our litter, not the user's — sweep the container
290
+ }
291
+ writeFileAtomic(settingsPath, `${JSON.stringify(current, null, 2)}\n`);
292
+ return { surface: "hooks", action: "removed", path: settingsPath };
293
+ }
294
+
295
+ // ── mcp surface ──────────────────────────────────────────────────────────────
296
+
297
+ export function mergeClaudeMcp(opts: {
298
+ cwd: string;
299
+ port: number;
300
+ personality: string;
301
+ }): SurfaceOutcome {
302
+ const mcpPath = path.join(opts.cwd, ".mcp.json");
303
+ const current = readJsonObject(mcpPath);
304
+ if (current === null) {
305
+ return {
306
+ surface: "mcp",
307
+ action: "failed",
308
+ path: mcpPath,
309
+ detail: ".mcp.json is not a JSON object — refusing to clobber",
310
+ };
311
+ }
312
+ const obj: JsonObject = current === "absent" ? {} : current;
313
+ const serversRaw = obj.mcpServers;
314
+ if (serversRaw !== undefined && (typeof serversRaw !== "object" || Array.isArray(serversRaw) || serversRaw === null)) {
315
+ return {
316
+ surface: "mcp",
317
+ action: "failed",
318
+ path: mcpPath,
319
+ detail: ".mcp.json `mcpServers` is not an object — refusing to clobber",
320
+ };
321
+ }
322
+ const servers: JsonObject = (serversRaw as JsonObject | undefined) ?? {};
323
+ const expected = expectedMcpEntry({ port: opts.port, personality: opts.personality });
324
+ if (sameJson(servers[MCP_SERVER_KEY], expected)) {
325
+ return { surface: "mcp", action: "already", path: mcpPath };
326
+ }
327
+ servers[MCP_SERVER_KEY] = expected;
328
+ obj.mcpServers = servers;
329
+ writeFileAtomic(mcpPath, `${JSON.stringify(obj, null, 2)}\n`);
330
+ return { surface: "mcp", action: "written", path: mcpPath };
331
+ }
332
+
333
+ export function removeClaudeMcp(opts: { cwd: string }): SurfaceOutcome {
334
+ const mcpPath = path.join(opts.cwd, ".mcp.json");
335
+ const current = readJsonObject(mcpPath);
336
+ if (current === "absent") {
337
+ return { surface: "mcp", action: "absent", path: mcpPath };
338
+ }
339
+ if (current === null) {
340
+ return {
341
+ surface: "mcp",
342
+ action: "failed",
343
+ path: mcpPath,
344
+ detail: ".mcp.json is not a JSON object — refusing to touch",
345
+ };
346
+ }
347
+ const servers = current.mcpServers;
348
+ if (!servers || typeof servers !== "object" || Array.isArray(servers) ||
349
+ !(MCP_SERVER_KEY in (servers as JsonObject))) {
350
+ return { surface: "mcp", action: "absent", path: mcpPath };
351
+ }
352
+ delete (servers as JsonObject)[MCP_SERVER_KEY];
353
+ const serversEmpty = Object.keys(servers as JsonObject).length === 0;
354
+ const onlyServersKey = Object.keys(current).length === 1;
355
+ if (serversEmpty && onlyServersKey) {
356
+ // semantically empty file — with our key gone it says nothing; a file we
357
+ // most likely created. Removing it leaves the cwd exactly as found.
358
+ guardedUnlinkSync(mcpPath);
359
+ return { surface: "mcp", action: "removed", path: mcpPath, detail: "file removed (empty after our key)" };
360
+ }
361
+ writeFileAtomic(mcpPath, `${JSON.stringify(current, null, 2)}\n`);
362
+ return { surface: "mcp", action: "removed", path: mcpPath };
363
+ }
364
+
365
+ // ── skills surface ───────────────────────────────────────────────────────────
366
+
367
+ export function mergeClaudeSkills(opts: { cwd: string }): SurfaceOutcome {
368
+ const skillsDir = path.join(opts.cwd, ".claude", "skills");
369
+ let wrote = 0;
370
+ try {
371
+ for (const name of SKILL_NAMES) {
372
+ const p = path.join(skillsDir, name, "SKILL.md");
373
+ const body = SKILL_BODIES[name];
374
+ try {
375
+ if (fs.readFileSync(p, "utf-8") === body) continue;
376
+ } catch {
377
+ // missing → write
378
+ }
379
+ writeFileAtomic(p, body);
380
+ wrote++;
381
+ }
382
+ } catch (err) {
383
+ return { surface: "skills", action: "failed", path: skillsDir, detail: String(err) };
384
+ }
385
+ return wrote
386
+ ? { surface: "skills", action: "written", path: skillsDir, detail: `${wrote}/${SKILL_NAMES.length} skill(s) written` }
387
+ : { surface: "skills", action: "already", path: skillsDir };
388
+ }
389
+
390
+ export function removeClaudeSkills(opts: { cwd: string }): SurfaceOutcome {
391
+ const skillsDir = path.join(opts.cwd, ".claude", "skills");
392
+ let entries: string[];
393
+ try {
394
+ entries = fs.readdirSync(skillsDir);
395
+ } catch {
396
+ return { surface: "skills", action: "absent", path: skillsDir };
397
+ }
398
+ // the `iapeer-memory-` prefix is OUR namespace (the naming promise): every
399
+ // matching directory is ours — including stale names of older versions
400
+ const ours = entries.filter((e) => e.startsWith(SKILL_DIR_PREFIX));
401
+ if (ours.length === 0) {
402
+ return { surface: "skills", action: "absent", path: skillsDir };
403
+ }
404
+ for (const e of ours) {
405
+ guardedRmSync(path.join(skillsDir, e), { recursive: true, force: true });
406
+ }
407
+ // sweep empty containers we may have created (skills/ then .claude/)
408
+ for (const dir of [skillsDir, path.dirname(skillsDir)]) {
409
+ try {
410
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
411
+ } catch {
412
+ break;
413
+ }
414
+ }
415
+ return { surface: "skills", action: "removed", path: skillsDir, detail: `${ours.length} skill dir(s) removed` };
416
+ }
417
+
418
+ // ── the per-peer verbs ───────────────────────────────────────────────────────
419
+
420
+ export function provisionClaudePeer(opts: {
421
+ cwd: string;
422
+ hooksDir: string;
423
+ port: number;
424
+ personality: string;
425
+ }): SurfaceOutcome[] {
426
+ materialiseShims(opts.hooksDir);
427
+ return [
428
+ mergeClaudeHooks({ cwd: opts.cwd, hooksDir: opts.hooksDir }),
429
+ mergeClaudeMcp({ cwd: opts.cwd, port: opts.port, personality: opts.personality }),
430
+ mergeClaudeSkills({ cwd: opts.cwd }),
431
+ ];
432
+ }
433
+
434
+ export function unprovisionClaudePeer(opts: { cwd: string }): SurfaceOutcome[] {
435
+ return [
436
+ removeClaudeHooks({ cwd: opts.cwd }),
437
+ removeClaudeMcp({ cwd: opts.cwd }),
438
+ removeClaudeSkills({ cwd: opts.cwd }),
439
+ ];
440
+ }
441
+
442
+ /** Read-only drift check of one peer's claude surfaces (verify's eye; D3
443
+ * wires it into `verify [--repair]` across the fleet map). */
444
+ export function checkClaudePeer(opts: {
445
+ cwd: string;
446
+ hooksDir: string;
447
+ port: number;
448
+ personality: string;
449
+ }): Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> {
450
+ const out: Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> = [];
451
+
452
+ const settingsPath = path.join(opts.cwd, ".claude", "settings.json");
453
+ const settings = readJsonObject(settingsPath);
454
+ const expected = expectedHookEntries(opts.hooksDir);
455
+ if (settings === "absent" || settings === null) {
456
+ out.push({ surface: "hooks", ok: false, detail: `no readable settings at ${settingsPath}` });
457
+ } else {
458
+ const hooks = (settings.hooks ?? {}) as JsonObject;
459
+ const missing = (["PostToolUse", "SessionStart"] as const).filter((event) => {
460
+ const list = Array.isArray(hooks[event]) ? (hooks[event] as unknown[]) : [];
461
+ const ours = list.filter(isOurHookEntry);
462
+ return !(ours.length === 1 && sameJson(ours[0], expected[event]));
463
+ });
464
+ out.push(
465
+ missing.length === 0
466
+ ? { surface: "hooks", ok: true, detail: "both hook entries in place" }
467
+ : { surface: "hooks", ok: false, detail: `drifted/missing: ${missing.join(", ")}` },
468
+ );
469
+ }
470
+
471
+ const mcp = readJsonObject(path.join(opts.cwd, ".mcp.json"));
472
+ const entry =
473
+ mcp !== "absent" && mcp !== null
474
+ ? ((mcp.mcpServers as JsonObject | undefined) ?? {})[MCP_SERVER_KEY]
475
+ : undefined;
476
+ out.push(
477
+ sameJson(entry, expectedMcpEntry({ port: opts.port, personality: opts.personality }))
478
+ ? { surface: "mcp", ok: true, detail: `${MCP_SERVER_KEY} server entry in place` }
479
+ : { surface: "mcp", ok: false, detail: `mcpServers["${MCP_SERVER_KEY}"] missing or drifted in ${opts.cwd}/.mcp.json` },
480
+ );
481
+
482
+ const skillsDir = path.join(opts.cwd, ".claude", "skills");
483
+ const drifted = SKILL_NAMES.filter((name) => {
484
+ try {
485
+ return fs.readFileSync(path.join(skillsDir, name, "SKILL.md"), "utf-8") !== SKILL_BODIES[name];
486
+ } catch {
487
+ return true;
488
+ }
489
+ });
490
+ out.push(
491
+ drifted.length === 0
492
+ ? { surface: "skills", ok: true, detail: `${SKILL_NAMES.length} skills in place` }
493
+ : { surface: "skills", ok: false, detail: `missing/drifted: ${drifted.join(", ")}` },
494
+ );
495
+
496
+ return out;
497
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Direct codex session surface — per-peer MCP via the PROJECT-LOCAL
3
+ * `<cwd>/.codex/config.toml` (ADR-009 v1.2; требование Артура №3 «глобально
4
+ * не класть» — proven satisfiable by the iapeer smoke 10.06 ~18:40: a
5
+ * project-local `[mcp_servers]` block in a TRUSTED cwd is read by
6
+ * `codex mcp list` AND imported end-to-end into exec sessions; the trust
7
+ * record is written by the core's provision, keyed on the realpath).
8
+ *
9
+ * Block form mirrors the core's PROVEN host-wide `[mcp_servers.iapeer]`
10
+ * block (iapeer src/init/index.ts writeCodexMcpConfig — live on the whole
11
+ * codex fleet), pointed at memoryd:
12
+ *
13
+ * - `url` — the memoryd HTTP-MCP endpoint. TOML carries no env
14
+ * substitution: the LITERAL port is baked at provision time from the
15
+ * host config (IAPEER_MEMORY_MCP_PORT, default 8766); a port change is
16
+ * re-baked by the update/verify sweep;
17
+ * - `default_tools_approval_mode = "approve"` — no per-tool dialog;
18
+ * - `bearer_token_env_var = "IAPEER_BEARER"` — flips codex's authStatus
19
+ * so the tools import (#21532/#4707 workaround); the value is the
20
+ * NON-SECRET dummy the core's launch exports to EVERY codex peer;
21
+ * - `env_http_headers."X-IAPeer-Identity" = "PEER_IDENTITY"` — per-peer
22
+ * identity from the launch env (`codex-<personality>`; memoryd's parser
23
+ * strips the runtime prefix). A codex session started OUTSIDE an iapeer
24
+ * launch carries no PEER_IDENTITY — same unattributed fallback the
25
+ * claude env form has.
26
+ *
27
+ * The file CARRIES FOREIGN CONTENT — the core's native-memory lever writes
28
+ * `[features] memories = false` here, the operator may keep own sections.
29
+ * Merge = line-based section surgery on OUR header namespace only
30
+ * (`[mcp_servers.iapeer-memory]` + its subsections), atomic write; unlike
31
+ * the core's append-if-absent we REPLACE a drifted block (repair duty,
32
+ * требование №2). Hooks/skills for codex are the P5 experiment — MCP is
33
+ * deliberately the only codex surface here.
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import type { SurfaceOutcome } from "./claude.js";
39
+ import { guardedWriteFileSync, guardedUnlinkSync } from "@agfpd/iapeer-memory-core";
40
+
41
+ export const CODEX_MCP_SECTION = "mcp_servers.iapeer-memory";
42
+ const SECTION_HEADER_RE = /^\s*\[/;
43
+ const OUR_HEADER_RE = /^\s*\[mcp_servers\.iapeer-memory(\.[A-Za-z0-9_.-]+)?\]\s*$/;
44
+
45
+ export function codexConfigPath(cwd: string): string {
46
+ return path.join(cwd, ".codex", "config.toml");
47
+ }
48
+
49
+ export function expectedCodexBlock(port: number): string {
50
+ return [
51
+ `[${CODEX_MCP_SECTION}]`,
52
+ `url = "http://127.0.0.1:${port}/mcp"`,
53
+ `default_tools_approval_mode = "approve"`,
54
+ `bearer_token_env_var = "IAPEER_BEARER"`,
55
+ "",
56
+ `[${CODEX_MCP_SECTION}.env_http_headers]`,
57
+ `"X-IAPeer-Identity" = "PEER_IDENTITY"`,
58
+ "",
59
+ ].join("\n");
60
+ }
61
+
62
+ function writeFileAtomic(filePath: string, content: string): void {
63
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
64
+ const tmp = `${filePath}.tmp`;
65
+ guardedWriteFileSync(tmp, content, "utf-8");
66
+ fs.renameSync(tmp, filePath);
67
+ }
68
+
69
+ /** Strip every section under OUR header namespace; foreign lines unchanged. */
70
+ function withoutOurSections(lines: string[]): string[] {
71
+ const kept: string[] = [];
72
+ let inOurs = false;
73
+ for (const line of lines) {
74
+ if (SECTION_HEADER_RE.test(line)) inOurs = OUR_HEADER_RE.test(line);
75
+ if (!inOurs) kept.push(line);
76
+ }
77
+ // drop the trailing blank run our removal may have exposed
78
+ while (kept.length > 0 && kept[kept.length - 1].trim() === "") kept.pop();
79
+ return kept;
80
+ }
81
+
82
+ function hasOurSection(text: string): boolean {
83
+ return text.split("\n").some((l) => OUR_HEADER_RE.test(l));
84
+ }
85
+
86
+ export function mergeCodexMcp(opts: { cwd: string; port: number }): SurfaceOutcome {
87
+ const configPath = codexConfigPath(opts.cwd);
88
+ let text = "";
89
+ try {
90
+ text = fs.readFileSync(configPath, "utf-8");
91
+ } catch {
92
+ // no config yet → create
93
+ }
94
+ const lines = text.length ? text.split("\n") : [];
95
+ const foreign = withoutOurSections(lines);
96
+ const next =
97
+ (foreign.length ? `${foreign.join("\n")}\n\n` : "") + expectedCodexBlock(opts.port);
98
+ if (next === text) {
99
+ return { surface: "mcp", action: "already", path: configPath };
100
+ }
101
+ writeFileAtomic(configPath, next);
102
+ return { surface: "mcp", action: "written", path: configPath };
103
+ }
104
+
105
+ export function removeCodexMcp(opts: { cwd: string }): SurfaceOutcome {
106
+ const configPath = codexConfigPath(opts.cwd);
107
+ let text: string;
108
+ try {
109
+ text = fs.readFileSync(configPath, "utf-8");
110
+ } catch {
111
+ return { surface: "mcp", action: "absent", path: configPath };
112
+ }
113
+ if (!hasOurSection(text)) {
114
+ return { surface: "mcp", action: "absent", path: configPath };
115
+ }
116
+ const foreign = withoutOurSections(text.split("\n"));
117
+ if (foreign.every((l) => l.trim() === "")) {
118
+ // nothing but our block lived here — leave the cwd exactly as found
119
+ guardedUnlinkSync(configPath);
120
+ return { surface: "mcp", action: "removed", path: configPath, detail: "file removed (empty after our block)" };
121
+ }
122
+ writeFileAtomic(configPath, `${foreign.join("\n")}\n`);
123
+ return { surface: "mcp", action: "removed", path: configPath };
124
+ }
125
+
126
+ export function provisionCodexPeer(opts: { cwd: string; port: number }): SurfaceOutcome[] {
127
+ return [mergeCodexMcp(opts)];
128
+ }
129
+
130
+ export function unprovisionCodexPeer(opts: { cwd: string }): SurfaceOutcome[] {
131
+ return [removeCodexMcp(opts)];
132
+ }
133
+
134
+ /** Read-only drift check (verify's eye, D3): the expected block must sit in
135
+ * the project-local config byte-exact. */
136
+ export function checkCodexPeer(opts: {
137
+ cwd: string;
138
+ port: number;
139
+ }): Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> {
140
+ const configPath = codexConfigPath(opts.cwd);
141
+ let text: string;
142
+ try {
143
+ text = fs.readFileSync(configPath, "utf-8");
144
+ } catch {
145
+ return [{ surface: "mcp", ok: false, detail: `no codex config at ${configPath}` }];
146
+ }
147
+ const expected = expectedCodexBlock(opts.port);
148
+ const ok = text.includes(expected);
149
+ return [
150
+ ok
151
+ ? { surface: "mcp", ok: true, detail: `[${CODEX_MCP_SECTION}] block in place` }
152
+ : hasOurSection(text)
153
+ ? { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block drifted in ${configPath}` }
154
+ : { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block missing in ${configPath}` },
155
+ ];
156
+ }