@agfpd/iapeer-memory 0.2.0 → 0.2.2

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.
@@ -24,9 +24,11 @@
24
24
  * (embedded bodies, bytes-compare; the directory prefix is OUR
25
25
  * namespace — unprovision removes every match).
26
26
  *
27
- * Pickup semantics (documented, boris design input): a live session does NOT
28
- * re-read these filessurfaces land on the peer's NEXT session start
29
- * (same semantics the plugin form had).
27
+ * Pickup semantics (documented, boris design input): hooks and MCP land on
28
+ * the peer's NEXT session start a live session does not re-read them (same
29
+ * semantics the plugin form had). Skills are picked up HOT by live sessions
30
+ * (live observation, боевой E2E 11.06) — «next restart» is the conservative
31
+ * guarantee for all three, exact for hooks/MCP.
30
32
  *
31
33
  * Idempotent by construction: same version re-run → `already` on every
32
34
  * surface; drift (a mangled/deleted entry) → re-written. The remove path is
@@ -38,6 +40,7 @@
38
40
  import fs from "node:fs";
39
41
  import path from "node:path";
40
42
  import { SKILL_BODIES, SKILL_DIR_PREFIX, SKILL_NAMES } from "../templates/skills.js";
43
+ import { guardedWriteFileSync, guardedUnlinkSync, guardedRmSync } from "@agfpd/iapeer-memory-core";
41
44
 
42
45
  export const MCP_SERVER_KEY = "iapeer-memory";
43
46
  /**
@@ -87,7 +90,7 @@ function shimContent(verb: "post-write" | "session-start"): string {
87
90
  function writeFileAtomic(filePath: string, content: string, mode?: number): void {
88
91
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
89
92
  const tmp = `${filePath}.tmp`;
90
- fs.writeFileSync(tmp, content, "utf-8");
93
+ guardedWriteFileSync(tmp, content, "utf-8");
91
94
  if (mode !== undefined) fs.chmodSync(tmp, mode);
92
95
  fs.renameSync(tmp, filePath);
93
96
  }
@@ -352,7 +355,7 @@ export function removeClaudeMcp(opts: { cwd: string }): SurfaceOutcome {
352
355
  if (serversEmpty && onlyServersKey) {
353
356
  // semantically empty file — with our key gone it says nothing; a file we
354
357
  // most likely created. Removing it leaves the cwd exactly as found.
355
- fs.unlinkSync(mcpPath);
358
+ guardedUnlinkSync(mcpPath);
356
359
  return { surface: "mcp", action: "removed", path: mcpPath, detail: "file removed (empty after our key)" };
357
360
  }
358
361
  writeFileAtomic(mcpPath, `${JSON.stringify(current, null, 2)}\n`);
@@ -399,7 +402,7 @@ export function removeClaudeSkills(opts: { cwd: string }): SurfaceOutcome {
399
402
  return { surface: "skills", action: "absent", path: skillsDir };
400
403
  }
401
404
  for (const e of ours) {
402
- fs.rmSync(path.join(skillsDir, e), { recursive: true, force: true });
405
+ guardedRmSync(path.join(skillsDir, e), { recursive: true, force: true });
403
406
  }
404
407
  // sweep empty containers we may have created (skills/ then .claude/)
405
408
  for (const dir of [skillsDir, path.dirname(skillsDir)]) {
@@ -36,6 +36,7 @@
36
36
  import fs from "node:fs";
37
37
  import path from "node:path";
38
38
  import type { SurfaceOutcome } from "./claude.js";
39
+ import { guardedWriteFileSync, guardedUnlinkSync } from "@agfpd/iapeer-memory-core";
39
40
 
40
41
  export const CODEX_MCP_SECTION = "mcp_servers.iapeer-memory";
41
42
  const SECTION_HEADER_RE = /^\s*\[/;
@@ -61,7 +62,7 @@ export function expectedCodexBlock(port: number): string {
61
62
  function writeFileAtomic(filePath: string, content: string): void {
62
63
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
63
64
  const tmp = `${filePath}.tmp`;
64
- fs.writeFileSync(tmp, content, "utf-8");
65
+ guardedWriteFileSync(tmp, content, "utf-8");
65
66
  fs.renameSync(tmp, filePath);
66
67
  }
67
68
 
@@ -115,7 +116,7 @@ export function removeCodexMcp(opts: { cwd: string }): SurfaceOutcome {
115
116
  const foreign = withoutOurSections(text.split("\n"));
116
117
  if (foreign.every((l) => l.trim() === "")) {
117
118
  // nothing but our block lived here — leave the cwd exactly as found
118
- fs.unlinkSync(configPath);
119
+ guardedUnlinkSync(configPath);
119
120
  return { surface: "mcp", action: "removed", path: configPath, detail: "file removed (empty after our block)" };
120
121
  }
121
122
  writeFileAtomic(configPath, `${foreign.join("\n")}\n`);
@@ -13,6 +13,7 @@
13
13
 
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
+ import { guardedWriteFileSync } from "@agfpd/iapeer-memory-core";
16
17
 
17
18
  export type SyncOutcome = { file: string; action: "updated" | "identical" | "missing" };
18
19
 
@@ -46,7 +47,7 @@ export function syncVersions(opts: {
46
47
  }
47
48
  manifest.version = opts.version;
48
49
  // 2-space indent + trailing newline — the repo's manifest style.
49
- fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
50
+ guardedWriteFileSync(file, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
50
51
  outcomes.push({ file: rel, action: "updated" });
51
52
  }
52
53
  return outcomes;
@@ -81,7 +82,7 @@ export function syncCoreDependencyPin(opts: {
81
82
  }
82
83
  deps["@agfpd/iapeer-memory-core"] = opts.version;
83
84
  manifest.dependencies = deps;
84
- fs.writeFileSync(
85
+ guardedWriteFileSync(
85
86
  opts.packageManifestPath,
86
87
  JSON.stringify(manifest, null, 2) + "\n",
87
88
  "utf-8",
@@ -16,6 +16,7 @@ import path from "node:path";
16
16
  import type { LocaleId } from "@agfpd/iapeer-memory-core";
17
17
  import { GUIDE_EN } from "./guide-en.js";
18
18
  import { GUIDE_RU } from "./guide-ru.js";
19
+ import { guardedWriteFileSync } from "@agfpd/iapeer-memory-core";
19
20
  import {
20
21
  SCRIBER_DOCTRINE_EN,
21
22
  DREAMWEAVER_DOCTRINE_EN,
@@ -152,7 +153,7 @@ export function materialiseTemplates(opts: {
152
153
  }
153
154
  fs.mkdirSync(path.dirname(file), { recursive: true });
154
155
  const tmp = `${file}.tmp`;
155
- fs.writeFileSync(tmp, content, "utf-8");
156
+ guardedWriteFileSync(tmp, content, "utf-8");
156
157
  fs.renameSync(tmp, file);
157
158
  written.push(file);
158
159
  }
@@ -69,6 +69,12 @@ step, when ALL THREE hold: the Scriber processed the note + the links
69
69
  section is complete + no open questions remain (no unanswered author
70
70
  pings). Nobody sets it by decision; nobody else clears it.
71
71
 
72
+ \`last_edited_by: unstamped\` — the write BYPASSED the hook (a Bash write,
73
+ an external editor): memoryd's detector honestly says «writer unknown»
74
+ instead of a silently inherited attribution. Resolve it by context (the
75
+ content, git, asking the writers); \`needs_review\` is already set — clear
76
+ it under the usual three conditions.
77
+
72
78
  ## Agent-memory curation (light, no Scriber)
73
79
 
74
80
  1. \`status\` is final → move to the archive subfolder; stop.
@@ -62,6 +62,12 @@ locale: ru
62
62
  дополнена + открытых вопросов нет (все пинги авторам закрыты). Никто не
63
63
  ставит флаг решением; никто кроме тебя не снимает.
64
64
 
65
+ \`last_edited_by: unstamped\` — запись прошла МИМО хука (Bash-запись,
66
+ внешний редактор): детектор memoryd честно говорит «писатель неизвестен»
67
+ вместо тихо унаследованной атрибуции. Разбирайся по контексту (содержание,
68
+ git, вопрос писателям); needs_review при этом уже стоит — снимаешь по
69
+ обычным трём условиям.
70
+
65
71
  ## Курирование оперативки (лёгкое, без Scriber'а)
66
72
 
67
73
  1. Финальный \`status\` → move в архивную подпапку; дальше не обрабатывай.
package/src/watcher.ts CHANGED
@@ -35,6 +35,8 @@
35
35
 
36
36
  import fs from "node:fs";
37
37
  import path from "node:path";
38
+ import { IAPEER_BIN, type Egress } from "./egress.js";
39
+ import { guardedWriteFileSync } from "@agfpd/iapeer-memory-core";
38
40
 
39
41
  export const WATCHER_TRIGGER_ID = "iapeer-memory-memoryd";
40
42
  /** Fail-open sweep (инверсия, ADR-015): check-gated hourly timer → index. */
@@ -69,7 +71,7 @@ function writeExecutable(filePath: string, content: string): "written" | "identi
69
71
  }
70
72
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
73
  const tmp = `${filePath}.tmp`;
72
- fs.writeFileSync(tmp, content, "utf-8");
74
+ guardedWriteFileSync(tmp, content, "utf-8");
73
75
  fs.chmodSync(tmp, 0o755);
74
76
  fs.renameSync(tmp, filePath);
75
77
  return "written";
@@ -152,7 +154,7 @@ export function patchWakePolicyEphemeral(
152
154
  if (profile.wake_policy === "ephemeral") return "identical";
153
155
  profile.wake_policy = "ephemeral";
154
156
  const tmp = `${profilePath}.tmp`;
155
- fs.writeFileSync(tmp, `${JSON.stringify(profile, null, 2)}\n`, "utf-8");
157
+ guardedWriteFileSync(tmp, `${JSON.stringify(profile, null, 2)}\n`, "utf-8");
156
158
  fs.renameSync(tmp, profilePath);
157
159
  return "written";
158
160
  }
@@ -225,45 +227,35 @@ export function fromIdentity(personality: string, runtime = DEFAULT_FROM_RUNTIME
225
227
  return `${runtime}-${personality}`;
226
228
  }
227
229
 
228
- function iapSend(opts: {
229
- message: string;
230
- fromIdentity: string;
231
- /** Notifier peer to address: `watcher` (events) or `timer` (cron/sweep). */
232
- to?: "watcher" | "timer";
233
- iapeerBin?: string;
234
- }): IapSendResult {
235
- // Hard fuse (incident 10.06, prod Index): verify-repair tests reached the
236
- // LIVE watcher peer and registered crashlooping temp-path triggers — /tmp
237
- // tmux sockets are host-global, no sandbox env can contain a real send.
238
- // Test scripts set this env (package.json), mirroring IAPEER_TEST_SANDBOX.
239
- // BOTH vars honoured (belt and braces): a test helper once stripped all
240
- // IAPEER_MEMORY_* env before spawning the CLI the ecosystem-wide
241
- // IAPEER_TEST_SANDBOX survives generic prefix-stripping.
242
- if (
243
- process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
244
- process.env.IAPEER_TEST_SANDBOX === "1"
245
- ) {
246
- return {
247
- ok: false,
248
- suppressed: true,
249
- detail: "iap send suppressed (test sandbox)",
250
- };
230
+ function iapSend(
231
+ egress: Egress,
232
+ opts: {
233
+ message: string;
234
+ fromIdentity: string;
235
+ /** Notifier peer to address: `watcher` (events) or `timer` (cron/sweep). */
236
+ to?: "watcher" | "timer";
237
+ iapeerBin?: string;
238
+ },
239
+ ): IapSendResult {
240
+ // The hard fuse of incident 10.06 (prod Index: crashlooping temp-path
241
+ // triggers; /tmp tmux sockets are host-global, no sandbox env contains a
242
+ // real send) lives in the EGRESS CONSTRUCTOR now (deny-by-default §4):
243
+ // under a test sandbox this handle refuses the spawn before it happens.
244
+ const bin = opts.iapeerBin ?? IAPEER_BIN;
245
+ const proc = egress.spawnSync(
246
+ [bin, "send", opts.to ?? "watcher", "--from", opts.fromIdentity, "--message", opts.message],
247
+ { explicitBin: opts.iapeerBin !== undefined },
248
+ );
249
+ if (proc.refused) {
250
+ return { ok: false, suppressed: true, detail: "iap send suppressed (test sandbox)" };
251
251
  }
252
- const bin = opts.iapeerBin ?? "iapeer";
253
- let proc: ReturnType<typeof Bun.spawnSync>;
254
- try {
255
- proc = Bun.spawnSync(
256
- [bin, "send", opts.to ?? "watcher", "--from", opts.fromIdentity, "--message", opts.message],
257
- { stdout: "pipe", stderr: "pipe" },
258
- );
259
- } catch (err) {
260
- return { ok: false, detail: `${bin} unavailable: ${String(err)}` };
252
+ if (proc.spawnError) {
253
+ return { ok: false, detail: `${bin} unavailable: ${proc.spawnError}` };
261
254
  }
262
255
  if (proc.exitCode !== 0) {
263
256
  return {
264
257
  ok: false,
265
- detail:
266
- (proc.stderr?.toString().trim() || "") || `iapeer send exited ${proc.exitCode}`,
258
+ detail: proc.stderr.trim() || `iapeer send exited ${proc.exitCode}`,
267
259
  };
268
260
  }
269
261
  // NB: a 0 exit code means DELIVERED, not "registration valid" — the
@@ -272,18 +264,21 @@ function iapSend(opts: {
272
264
  return { ok: true, detail: "delivered (confirm via the registrant's profile)" };
273
265
  }
274
266
 
275
- export function registerWatcher(opts: {
276
- launcherPath: string;
277
- /** Registrant PERSONALITY (owner of the durable trigger + default target). */
278
- registrant?: string;
279
- /** Runtime prefix of the registrant's identity (claude for role peers). */
280
- runtime?: string;
281
- target?: string;
282
- id?: string;
283
- iapeerBin?: string;
284
- }): IapSendResult {
267
+ export function registerWatcher(
268
+ egress: Egress,
269
+ opts: {
270
+ launcherPath: string;
271
+ /** Registrant PERSONALITY (owner of the durable trigger + default target). */
272
+ registrant?: string;
273
+ /** Runtime prefix of the registrant's identity (claude for role peers). */
274
+ runtime?: string;
275
+ target?: string;
276
+ id?: string;
277
+ iapeerBin?: string;
278
+ },
279
+ ): IapSendResult {
285
280
  const registrant = opts.registrant ?? DEFAULT_REGISTRANT;
286
- return iapSend({
281
+ return iapSend(egress, {
287
282
  fromIdentity: fromIdentity(registrant, opts.runtime),
288
283
  iapeerBin: opts.iapeerBin,
289
284
  message: registrationMessage({
@@ -296,13 +291,16 @@ export function registerWatcher(opts: {
296
291
  });
297
292
  }
298
293
 
299
- export function unregisterWatcher(opts: {
300
- registrant?: string;
301
- runtime?: string;
302
- id?: string;
303
- iapeerBin?: string;
304
- }): IapSendResult {
305
- return iapSend({
294
+ export function unregisterWatcher(
295
+ egress: Egress,
296
+ opts: {
297
+ registrant?: string;
298
+ runtime?: string;
299
+ id?: string;
300
+ iapeerBin?: string;
301
+ },
302
+ ): IapSendResult {
303
+ return iapSend(egress, {
306
304
  fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
307
305
  iapeerBin: opts.iapeerBin,
308
306
  message: JSON.stringify({ cmd: "unregister", id: opts.id ?? WATCHER_TRIGGER_ID }),
@@ -310,13 +308,16 @@ export function unregisterWatcher(opts: {
310
308
  }
311
309
 
312
310
  /** Register a timer trigger (sweep/dream) — body built by *TimerMessage(). */
313
- export function registerTimer(opts: {
314
- message: string;
315
- registrant?: string;
316
- runtime?: string;
317
- iapeerBin?: string;
318
- }): IapSendResult {
319
- return iapSend({
311
+ export function registerTimer(
312
+ egress: Egress,
313
+ opts: {
314
+ message: string;
315
+ registrant?: string;
316
+ runtime?: string;
317
+ iapeerBin?: string;
318
+ },
319
+ ): IapSendResult {
320
+ return iapSend(egress, {
320
321
  to: "timer",
321
322
  fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
322
323
  iapeerBin: opts.iapeerBin,
@@ -324,13 +325,16 @@ export function registerTimer(opts: {
324
325
  });
325
326
  }
326
327
 
327
- export function unregisterTimer(opts: {
328
- id: string;
329
- registrant?: string;
330
- runtime?: string;
331
- iapeerBin?: string;
332
- }): IapSendResult {
333
- return iapSend({
328
+ export function unregisterTimer(
329
+ egress: Egress,
330
+ opts: {
331
+ id: string;
332
+ registrant?: string;
333
+ runtime?: string;
334
+ iapeerBin?: string;
335
+ },
336
+ ): IapSendResult {
337
+ return iapSend(egress, {
334
338
  to: "timer",
335
339
  fromIdentity: fromIdentity(opts.registrant ?? DEFAULT_REGISTRANT, opts.runtime),
336
340
  iapeerBin: opts.iapeerBin,