@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.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # package
2
+
3
+ npm-фасад iapeer-фундамента: CLI (init/verify/update/fm-update/render), регистрация memoryd-watcher у notifier, рендер доктрин ролей, шаблоны vault (ADR-009/010). Наполняется после core.
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # npx @agfpd/iapeer-memory bootstrap.
3
+ #
4
+ # Runs the iapeer-memory CLI from the package SOURCE via bun — the package is
5
+ # TypeScript-only, no precompiled JS is shipped (ADR-003; same pattern as the
6
+ # @agfpd/iapeer foundation bin). All args pass straight through to the CLI;
7
+ # a BARE invocation prints usage — provisioning is always explicit:
8
+ # `npx @agfpd/iapeer-memory init`.
9
+ set -euo pipefail
10
+
11
+ # Resolve the real path of this script BEFORE computing the package root.
12
+ # npm/npx expose the bin as a SYMLINK at node_modules/.bin/iapeer-memory →
13
+ # ../@agfpd/iapeer-memory/bin/iapeer-memory; without resolving it, dirname/..
14
+ # lands on node_modules (not the package dir) and src/cli.ts is not found.
15
+ SOURCE="${BASH_SOURCE[0]}"
16
+ while [ -L "$SOURCE" ]; do
17
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
18
+ SOURCE="$(readlink "$SOURCE")"
19
+ [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE"
20
+ done
21
+ PKG_ROOT="$(cd -P "$(dirname "$SOURCE")/.." && pwd)"
22
+ CLI="$PKG_ROOT/src/cli.ts"
23
+
24
+ if ! command -v bun >/dev/null 2>&1; then
25
+ echo "iapeer-memory: 'bun' is required on PATH to run the CLI — install it from https://bun.sh" >&2
26
+ exit 1
27
+ fi
28
+
29
+ exec bun "$CLI" "$@"
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@agfpd/iapeer-memory",
3
+ "version": "0.1.1",
4
+ "description": "iapeer-memory — peer memory for the iapeer ecosystem: vault, memoryd (index/search/MCP-http), layer-5 context fragments, role doctrines. The package IS the system; the claude/codex plugins are thin session sockets (docs/10-distribution.md, ADR-009).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "iapeer-memory": "bin/iapeer-memory"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "bin",
13
+ "tsconfig.json"
14
+ ],
15
+ "scripts": {
16
+ "test": "bun test",
17
+ "typecheck": "tsc --noEmit",
18
+ "preversion": "bun run test && bun run typecheck",
19
+ "version": "bun src/sync-versions.ts && git add -A ..",
20
+ "release": "npm version patch --workspaces-update=false && npm run release:finish",
21
+ "release:minor": "npm version minor --workspaces-update=false && npm run release:finish",
22
+ "release:major": "npm version major --workspaces-update=false && npm run release:finish",
23
+ "release:finish": "V=$(bun -e 'console.log(JSON.parse(require(\"fs\").readFileSync(\"package.json\",\"utf-8\")).version)') && git -C .. commit -m \"$V\" && git -C .. tag \"v$V\" && (cd ../core && npm publish) && npm publish && git push --follow-tags",
24
+ "prepublishOnly": "test -z \"$(git status --porcelain)\" || (echo 'release: working tree is dirty — commit or stash before release' >&2 && exit 1)"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "@agfpd/iapeer-memory-core": "0.1.1"
31
+ },
32
+ "devDependencies": {
33
+ "@types/bun": "^1.2.0",
34
+ "typescript": "^5.7.0"
35
+ }
36
+ }
package/src/binary.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Stable CLI binary — `bun build --compile` into `~/.local/bin/iapeer-memory`
3
+ * (the @agfpd/iapeer precedent: notifier/telegram runtime packages ship the
4
+ * same Mach-O form). WHY a compiled binary and not the npx cache: hooks,
5
+ * the notifier watcher launcher and the shims need a path that survives
6
+ * npx-cache eviction — the proven MergeMind production defect class
7
+ * («daemons executing a deleted cache snapshot») is closed by owning a
8
+ * stable artifact (ADR-010).
9
+ *
10
+ * Facts from the P3a compile check (live, bun 1.3.13):
11
+ * - the whole workspace (package + core) bundles into one ~61MB binary;
12
+ * - runtime fs reads of package.json do NOT work inside the binary
13
+ * (`/$bunfs/`) — versions are embedded via static json imports;
14
+ * - bun:sqlite works compiled (memoryd ran live: DB created, MCP up,
15
+ * heartbeat, clean SIGTERM); the sqlite-vec extension path is exercised
16
+ * only with an embedding endpoint configured and degrades to BM25-only
17
+ * with a logged reason — re-checked at the P3c live smoke.
18
+ *
19
+ * Recompilation needs SOURCES: a compiled binary cannot rebuild itself from
20
+ * /$bunfs — `install-binary` run from the installed binary reports
21
+ * `skipped-compiled` (re-install goes through npx, which runs from source).
22
+ */
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ export function isCompiledRuntime(): boolean {
28
+ return import.meta.url.includes("/$bunfs/");
29
+ }
30
+
31
+ export type InstallBinaryOutcome =
32
+ | { action: "compiled"; outPath: string; bytes: number }
33
+ | { action: "skipped-compiled"; outPath: string }
34
+ | { action: "failed"; outPath: string; detail: string };
35
+
36
+ export function installBinary(opts: { outPath: string }): InstallBinaryOutcome {
37
+ const { outPath } = opts;
38
+ if (isCompiledRuntime()) {
39
+ return { action: "skipped-compiled", outPath };
40
+ }
41
+
42
+ const cliPath = new URL("./cli.ts", import.meta.url).pathname;
43
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
44
+ const tmp = path.join(
45
+ path.dirname(outPath),
46
+ `.${path.basename(outPath)}.build.tmp`,
47
+ );
48
+
49
+ const proc = Bun.spawnSync(
50
+ [process.execPath, "build", "--compile", cliPath, "--outfile", tmp],
51
+ { stdout: "pipe", stderr: "pipe" },
52
+ );
53
+ if (proc.exitCode !== 0 || !fs.existsSync(tmp)) {
54
+ try {
55
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
56
+ } catch {
57
+ // best effort
58
+ }
59
+ return {
60
+ action: "failed",
61
+ outPath,
62
+ detail: proc.stderr.toString().trim() || `bun build exited ${proc.exitCode}`,
63
+ };
64
+ }
65
+
66
+ fs.chmodSync(tmp, 0o755);
67
+ fs.renameSync(tmp, outPath); // atomic swap — safe over a running binary on macOS
68
+ return { action: "compiled", outPath, bytes: fs.statSync(outPath).size };
69
+ }
70
+
71
+ export function removeBinary(outPath: string): "removed" | "absent" {
72
+ if (!fs.existsSync(outPath)) return "absent";
73
+ fs.unlinkSync(outPath);
74
+ return "removed";
75
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * iapeer-memory CLI — the package facade over core (docs/10-distribution.md).
4
+ *
5
+ * The package IS the system (ADR-009): everything live — memoryd with the
6
+ * MCP-http endpoint, fragment/index/doctrine rendering, frontmatter tooling,
7
+ * install/verify/update — enters through this binary. The claude/codex
8
+ * plugins are thin session sockets that call back into it.
9
+ *
10
+ * Boot order: resolve the path namespace → load the package config file
11
+ * (env precedence: flags > process env > config file > defaults,
12
+ * `config-env.ts`) → dispatch. Exit codes: 0 ok, 1 command failed /
13
+ * verify found problems, 2 usage error or not-yet-implemented stage.
14
+ */
15
+
16
+ import { loadConfigFile } from "./config-env.js";
17
+ import { memoryPaths } from "./paths.js";
18
+ import { packageVersion } from "./version.js";
19
+ import { cmdFmUpdate } from "./commands/fm-update.js";
20
+ import { cmdHook } from "./commands/hook.js";
21
+ import { cmdInit } from "./commands/init.js";
22
+ import { cmdInstallBinary } from "./commands/install-binary.js";
23
+ import { cmdMemoryd } from "./commands/memoryd.js";
24
+ import { cmdMigrate } from "./commands/migrate.js";
25
+ import { cmdRender } from "./commands/render.js";
26
+ import { cmdStatus } from "./commands/status.js";
27
+ import { cmdUninstall } from "./commands/uninstall.js";
28
+ import { cmdUpdate } from "./commands/update.js";
29
+ import { cmdVerify } from "./commands/verify.js";
30
+
31
+ export const USAGE = `iapeer-memory — peer memory for the iapeer ecosystem
32
+
33
+ Usage: iapeer-memory <command> [options]
34
+
35
+ Commands:
36
+ init provision the system on this host (vault, config,
37
+ role peers, memoryd registration); idempotent
38
+ uninstall [--keep-binary] remove the system: slot declaration + binary
39
+ (vault and config are kept — user-owned)
40
+ status read-only diagnostics: verify + slot + MCP probe
41
+ + inbox load
42
+ verify [--repair] check (and repair) the live surfaces: config,
43
+ memory-provider slot, memoryd heartbeat, role
44
+ doctrine versions
45
+ update [--skip-binary] update every surface (ADR-010): binary recompile,
46
+ templates, doctrine re-render, slot version,
47
+ launcher, managed memoryd restart
48
+ install-binary [--out P] compile the stable CLI binary (~/.local/bin) —
49
+ init step / repair path; needs package sources
50
+ fm-update [ops] FILE... structural frontmatter edits + attribution stamp
51
+ migrate --source DIR move harness auto-memory into the vault
52
+ (dry-run by default; --apply to execute)
53
+ render index|fragment|doctrine|guide
54
+ render one artifact explicitly (memoryd does this
55
+ continuously; render is the manual/scripted path)
56
+ memoryd run the daemon in the foreground (stdout = event
57
+ lines; supervised by a notifier watcher)
58
+ hook post-write|session-start
59
+ plugin-hook engine (the adapters' bash hooks are
60
+ 3-line shims around these; fail-open by contract)
61
+ version print the package version
62
+ help print this help
63
+
64
+ Config file: ~/.iapeer/plugins/iapeer-memory/config.env (env format;
65
+ overridable via IAPEER_MEMORY_CONFIG_FILE). An explicit IAPEER_MEMORY_* in
66
+ the process env always wins over the file.`;
67
+
68
+ export async function main(argv: string[]): Promise<number> {
69
+ const [cmd, ...rest] = argv;
70
+
71
+ // The config file is the env context of every command (except pure
72
+ // help/version, where a broken file must not block the output).
73
+ if (cmd && !["help", "--help", "-h", "version", "--version", "-V"].includes(cmd)) {
74
+ loadConfigFile(memoryPaths().configFile);
75
+ }
76
+
77
+ switch (cmd) {
78
+ case undefined:
79
+ case "help":
80
+ case "--help":
81
+ case "-h":
82
+ console.log(USAGE);
83
+ return 0;
84
+ case "version":
85
+ case "--version":
86
+ case "-V":
87
+ console.log(packageVersion());
88
+ return 0;
89
+ case "init":
90
+ return cmdInit(rest);
91
+ case "uninstall":
92
+ return cmdUninstall(rest);
93
+ case "status":
94
+ return cmdStatus(rest);
95
+ case "verify":
96
+ return cmdVerify(rest);
97
+ case "update":
98
+ return cmdUpdate(rest);
99
+ case "install-binary":
100
+ return cmdInstallBinary(rest);
101
+ case "fm-update":
102
+ return cmdFmUpdate(rest);
103
+ case "migrate":
104
+ return cmdMigrate(rest);
105
+ case "render":
106
+ return cmdRender(rest);
107
+ case "memoryd":
108
+ return cmdMemoryd(rest);
109
+ case "hook":
110
+ return cmdHook(rest);
111
+ default:
112
+ console.error(`iapeer-memory: unknown command: ${cmd}\n`);
113
+ console.error(USAGE);
114
+ return 2;
115
+ }
116
+ }
117
+
118
+ if (import.meta.main) {
119
+ process.exitCode = await main(process.argv.slice(2));
120
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * `iapeer-memory fm-update` — argv glue for the core `fmUpdate` engine.
3
+ * The contract is fixed in the core module header (core/src/fm-update.ts):
4
+ *
5
+ * iapeer-memory fm-update [--agent NAME] [--vault PATH] [--no-stamp]
6
+ * [--set KEY VALUE | --unset KEY | --list-add KEY VALUE
7
+ * | --list-remove KEY VALUE]...
8
+ * FILE [FILE ...]
9
+ *
10
+ * - identity: `--agent` → PEER_PERSONALITY → IAPEER_MEMORY_AGENT_NAME
11
+ * (resolveAgentName, нюанс 10 — never guessed from cwd);
12
+ * - with no operations it is a pure attribution stamp;
13
+ * - taxonomy/curator-set come from the env context (config file already
14
+ * loaded by the CLI boot), NOT from configFromEnv — fm-update must work
15
+ * on explicit paths without a provisioned vault env.
16
+ */
17
+
18
+ import {
19
+ collectOps,
20
+ DEFAULT_CURATOR_SET,
21
+ fmUpdate,
22
+ getTaxonomy,
23
+ isLocaleId,
24
+ resolveAgentName,
25
+ } from "@agfpd/iapeer-memory-core";
26
+
27
+ export function cmdFmUpdate(argv: string[]): number {
28
+ let agent: string | null = null;
29
+ let vault = "";
30
+ let stamp = true;
31
+ const set: Array<[string, string]> = [];
32
+ const unset: string[] = [];
33
+ const listAdd: Array<[string, string]> = [];
34
+ const listRemove: Array<[string, string]> = [];
35
+ const files: string[] = [];
36
+
37
+ const take = (flag: string, i: number): string => {
38
+ const v = argv[i];
39
+ if (v === undefined) throw new UsageError(`${flag} requires a value`);
40
+ return v;
41
+ };
42
+
43
+ try {
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ switch (a) {
47
+ case "--agent":
48
+ agent = take(a, ++i);
49
+ break;
50
+ case "--vault":
51
+ vault = take(a, ++i);
52
+ break;
53
+ case "--no-stamp":
54
+ stamp = false;
55
+ break;
56
+ case "--set":
57
+ set.push([take(a, ++i), take(a, ++i)]);
58
+ break;
59
+ case "--unset":
60
+ unset.push(take(a, ++i));
61
+ break;
62
+ case "--list-add":
63
+ listAdd.push([take(a, ++i), take(a, ++i)]);
64
+ break;
65
+ case "--list-remove":
66
+ listRemove.push([take(a, ++i), take(a, ++i)]);
67
+ break;
68
+ default:
69
+ if (a.startsWith("--")) throw new UsageError(`unknown flag: ${a}`);
70
+ files.push(a);
71
+ }
72
+ }
73
+ if (!files.length) throw new UsageError("no files given");
74
+ } catch (err) {
75
+ if (err instanceof UsageError) {
76
+ console.error(`iapeer-memory fm-update: ${err.message}`);
77
+ return 2;
78
+ }
79
+ throw err;
80
+ }
81
+
82
+ const localeRaw = process.env.IAPEER_MEMORY_LOCALE || "en";
83
+ if (!isLocaleId(localeRaw)) {
84
+ console.error(`iapeer-memory fm-update: unknown locale "${localeRaw}"`);
85
+ return 2;
86
+ }
87
+ const curatorSet = (process.env.IAPEER_MEMORY_CURATOR_SET || "")
88
+ .split(",")
89
+ .map((s) => s.trim())
90
+ .filter(Boolean);
91
+
92
+ fmUpdate({
93
+ files,
94
+ ops: collectOps({ set, unset, listAdd, listRemove }),
95
+ agent: resolveAgentName(agent),
96
+ vault: vault || process.env.IAPEER_MEMORY_VAULT_PATH || "",
97
+ taxonomy: getTaxonomy(localeRaw),
98
+ curatorSet: curatorSet.length ? curatorSet : DEFAULT_CURATOR_SET,
99
+ stamp,
100
+ });
101
+ return 0;
102
+ }
103
+
104
+ class UsageError extends Error {}
@@ -0,0 +1,264 @@
1
+ /**
2
+ * `iapeer-memory hook <event>` — the TESTABLE engine of the plugin hooks
3
+ * (ADR-009: the plugin is a thin session socket; its bash hooks are 3-line
4
+ * shims that exec this CLI). All parsing/gating logic lives here under
5
+ * tests — the reference's bash/python JSON juggling is deliberately not
6
+ * ported.
7
+ *
8
+ * hook post-write stdin: PostToolUse event JSON. Claude tools
9
+ * Write|Edit|MultiEdit (the codex `apply_patch`
10
+ * branch lands in P5 behind the live-format gate).
11
+ * Stamps vault-note frontmatter through the SAME
12
+ * core fill the fm-update path uses; a NEW note
13
+ * (Write) in the author's own agent-memory folder
14
+ * additionally emits the "team material?" reminder.
15
+ * Reference fact: PostToolUse plain stdout is NOT
16
+ * injected — the reminder goes out as
17
+ * hookSpecificOutput.additionalContext JSON.
18
+ *
19
+ * hook session-start SessionStart health-check (ADR-009 п.3): NEVER a
20
+ * content inject. Provisioned + fresh heartbeat →
21
+ * silent. Unprovisioned → one-line hint. Missing /
22
+ * stale heartbeat → one-line degraded warning
23
+ * (SessionStart plain stdout IS injected — reference
24
+ * fact) + a DEBOUNCED background kick of
25
+ * `verify --repair` (stamp file in state dir — many
26
+ * peers waking at once must not storm repairs).
27
+ *
28
+ * Both verbs are FAIL-OPEN: any internal error is appended to
29
+ * `<logs>/hook-errors.log` and the exit code stays 0 — a memory hook must
30
+ * never block the author's tool flow or session start.
31
+ */
32
+
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+ import {
36
+ DEFAULT_CURATOR_SET,
37
+ fmUpdate,
38
+ getTaxonomy,
39
+ isLocaleId,
40
+ resolveAgentName,
41
+ } from "@agfpd/iapeer-memory-core";
42
+ import { memoryPaths, type MemoryPaths } from "../paths.js";
43
+ import { DEFAULT_HEARTBEAT_STALE_MS } from "./verify.js";
44
+
45
+ /** Tools whose writes stamp frontmatter. P5 adds "apply_patch" (codex). */
46
+ export const POST_WRITE_TOOLS: ReadonlySet<string> = new Set([
47
+ "Write",
48
+ "Edit",
49
+ "MultiEdit",
50
+ ]);
51
+
52
+ /** Min interval between background verify-kicks (anti-storm). */
53
+ export const KICK_DEBOUNCE_MS = 5 * 60_000;
54
+
55
+ // ── post-write ───────────────────────────────────────────────────────────────
56
+
57
+ export type PostWriteResult = {
58
+ stamped: boolean;
59
+ /** JSON string for stdout (hookSpecificOutput), or null for silence. */
60
+ output: string | null;
61
+ };
62
+
63
+ export function reminderText(inboxFolder: string): string {
64
+ return (
65
+ "[iapeer-memory] New note in your agent memory. Check the guide's " +
66
+ "canon-vs-memory filter: does any part of it belong to the team's shared " +
67
+ `knowledge? If yes — also drop a draft into ${inboxFolder}/ and mention ` +
68
+ "this note inline as [[Title]] in the draft body; the Index will link them."
69
+ );
70
+ }
71
+
72
+ export function runPostWrite(
73
+ eventJson: string,
74
+ env: Record<string, string | undefined> = process.env,
75
+ ): PostWriteResult {
76
+ const silent: PostWriteResult = { stamped: false, output: null };
77
+
78
+ let event: { tool_name?: string; tool_input?: { file_path?: string } };
79
+ try {
80
+ event = JSON.parse(eventJson) as typeof event;
81
+ } catch {
82
+ return silent; // malformed event — not our problem to escalate
83
+ }
84
+ const tool = event.tool_name ?? "";
85
+ // Cheap tool gate FIRST (codex sends post_tool_use for EVERY tool —
86
+ // reference live-smoke fact; the same ordering keeps claude cheap too).
87
+ if (!POST_WRITE_TOOLS.has(tool)) return silent;
88
+
89
+ const filePath = event.tool_input?.file_path ?? "";
90
+ if (!filePath.endsWith(".md")) return silent;
91
+
92
+ const vault = env.IAPEER_MEMORY_VAULT_PATH ?? "";
93
+ if (!vault) return silent; // socket without a provisioned system
94
+ if (!filePath.startsWith(vault.endsWith(path.sep) ? vault : vault + path.sep)) {
95
+ return silent; // outside the vault — никогда не трогаем чужие файлы
96
+ }
97
+ if (!fs.existsSync(filePath)) return silent; // failed write — nothing to stamp
98
+
99
+ // Identity: PEER_PERSONALITY → IAPEER_MEMORY_AGENT_NAME. NO cwd guessing
100
+ // (нюанс 10 — deliberate divergence from the reference basename(PWD)).
101
+ const agent = resolveAgentName(null, env);
102
+ if (!agent) return silent;
103
+
104
+ const localeRaw = env.IAPEER_MEMORY_LOCALE || "en";
105
+ if (!isLocaleId(localeRaw)) return silent;
106
+ const taxonomy = getTaxonomy(localeRaw);
107
+ const curatorSet = (env.IAPEER_MEMORY_CURATOR_SET || "")
108
+ .split(",")
109
+ .map((s) => s.trim())
110
+ .filter(Boolean);
111
+
112
+ fmUpdate({
113
+ files: [filePath],
114
+ ops: [],
115
+ agent,
116
+ vault,
117
+ taxonomy,
118
+ curatorSet: curatorSet.length ? curatorSet : DEFAULT_CURATOR_SET,
119
+ stamp: true,
120
+ });
121
+
122
+ // Reminder: ONLY on Write (new note) in the author's OWN memory folder —
123
+ // an Edit-loop on one note must not spam the context (reference semantics).
124
+ const ownMemoryDir =
125
+ path.join(vault, taxonomy.folders.agentMemory, agent) + path.sep;
126
+ const output =
127
+ tool === "Write" && filePath.startsWith(ownMemoryDir)
128
+ ? JSON.stringify({
129
+ hookSpecificOutput: {
130
+ hookEventName: "PostToolUse",
131
+ additionalContext: reminderText(taxonomy.folders.inbox),
132
+ },
133
+ })
134
+ : null;
135
+
136
+ return { stamped: true, output };
137
+ }
138
+
139
+ // ── session-start ────────────────────────────────────────────────────────────
140
+
141
+ export type SessionStartResult = {
142
+ /** One-line context message, or null for a healthy silent start. */
143
+ output: string | null;
144
+ kicked: boolean;
145
+ };
146
+
147
+ export function runSessionStart(opts: {
148
+ paths?: MemoryPaths;
149
+ env?: Record<string, string | undefined>;
150
+ nowMs?: number;
151
+ staleMs?: number;
152
+ /** Injectable background-kicker (the CLI glue spawns verify --repair). */
153
+ kick?: () => void;
154
+ }): SessionStartResult {
155
+ const env = opts.env ?? process.env;
156
+ const paths = opts.paths ?? memoryPaths(env);
157
+ const nowMs = opts.nowMs ?? Date.now();
158
+ const staleMs = opts.staleMs ?? DEFAULT_HEARTBEAT_STALE_MS;
159
+
160
+ // Not provisioned at all (no config file AND no env context): one hint,
161
+ // no repair kick — there is nothing to repair yet.
162
+ const vault = env.IAPEER_MEMORY_VAULT_PATH ?? "";
163
+ if (!vault && !fs.existsSync(paths.configFile)) {
164
+ return {
165
+ output:
166
+ "[iapeer-memory] plugin is installed but the system is not " +
167
+ "provisioned on this host — run: npx @agfpd/iapeer-memory init",
168
+ kicked: false,
169
+ };
170
+ }
171
+
172
+ let problem: string | null = null;
173
+ try {
174
+ const ageMs = nowMs - fs.statSync(paths.heartbeatPath).mtimeMs;
175
+ if (ageMs > staleMs) {
176
+ problem = `memoryd heartbeat is stale (${Math.round(ageMs / 1000)}s old) — the daemon looks hung`;
177
+ }
178
+ } catch {
179
+ problem = "memoryd is not running (no heartbeat)";
180
+ }
181
+ if (!problem) return { output: null, kicked: false };
182
+
183
+ // Debounced self-repair kick (ADR-010: the system is repaired by whichever
184
+ // peer is alive). The stamp file gates the storm of simultaneous wakes.
185
+ let kicked = false;
186
+ const stamp = path.join(paths.stateDir, "verify-kick.stamp");
187
+ let recentKick = false;
188
+ try {
189
+ recentKick = nowMs - fs.statSync(stamp).mtimeMs < KICK_DEBOUNCE_MS;
190
+ } catch {
191
+ recentKick = false;
192
+ }
193
+ if (!recentKick) {
194
+ try {
195
+ fs.mkdirSync(paths.stateDir, { recursive: true });
196
+ fs.writeFileSync(stamp, `${new Date(nowMs).toISOString()}\n`);
197
+ opts.kick?.();
198
+ kicked = true;
199
+ } catch {
200
+ // best effort — the warning still reaches the context
201
+ }
202
+ }
203
+
204
+ return {
205
+ output:
206
+ `[iapeer-memory] degraded: ${problem}. ` +
207
+ (kicked
208
+ ? "Kicked `verify --repair` in the background; "
209
+ : "Repair was kicked recently; ") +
210
+ "check with: iapeer-memory verify",
211
+ kicked,
212
+ };
213
+ }
214
+
215
+ // ── CLI glue ─────────────────────────────────────────────────────────────────
216
+
217
+ function logHookError(err: unknown): void {
218
+ try {
219
+ const dir = memoryPaths().logsDir;
220
+ fs.mkdirSync(dir, { recursive: true });
221
+ fs.appendFileSync(
222
+ path.join(dir, "hook-errors.log"),
223
+ `${new Date().toISOString()} ${String(err)}\n`,
224
+ );
225
+ } catch {
226
+ // truly nowhere to report — stay silent, stay fail-open
227
+ }
228
+ }
229
+
230
+ export async function cmdHook(argv: string[]): Promise<number> {
231
+ const [event] = argv;
232
+ try {
233
+ switch (event) {
234
+ case "post-write": {
235
+ const result = runPostWrite(await Bun.stdin.text());
236
+ if (result.output) console.log(result.output);
237
+ return 0;
238
+ }
239
+ case "session-start": {
240
+ const result = runSessionStart({
241
+ kick: () => {
242
+ const cli = new URL("../cli.ts", import.meta.url).pathname;
243
+ const proc = Bun.spawn([process.execPath, cli, "verify", "--repair"], {
244
+ stdout: "ignore",
245
+ stderr: "ignore",
246
+ stdin: "ignore",
247
+ });
248
+ proc.unref();
249
+ },
250
+ });
251
+ if (result.output) console.log(result.output);
252
+ return 0;
253
+ }
254
+ default:
255
+ console.error(
256
+ `iapeer-memory hook: unknown event: ${event ?? "(none)"} (expected post-write | session-start)`,
257
+ );
258
+ return 2;
259
+ }
260
+ } catch (err) {
261
+ logHookError(err);
262
+ return 0; // fail-open by contract
263
+ }
264
+ }