@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/package.json +2 -2
- package/src/cli.ts +12 -0
- package/src/commands/init.ts +129 -38
- package/src/commands/memoryd.ts +18 -1
- package/src/commands/provision-peer.ts +172 -0
- package/src/commands/render.ts +8 -1
- package/src/commands/uninstall.ts +60 -23
- package/src/commands/update.ts +138 -32
- package/src/commands/verify.ts +147 -4
- package/src/fleet.ts +150 -0
- package/src/paths.ts +10 -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/guide-en.ts +1 -1
- package/src/templates/guide-ru.ts +1 -1
- package/src/templates/index.ts +11 -2
- package/src/templates/skills.ts +196 -0
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
|
|
4
|
-
* the three public surfaces (layer-5
|
|
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
|
-
* - `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
90
|
-
existing.
|
|
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
|
-
|
|
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
|
+
}
|