@agfpd/iapeer-memory 0.1.13 → 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/package.json +2 -2
- package/src/cli.ts +12 -0
- package/src/commands/init.ts +105 -38
- package/src/commands/provision-peer.ts +172 -0
- package/src/commands/uninstall.ts +60 -23
- package/src/commands/update.ts +118 -47
- package/src/commands/verify.ts +110 -5
- package/src/fleet.ts +72 -7
- package/src/paths.ts +5 -0
- package/src/slot.ts +67 -22
- package/src/surfaces/claude.ts +494 -0
- package/src/surfaces/codex.ts +155 -0
- package/src/surfaces/lock.ts +72 -0
- package/src/surfaces/sweep.ts +170 -0
- package/src/templates/skills.ts +196 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
|
|
40
|
+
export const CODEX_MCP_SECTION = "mcp_servers.iapeer-memory";
|
|
41
|
+
const SECTION_HEADER_RE = /^\s*\[/;
|
|
42
|
+
const OUR_HEADER_RE = /^\s*\[mcp_servers\.iapeer-memory(\.[A-Za-z0-9_.-]+)?\]\s*$/;
|
|
43
|
+
|
|
44
|
+
export function codexConfigPath(cwd: string): string {
|
|
45
|
+
return path.join(cwd, ".codex", "config.toml");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function expectedCodexBlock(port: number): string {
|
|
49
|
+
return [
|
|
50
|
+
`[${CODEX_MCP_SECTION}]`,
|
|
51
|
+
`url = "http://127.0.0.1:${port}/mcp"`,
|
|
52
|
+
`default_tools_approval_mode = "approve"`,
|
|
53
|
+
`bearer_token_env_var = "IAPEER_BEARER"`,
|
|
54
|
+
"",
|
|
55
|
+
`[${CODEX_MCP_SECTION}.env_http_headers]`,
|
|
56
|
+
`"X-IAPeer-Identity" = "PEER_IDENTITY"`,
|
|
57
|
+
"",
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeFileAtomic(filePath: string, content: string): void {
|
|
62
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
63
|
+
const tmp = `${filePath}.tmp`;
|
|
64
|
+
fs.writeFileSync(tmp, content, "utf-8");
|
|
65
|
+
fs.renameSync(tmp, filePath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Strip every section under OUR header namespace; foreign lines unchanged. */
|
|
69
|
+
function withoutOurSections(lines: string[]): string[] {
|
|
70
|
+
const kept: string[] = [];
|
|
71
|
+
let inOurs = false;
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
if (SECTION_HEADER_RE.test(line)) inOurs = OUR_HEADER_RE.test(line);
|
|
74
|
+
if (!inOurs) kept.push(line);
|
|
75
|
+
}
|
|
76
|
+
// drop the trailing blank run our removal may have exposed
|
|
77
|
+
while (kept.length > 0 && kept[kept.length - 1].trim() === "") kept.pop();
|
|
78
|
+
return kept;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasOurSection(text: string): boolean {
|
|
82
|
+
return text.split("\n").some((l) => OUR_HEADER_RE.test(l));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function mergeCodexMcp(opts: { cwd: string; port: number }): SurfaceOutcome {
|
|
86
|
+
const configPath = codexConfigPath(opts.cwd);
|
|
87
|
+
let text = "";
|
|
88
|
+
try {
|
|
89
|
+
text = fs.readFileSync(configPath, "utf-8");
|
|
90
|
+
} catch {
|
|
91
|
+
// no config yet → create
|
|
92
|
+
}
|
|
93
|
+
const lines = text.length ? text.split("\n") : [];
|
|
94
|
+
const foreign = withoutOurSections(lines);
|
|
95
|
+
const next =
|
|
96
|
+
(foreign.length ? `${foreign.join("\n")}\n\n` : "") + expectedCodexBlock(opts.port);
|
|
97
|
+
if (next === text) {
|
|
98
|
+
return { surface: "mcp", action: "already", path: configPath };
|
|
99
|
+
}
|
|
100
|
+
writeFileAtomic(configPath, next);
|
|
101
|
+
return { surface: "mcp", action: "written", path: configPath };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function removeCodexMcp(opts: { cwd: string }): SurfaceOutcome {
|
|
105
|
+
const configPath = codexConfigPath(opts.cwd);
|
|
106
|
+
let text: string;
|
|
107
|
+
try {
|
|
108
|
+
text = fs.readFileSync(configPath, "utf-8");
|
|
109
|
+
} catch {
|
|
110
|
+
return { surface: "mcp", action: "absent", path: configPath };
|
|
111
|
+
}
|
|
112
|
+
if (!hasOurSection(text)) {
|
|
113
|
+
return { surface: "mcp", action: "absent", path: configPath };
|
|
114
|
+
}
|
|
115
|
+
const foreign = withoutOurSections(text.split("\n"));
|
|
116
|
+
if (foreign.every((l) => l.trim() === "")) {
|
|
117
|
+
// nothing but our block lived here — leave the cwd exactly as found
|
|
118
|
+
fs.unlinkSync(configPath);
|
|
119
|
+
return { surface: "mcp", action: "removed", path: configPath, detail: "file removed (empty after our block)" };
|
|
120
|
+
}
|
|
121
|
+
writeFileAtomic(configPath, `${foreign.join("\n")}\n`);
|
|
122
|
+
return { surface: "mcp", action: "removed", path: configPath };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function provisionCodexPeer(opts: { cwd: string; port: number }): SurfaceOutcome[] {
|
|
126
|
+
return [mergeCodexMcp(opts)];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function unprovisionCodexPeer(opts: { cwd: string }): SurfaceOutcome[] {
|
|
130
|
+
return [removeCodexMcp(opts)];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Read-only drift check (verify's eye, D3): the expected block must sit in
|
|
134
|
+
* the project-local config byte-exact. */
|
|
135
|
+
export function checkCodexPeer(opts: {
|
|
136
|
+
cwd: string;
|
|
137
|
+
port: number;
|
|
138
|
+
}): Array<{ surface: SurfaceOutcome["surface"]; ok: boolean; detail: string }> {
|
|
139
|
+
const configPath = codexConfigPath(opts.cwd);
|
|
140
|
+
let text: string;
|
|
141
|
+
try {
|
|
142
|
+
text = fs.readFileSync(configPath, "utf-8");
|
|
143
|
+
} catch {
|
|
144
|
+
return [{ surface: "mcp", ok: false, detail: `no codex config at ${configPath}` }];
|
|
145
|
+
}
|
|
146
|
+
const expected = expectedCodexBlock(opts.port);
|
|
147
|
+
const ok = text.includes(expected);
|
|
148
|
+
return [
|
|
149
|
+
ok
|
|
150
|
+
? { surface: "mcp", ok: true, detail: `[${CODEX_MCP_SECTION}] block in place` }
|
|
151
|
+
: hasOurSection(text)
|
|
152
|
+
? { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block drifted in ${configPath}` }
|
|
153
|
+
: { surface: "mcp", ok: false, detail: `[${CODEX_MCP_SECTION}] block missing in ${configPath}` },
|
|
154
|
+
];
|
|
155
|
+
}
|