@agfpd/iapeer-memory 0.1.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,199 @@
1
+ /**
2
+ * `iapeer-memory render` — explicit one-shot rendering of the package's
3
+ * artifacts. memoryd renders these continuously at runtime; `render` is the
4
+ * manual/scripted path (init, repair, debugging, tests).
5
+ *
6
+ * render index --agent NAME [--out FILE] [--projects-root DIR]
7
+ * render fragment --agent NAME --peer-cwd DIR [--index FILE]
8
+ * render doctrine --role NAME --peer-cwd DIR --template FILE
9
+ * render guide --source FILE --target IAPEER_DIR
10
+ *
11
+ * `guide` writes the HOST-WIDE fragment — it reaches EVERY peer of the
12
+ * fleet on their next wakes, so the target directory is always EXPLICIT
13
+ * (no default to the production ~/.iapeer; the fleet rollout is a
14
+ * separately sanctioned release step).
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import {
21
+ configFromEnv,
22
+ regenerateVaultIndex,
23
+ renderDoctrine,
24
+ renderPeerFragment,
25
+ resolveAgentName,
26
+ writeHostWideGuideFragment,
27
+ type FragmentEnv,
28
+ } from "@agfpd/iapeer-memory-core";
29
+ import { authorIndexPath, memoryPaths } from "../paths.js";
30
+ import { packageVersion } from "../version.js";
31
+
32
+ class UsageError extends Error {}
33
+
34
+ function parseFlags(argv: string[], spec: Record<string, "value" | "bool">): {
35
+ flags: Record<string, string | boolean>;
36
+ rest: string[];
37
+ } {
38
+ const flags: Record<string, string | boolean> = {};
39
+ const rest: string[] = [];
40
+ for (let i = 0; i < argv.length; i++) {
41
+ const a = argv[i];
42
+ if (!a.startsWith("--")) {
43
+ rest.push(a);
44
+ continue;
45
+ }
46
+ const kind = spec[a];
47
+ if (!kind) throw new UsageError(`unknown flag: ${a}`);
48
+ if (kind === "bool") {
49
+ flags[a] = true;
50
+ } else {
51
+ const v = argv[++i];
52
+ if (v === undefined) throw new UsageError(`${a} requires a value`);
53
+ flags[a] = v;
54
+ }
55
+ }
56
+ return { flags, rest };
57
+ }
58
+
59
+ function renderIndex(argv: string[]): number {
60
+ const { flags } = parseFlags(argv, {
61
+ "--agent": "value",
62
+ "--out": "value",
63
+ "--projects-root": "value",
64
+ });
65
+ const agent = resolveAgentName((flags["--agent"] as string) ?? null);
66
+ if (!agent) throw new UsageError("no agent (pass --agent or set PEER_PERSONALITY)");
67
+
68
+ const config = configFromEnv();
69
+ const paths = memoryPaths();
70
+ const outFile = (flags["--out"] as string) || authorIndexPath(paths, agent);
71
+ const projectsRoot =
72
+ (flags["--projects-root"] as string) ||
73
+ process.env.IAPEER_MEMORY_PROJECTS_ROOT ||
74
+ path.join(process.env.HOME || os.homedir(), "Projects");
75
+
76
+ fs.mkdirSync(path.dirname(outFile), { recursive: true });
77
+ const result = regenerateVaultIndex({
78
+ vault: config.vaultPath,
79
+ agent,
80
+ outFile,
81
+ ctx: { taxonomy: config.taxonomy, ranking: config.ranking },
82
+ projectsRoot,
83
+ });
84
+ console.log(
85
+ `render index: ${outFile} (${result.total} notes` +
86
+ `${result.truncated ? ", truncated" : ""}` +
87
+ `${result.skipped.length ? `, ${result.skipped.length} skipped` : ""})`,
88
+ );
89
+ return 0;
90
+ }
91
+
92
+ function renderFragment(argv: string[]): number {
93
+ const { flags } = parseFlags(argv, {
94
+ "--agent": "value",
95
+ "--peer-cwd": "value",
96
+ "--index": "value",
97
+ });
98
+ const agent = resolveAgentName((flags["--agent"] as string) ?? null);
99
+ if (!agent) throw new UsageError("no agent (pass --agent or set PEER_PERSONALITY)");
100
+ const peerCwd = flags["--peer-cwd"] as string | undefined;
101
+ if (!peerCwd) throw new UsageError("--peer-cwd is required");
102
+
103
+ const config = configFromEnv();
104
+ const paths = memoryPaths();
105
+ const indexAgent = process.env.IAPEER_MEMORY_INDEX_AGENT || "index";
106
+ const indexFile = (flags["--index"] as string) || authorIndexPath(paths, agent);
107
+ if (!fs.existsSync(indexFile)) {
108
+ console.error(
109
+ `render fragment: author index not found at ${indexFile} — ` +
110
+ `run \`iapeer-memory render index --agent ${agent}\` first ` +
111
+ `(the fragment would silently miss its index layer)`,
112
+ );
113
+ return 1;
114
+ }
115
+
116
+ const env: FragmentEnv = {
117
+ agent,
118
+ indexAgent,
119
+ paths: {
120
+ vault: config.vaultPath,
121
+ db: config.index.dbPath,
122
+ config: paths.configFile,
123
+ state: paths.stateDir,
124
+ cache: paths.cacheDir,
125
+ logs: paths.logsDir,
126
+ },
127
+ authorIndexPath: indexFile,
128
+ tagsDictionaryPath: agent === indexAgent ? paths.tagsMirrorPath : undefined,
129
+ };
130
+ const written = renderPeerFragment({ peerCwd, env });
131
+ console.log(`render fragment: ${written}`);
132
+ return 0;
133
+ }
134
+
135
+ function renderDoctrineCmd(argv: string[]): number {
136
+ const { flags } = parseFlags(argv, {
137
+ "--role": "value",
138
+ "--peer-cwd": "value",
139
+ "--template": "value",
140
+ });
141
+ const role = flags["--role"] as string | undefined;
142
+ const peerCwd = flags["--peer-cwd"] as string | undefined;
143
+ const template = flags["--template"] as string | undefined;
144
+ if (!role || !peerCwd || !template) {
145
+ throw new UsageError("--role, --peer-cwd and --template are all required");
146
+ }
147
+ const outcome = renderDoctrine({
148
+ templatePath: template,
149
+ peerCwd,
150
+ version: packageVersion(),
151
+ });
152
+ console.log(`render doctrine [${role}]: ${outcome.action} ${outcome.target}`);
153
+ return outcome.action === "missing-template" ? 1 : 0;
154
+ }
155
+
156
+ function renderGuide(argv: string[]): number {
157
+ const { flags } = parseFlags(argv, {
158
+ "--source": "value",
159
+ "--target": "value",
160
+ });
161
+ const source = flags["--source"] as string | undefined;
162
+ const target = flags["--target"] as string | undefined;
163
+ if (!source || !target) {
164
+ throw new UsageError(
165
+ "--source and --target are both required (the host-wide guide reaches " +
166
+ "the whole fleet — the target is never implicit)",
167
+ );
168
+ }
169
+ const text = fs.readFileSync(source, "utf-8");
170
+ const written = writeHostWideGuideFragment(target, text);
171
+ console.log(`render guide: ${written}`);
172
+ return 0;
173
+ }
174
+
175
+ export function cmdRender(argv: string[]): number {
176
+ const [sub, ...rest] = argv;
177
+ try {
178
+ switch (sub) {
179
+ case "index":
180
+ return renderIndex(rest);
181
+ case "fragment":
182
+ return renderFragment(rest);
183
+ case "doctrine":
184
+ return renderDoctrineCmd(rest);
185
+ case "guide":
186
+ return renderGuide(rest);
187
+ default:
188
+ throw new UsageError(
189
+ `unknown render target: ${sub ?? "(none)"} (expected index | fragment | doctrine | guide)`,
190
+ );
191
+ }
192
+ } catch (err) {
193
+ if (err instanceof UsageError) {
194
+ console.error(`iapeer-memory render: ${err.message}`);
195
+ return 2;
196
+ }
197
+ throw err;
198
+ }
199
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * `iapeer-memory status` — read-only diagnostics of the whole chain
3
+ * (ADR-009: the status surface must diagnose a socket without a system).
4
+ * Aggregates: verify checks (NO repair) + slot declaration + a live TCP
5
+ * probe of the MCP endpoint + the inbox load. Never mutates anything;
6
+ * exit 1 when something needs attention.
7
+ */
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import {
12
+ getTaxonomy,
13
+ isLocaleId,
14
+ prepareSqliteRuntime,
15
+ } from "@agfpd/iapeer-memory-core";
16
+ import { memoryPaths } from "../paths.js";
17
+ import { readSlot } from "../slot.js";
18
+ import { packageVersion } from "../version.js";
19
+ import { runVerify } from "./verify.js";
20
+
21
+ /**
22
+ * The search-pipeline line — VISIBLE degradation is an acceptance condition
23
+ * (boris, P3a): a host configured for vector search that silently falls
24
+ * back must say so here and why.
25
+ *
26
+ * Source of truth ladder: (1) the LIVE pipeline as reported by the running
27
+ * memoryd (a real vault_search call over MCP — per-component statuses, the
28
+ * same object every search returns); (2) when memoryd is down — the static
29
+ * configuration view + the sqlite runtime probe. P3c live-smoke fact: in
30
+ * the compiled binary the sqlite-vec dylib does not resolve from /$bunfs —
31
+ * memoryd logs it once and vector search continues BRUTE-FORCE (semantics
32
+ * intact, slower on large vaults); the live pipeline still says
33
+ * embedding: ok, which is the truthful state.
34
+ */
35
+ export function searchPipelineLine(env: Record<string, string | undefined>): string {
36
+ const embeddingConfigured = Boolean(env.IAPEER_MEMORY_EMBEDDING_ENDPOINT);
37
+ const rerankerConfigured = Boolean(env.IAPEER_MEMORY_RERANKER_ENDPOINT);
38
+ if (!embeddingConfigured) {
39
+ return "BM25-only (no embedding endpoint configured — a valid zero-config state)";
40
+ }
41
+ const vec = prepareSqliteRuntime();
42
+ return (
43
+ `hybrid configured: BM25 + embeddings${rerankerConfigured ? " + reranker" : ""}; ` +
44
+ (vec.available
45
+ ? `vec index runtime ok (${vec.dylibPath})`
46
+ : `vec index unavailable (${vec.reason}) — vector search runs brute-force`)
47
+ );
48
+ }
49
+
50
+ /** Live pipeline from the running memoryd — the same per-component statuses
51
+ * every vault_search returns. Null when memoryd is unreachable. */
52
+ export async function probeSearchPipeline(port: number): Promise<string | null> {
53
+ try {
54
+ const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
55
+ method: "POST",
56
+ headers: {
57
+ "content-type": "application/json",
58
+ accept: "application/json, text/event-stream",
59
+ "X-IAPeer-Identity": "claude-status-probe",
60
+ },
61
+ body: JSON.stringify({
62
+ jsonrpc: "2.0",
63
+ id: 1,
64
+ method: "tools/call",
65
+ params: { name: "vault_search", arguments: { query: "status probe" } },
66
+ }),
67
+ signal: AbortSignal.timeout(8000),
68
+ });
69
+ const text = await res.text();
70
+ // streamable-http frames the JSON as an SSE `data:` line
71
+ const dataLine = text.split("\n").find((l) => l.startsWith("data:"));
72
+ const payload = JSON.parse(dataLine ? dataLine.slice(5) : text) as {
73
+ result?: { structuredContent?: { pipeline?: Record<string, unknown> } };
74
+ };
75
+ const pipeline = payload.result?.structuredContent?.pipeline;
76
+ if (!pipeline) return null;
77
+ const parts = ["bm25", "embedding", "reranker", "graph"]
78
+ .filter((k) => k in pipeline)
79
+ .map((k) => `${k}: ${String(pipeline[k])}`);
80
+ return `live pipeline — ${parts.join(", ")}`;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ async function probeMcp(port: number): Promise<{ line: string; alive: boolean }> {
87
+ try {
88
+ const res = await fetch(`http://127.0.0.1:${port}/mcp`, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json" },
91
+ body: "{}",
92
+ signal: AbortSignal.timeout(1500),
93
+ });
94
+ // ANY http response means memoryd is listening (a real MCP handshake
95
+ // needs a session — this is a liveness probe, not a protocol check).
96
+ return { line: `listening on ${port} (http ${res.status})`, alive: true };
97
+ } catch {
98
+ return { line: `nothing listening on ${port}`, alive: false };
99
+ }
100
+ }
101
+
102
+ export async function cmdStatus(argv: string[]): Promise<number> {
103
+ if (argv.length) {
104
+ console.error(`iapeer-memory status: unknown flag: ${argv[0]}`);
105
+ return 2;
106
+ }
107
+ const paths = memoryPaths();
108
+ const version = packageVersion();
109
+ console.log(`iapeer-memory v${version}`);
110
+
111
+ const results = runVerify({ repair: false });
112
+ const width = Math.max(...results.map((r) => r.name.length), 12);
113
+ for (const r of results) {
114
+ const mark =
115
+ r.status === "ok" ? "ok " : r.status === "skip" ? "skip" : "FAIL";
116
+ console.log(`${mark} ${r.name.padEnd(width)} ${r.detail}`);
117
+ }
118
+
119
+ const slot = readSlot(paths.slotPath);
120
+ console.log(
121
+ ` ${"slot-file".padEnd(width)} ` +
122
+ (slot
123
+ ? `${slot.provider} v${slot.version} (registered ${slot.registeredAt})`
124
+ : "empty"),
125
+ );
126
+
127
+ const port = Number(process.env.IAPEER_MEMORY_MCP_PORT || "") || 8766;
128
+ const mcp = await probeMcp(port);
129
+ console.log(` ${"mcp-endpoint".padEnd(width)} ${mcp.line}`);
130
+ // The live pipeline is only probed when the endpoint is alive — a dead
131
+ // port already told us everything (and the static view says the rest).
132
+ const livePipeline = mcp.alive ? await probeSearchPipeline(port) : null;
133
+ console.log(
134
+ ` ${"search".padEnd(width)} ` +
135
+ (livePipeline ?? `${searchPipelineLine(process.env)} (memoryd down — static view)`),
136
+ );
137
+
138
+ const vault = process.env.IAPEER_MEMORY_VAULT_PATH ?? "";
139
+ const localeRaw = process.env.IAPEER_MEMORY_LOCALE || "en";
140
+ if (vault && isLocaleId(localeRaw)) {
141
+ const inboxDir = path.join(vault, getTaxonomy(localeRaw).folders.inbox);
142
+ let count = 0;
143
+ try {
144
+ count = fs.readdirSync(inboxDir).filter((f) => f.endsWith(".md")).length;
145
+ } catch {
146
+ count = -1;
147
+ }
148
+ console.log(
149
+ ` ${"inbox".padEnd(width)} ` +
150
+ (count < 0 ? `folder missing (${inboxDir})` : `${count} draft(s) awaiting the Index`),
151
+ );
152
+ }
153
+
154
+ return results.some((r) => r.status === "fail") ? 1 : 0;
155
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `iapeer-memory uninstall [--keep-binary]` — remove the system from the
3
+ * host. SYMMETRY OBLIGATION of the memory-slot contract: the provider that
4
+ * writes the slot declaration removes it.
5
+ *
6
+ * What it removes: slot declaration (own only — a foreign slot is refused),
7
+ * the compiled binary. What it deliberately KEEPS: the vault (user data),
8
+ * the package config (operator-owned), state/cache (cheap to rebuild, may
9
+ * hold migrate backups!). What lands in P3c: notifier deregistration +
10
+ * memoryd stop (the watcher owns the process lifecycle).
11
+ *
12
+ * Native auto-memory of the fleet is NOT restored (contract decision,
13
+ * c968219): silent re-enabling would quietly resurrect split memory across
14
+ * the fleet — worse than an honest visible degradation. The restore lever
15
+ * is the core's: `iapeer native-memory on --all` (manual decision).
16
+ */
17
+
18
+ import fs from "node:fs";
19
+ import { memoryPaths } from "../paths.js";
20
+ import { removeBinary } from "../binary.js";
21
+ import { removeSlot } from "../slot.js";
22
+ import { unregisterWatcher, WATCHER_TRIGGER_ID } from "../watcher.js";
23
+
24
+ /**
25
+ * Owner verification before signalling: the process command line must look
26
+ * like OUR daemon (`… memoryd`, launched via the CLI/launcher). A pid can
27
+ * be recycled by the OS between memoryd's crash and uninstall — verifying
28
+ * the command closes the "signal a stranger" class. Probe failure → false
29
+ * (never signal on uncertainty).
30
+ */
31
+ export function pidLooksLikeOurs(pid: number): boolean {
32
+ try {
33
+ const proc = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)], {
34
+ stdout: "pipe",
35
+ stderr: "pipe",
36
+ });
37
+ if (proc.exitCode !== 0) return false;
38
+ const command = proc.stdout.toString().trim();
39
+ return command.includes("memoryd");
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Stop memoryd by its pid file. DEFENSIVE KILL CONTRACT (incident 10.06,
47
+ * boris): a signal is sent ONLY to a positive, live pid whose command line
48
+ * is VERIFIED to be ours — a recycled/foreign pid in a stale file must
49
+ * never be signalled; never a group/negative pid by construction. Shared
50
+ * by uninstall (stop) and update (managed restart: SIGTERM → the notifier
51
+ * watcher relaunches via the launcher with the fresh binary, ADR-010).
52
+ */
53
+ export function stopMemorydByPidFile(pidPath: string): string {
54
+ let line = "not running (no pid file)";
55
+ try {
56
+ const pid = Number(fs.readFileSync(pidPath, "utf-8").trim());
57
+ if (Number.isInteger(pid) && pid > 1) {
58
+ if (!pidLooksLikeOurs(pid)) {
59
+ line = `pid file points at a non-memoryd process (${pid}) — NOT signalling; stale file removed`;
60
+ } else {
61
+ try {
62
+ process.kill(pid, "SIGTERM");
63
+ line = `SIGTERM sent to pid ${pid} (command verified)`;
64
+ } catch {
65
+ line = `stale pid file (process ${pid} gone) — removed`;
66
+ }
67
+ }
68
+ }
69
+ fs.unlinkSync(pidPath);
70
+ } catch {
71
+ // no pid file — nothing to stop
72
+ }
73
+ return line;
74
+ }
75
+
76
+ export function cmdUninstall(argv: string[]): number {
77
+ let keepBinary = false;
78
+ let iapeerBin = "iapeer";
79
+ for (let i = 0; i < argv.length; i++) {
80
+ const a = argv[i];
81
+ if (a === "--keep-binary") keepBinary = true;
82
+ else if (a === "--iapeer-bin") iapeerBin = argv[++i] ?? "iapeer";
83
+ else {
84
+ console.error(`iapeer-memory uninstall: unknown flag: ${a}`);
85
+ return 2;
86
+ }
87
+ }
88
+
89
+ const paths = memoryPaths();
90
+ let failed = false;
91
+
92
+ const slot = removeSlot(paths.slotPath);
93
+ if (slot === "refused-foreign") {
94
+ console.log("slot : held by a FOREIGN provider — left intact");
95
+ failed = true;
96
+ } else {
97
+ console.log(`slot : ${slot === "removed" ? "declaration removed" : "already absent"}`);
98
+ }
99
+
100
+ // watcher: best-effort unregister (not-found is soft on the notifier side;
101
+ // the teaching reply goes to the index session, not here).
102
+ const unreg = unregisterWatcher({ iapeerBin });
103
+ console.log(
104
+ `watcher : ${
105
+ unreg.ok
106
+ ? `unregister sent for ${WATCHER_TRIGGER_ID}`
107
+ : `unregister not sent (${unreg.detail}) — remove the trigger manually via the watcher peer`
108
+ }`,
109
+ );
110
+
111
+ console.log(`memoryd : ${stopMemorydByPidFile(paths.pidPath)}`);
112
+
113
+ if (keepBinary) {
114
+ console.log(`binary : kept (${paths.binaryPath})`);
115
+ } else {
116
+ console.log(`binary : ${removeBinary(paths.binaryPath) === "removed" ? `removed ${paths.binaryPath}` : "already absent"}`);
117
+ }
118
+
119
+ console.log(`vault : NOT touched (user data)`);
120
+ console.log(`config : NOT touched (${paths.configFile} — operator-owned)`);
121
+ console.log(
122
+ "native : fleet auto-memory stays OFF — restoring it is a manual decision; core lever: iapeer native-memory on --all",
123
+ );
124
+
125
+ return failed ? 1 : 0;
126
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `iapeer-memory update` — one command, every surface, deterministic
3
+ * (ADR-010; closes the «daemon running a deleted snapshot» defect class).
4
+ *
5
+ * Run from SOURCE (`npx @agfpd/iapeer-memory@latest update`): the fresh
6
+ * npx snapshot recompiles the stable binary; everything downstream follows
7
+ * the new code. Surfaces, in order:
8
+ *
9
+ * 1. binary — recompile `~/.local/bin/iapeer-memory` (from the
10
+ * installed binary itself: honest skip + npx hint);
11
+ * 2. templates — re-materialise (package-owned, bytes-compare);
12
+ * 3. doctrines — re-render every role from the roles manifest with the
13
+ * fresh version marker; roles pick it up on their next
14
+ * cold wake (ADR-007), no restarts;
15
+ * 4. slot — re-declare with the new version (contract obligation);
16
+ * 5. launcher — regenerate (in case the binary path moved);
17
+ * 6. memoryd — MANAGED restart: verified SIGTERM via the pid file →
18
+ * the notifier watcher relaunches through the launcher
19
+ * with the NEW binary (exit-detect is unconditional —
20
+ * notifier fact). Not running → nothing to do;
21
+ * 7. plugin — the harness's native plugin update flow (printed hint;
22
+ * the package never reaches into harness internals).
23
+ *
24
+ * Idempotent: same version re-run → identical/no-op on every surface.
25
+ */
26
+
27
+ import {
28
+ isLocaleId,
29
+ renderDoctrine,
30
+ type LocaleId,
31
+ } from "@agfpd/iapeer-memory-core";
32
+ import { installBinary } from "../binary.js";
33
+ import { memoryPaths } from "../paths.js";
34
+ import { readRolesManifest } from "../roles.js";
35
+ import { writeSlot } from "../slot.js";
36
+ import { materialiseTemplates } from "../templates/index.js";
37
+ import { packageVersion } from "../version.js";
38
+ import { writeLauncherScript } from "../watcher.js";
39
+ import { stopMemorydByPidFile } from "./uninstall.js";
40
+
41
+ export function cmdUpdate(argv: string[]): number {
42
+ let skipBinary = false;
43
+ for (const a of argv) {
44
+ if (a === "--skip-binary") skipBinary = true;
45
+ else {
46
+ console.error(`iapeer-memory update: unknown flag: ${a}`);
47
+ return 2;
48
+ }
49
+ }
50
+
51
+ const paths = memoryPaths();
52
+ const version = packageVersion();
53
+ const localeRaw = process.env.IAPEER_MEMORY_LOCALE || "en";
54
+ if (!isLocaleId(localeRaw)) {
55
+ console.error(`iapeer-memory update: unknown locale "${localeRaw}"`);
56
+ return 2;
57
+ }
58
+ const locale: LocaleId = localeRaw;
59
+ let failures = 0;
60
+ const step = (name: string, detail: string, ok = true): void => {
61
+ if (!ok) failures++;
62
+ console.log(`${ok ? "ok " : "FAIL"} ${name.padEnd(10)} ${detail}`);
63
+ };
64
+
65
+ console.log(`iapeer-memory update → v${version}`);
66
+
67
+ // 1. binary
68
+ if (skipBinary) {
69
+ step("binary", "skipped (--skip-binary)");
70
+ } else {
71
+ const bin = installBinary({ outPath: paths.binaryPath });
72
+ step(
73
+ "binary",
74
+ bin.action === "compiled"
75
+ ? `recompiled ${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB)`
76
+ : bin.action === "skipped-compiled"
77
+ ? "running FROM the installed binary — recompile via: npx @agfpd/iapeer-memory@latest update"
78
+ : `compile failed — ${bin.detail}`,
79
+ bin.action !== "failed",
80
+ );
81
+ }
82
+
83
+ // 2. templates (package-owned)
84
+ const tmpl = materialiseTemplates({ templatesDir: paths.templatesDir, locale });
85
+ step("templates", `${tmpl.written.length} written, ${tmpl.identical.length} identical`);
86
+
87
+ // 3. role doctrines (version marker follows the package — ADR-010)
88
+ const manifest = readRolesManifest(paths.rolesManifestPath);
89
+ if (!manifest || manifest.roles.length === 0) {
90
+ step("doctrines", "no roles manifest — init has not run (nothing to re-render)");
91
+ } else {
92
+ const outcomes = manifest.roles.map((r) => ({
93
+ role: r.role,
94
+ ...renderDoctrine({ templatePath: r.template, peerCwd: r.peerCwd, version }),
95
+ }));
96
+ const missing = outcomes.filter((o) => o.action === "missing-template");
97
+ step(
98
+ "doctrines",
99
+ outcomes
100
+ .map((o) => `${o.role}: ${o.action}`)
101
+ .join(", ") + ` (v${version}; roles pick it up on next cold wake)`,
102
+ missing.length === 0,
103
+ );
104
+ }
105
+
106
+ // 4. slot version (contract obligation)
107
+ const slot = writeSlot({
108
+ slotPath: paths.slotPath,
109
+ version,
110
+ heartbeat: paths.heartbeatPath,
111
+ });
112
+ step(
113
+ "slot",
114
+ slot.action === "refused-foreign"
115
+ ? `slot held by foreign provider "${slot.existing?.provider}" — not ours to update`
116
+ : `${slot.action} (v${version})`,
117
+ slot.action !== "refused-foreign",
118
+ );
119
+
120
+ // 5. launcher
121
+ step("launcher", writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath }));
122
+
123
+ // 6. memoryd managed restart (the watcher relaunches with the new binary)
124
+ step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
125
+
126
+ // 7. plugin — harness-owned surface
127
+ console.log(
128
+ " plugin update via the harness's native plugin flow " +
129
+ "(claude/codex plugin manager); the package never reaches into harness internals",
130
+ );
131
+
132
+ console.log(
133
+ failures
134
+ ? `\nupdate finished with ${failures} problem(s) — iapeer-memory verify --repair`
135
+ : "\nupdate complete — confirm: iapeer-memory verify",
136
+ );
137
+ return failures ? 1 : 0;
138
+ }