@agfpd/iapeer-memory 0.1.13 → 0.2.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,72 @@
1
+ /**
2
+ * Host-wide provision lock — the iapeer v1.2 contract obliges the provision
3
+ * command to TOLERATE PARALLEL CALLS (the locking the plugin manager used to
4
+ * give moved to the provider; §7 requirement 3). The core may fire
5
+ * provision-peer concurrently (peer births race sweeps); two unsynchronised
6
+ * read-merge-writes of the SAME settings.json would lose one writer's keys.
7
+ *
8
+ * Form: mkdir-based exclusive lock (atomic on every POSIX fs, no flock in
9
+ * Bun's stable API). One lock for the whole host — provision bodies are
10
+ * milliseconds of file I/O, serialising them is simpler and strictly safer
11
+ * than per-cwd granularity. Stale detection: a lock directory older than
12
+ * STALE_MS belongs to a crashed run — broken and re-taken (provision is
13
+ * idempotent by contract, a double-run repairs, never corrupts).
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+
19
+ const RETRY_MS = 50;
20
+ const DEFAULT_TIMEOUT_MS = 15_000;
21
+ export const STALE_MS = 120_000;
22
+
23
+ export type LockResult<T> =
24
+ | { acquired: true; result: T }
25
+ | { acquired: false; detail: string };
26
+
27
+ function tryTake(lockDir: string): boolean {
28
+ try {
29
+ fs.mkdirSync(lockDir); // atomic: EEXIST when held
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function breakIfStale(lockDir: string): void {
37
+ try {
38
+ const stat = fs.statSync(lockDir);
39
+ if (Date.now() - stat.mtimeMs > STALE_MS) fs.rmdirSync(lockDir);
40
+ } catch {
41
+ // raced away or unreadable — the next tryTake decides
42
+ }
43
+ }
44
+
45
+ export function withProvisionLock<T>(opts: {
46
+ stateDir: string;
47
+ fn: () => T;
48
+ timeoutMs?: number;
49
+ }): LockResult<T> {
50
+ const lockDir = path.join(opts.stateDir, "provision.lock.d");
51
+ fs.mkdirSync(opts.stateDir, { recursive: true });
52
+ const deadline = Date.now() + (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
53
+ while (!tryTake(lockDir)) {
54
+ breakIfStale(lockDir);
55
+ if (Date.now() >= deadline) {
56
+ return {
57
+ acquired: false,
58
+ detail: `provision lock busy for ${Math.round((opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000)}s (${lockDir}) — another provision hung? stale locks self-break after ${STALE_MS / 1000}s`,
59
+ };
60
+ }
61
+ Bun.sleepSync(RETRY_MS);
62
+ }
63
+ try {
64
+ return { acquired: true, result: opts.fn() };
65
+ } finally {
66
+ try {
67
+ fs.rmdirSync(lockDir);
68
+ } catch {
69
+ // already gone (stale-broken by a peer) — nothing to release
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Fleet-wide surfaces sweep — the package's own rail over fleet.json
3
+ * (ADR-009 v1.2). The core's birth-hook covers NEWBORNS via the slot's
4
+ * provision command; everything fleet-wide (init coverage of the existing
5
+ * fleet, update's «всё на местах» duty, verify --repair self-healing) walks
6
+ * the fleet map HERE — peer × session-runtime, claude and codex forms.
7
+ *
8
+ * Session runtimes are exactly {claude, codex}: telegram/notifier and other
9
+ * infra runtimes carry no session config surfaces. A peer entry without a
10
+ * runtimes array (pre-v1.2 map) is SKIPPED and reported — the next map
11
+ * re-write (same command) picks it up.
12
+ *
13
+ * The caller holds the provision lock around the WHOLE sweep (one
14
+ * acquisition, not per-peer — the sweep body is pure file I/O).
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import {
19
+ checkClaudePeer,
20
+ provisionClaudePeer,
21
+ unprovisionClaudePeer,
22
+ type SurfaceOutcome,
23
+ } from "./claude.js";
24
+ import { checkCodexPeer, provisionCodexPeer, unprovisionCodexPeer } from "./codex.js";
25
+ import type { FleetPeer } from "../fleet.js";
26
+
27
+ export const SESSION_RUNTIMES = ["claude", "codex"] as const;
28
+ export type SessionRuntime = (typeof SESSION_RUNTIMES)[number];
29
+
30
+ export type PeerSweepResult = {
31
+ personality: string;
32
+ runtime: SessionRuntime;
33
+ cwd: string;
34
+ /** worst action across the peer-runtime's surfaces */
35
+ ok: boolean;
36
+ outcomes: SurfaceOutcome[];
37
+ };
38
+
39
+ export type SweepSummary = {
40
+ results: PeerSweepResult[];
41
+ /** peers skipped: no session runtimes in the map entry / vanished cwd */
42
+ skipped: Array<{ personality: string; reason: string }>;
43
+ };
44
+
45
+ function sessionRuntimesOf(peer: FleetPeer): SessionRuntime[] {
46
+ return SESSION_RUNTIMES.filter((r) => peer.runtimes.includes(r));
47
+ }
48
+
49
+ export function sweepProvision(opts: {
50
+ fleet: FleetPeer[];
51
+ hooksDir: string;
52
+ port: number;
53
+ }): SweepSummary {
54
+ const results: PeerSweepResult[] = [];
55
+ const skipped: SweepSummary["skipped"] = [];
56
+ for (const peer of opts.fleet) {
57
+ const runtimes = sessionRuntimesOf(peer);
58
+ if (runtimes.length === 0) {
59
+ skipped.push({
60
+ personality: peer.personality,
61
+ reason: peer.runtimes.length
62
+ ? `no session runtime (${peer.runtimes.join(",")})`
63
+ : "no runtimes in fleet map (pre-v1.2 entry) — re-write the map",
64
+ });
65
+ continue;
66
+ }
67
+ if (!fs.existsSync(peer.cwd)) {
68
+ skipped.push({ personality: peer.personality, reason: `cwd missing: ${peer.cwd}` });
69
+ continue;
70
+ }
71
+ for (const runtime of runtimes) {
72
+ const outcomes =
73
+ runtime === "codex"
74
+ ? provisionCodexPeer({ cwd: peer.cwd, port: opts.port })
75
+ : provisionClaudePeer({
76
+ cwd: peer.cwd,
77
+ hooksDir: opts.hooksDir,
78
+ port: opts.port,
79
+ personality: peer.personality,
80
+ });
81
+ results.push({
82
+ personality: peer.personality,
83
+ runtime,
84
+ cwd: peer.cwd,
85
+ ok: outcomes.every((o) => o.action !== "failed"),
86
+ outcomes,
87
+ });
88
+ }
89
+ }
90
+ return { results, skipped };
91
+ }
92
+
93
+ export function sweepUnprovision(opts: { fleet: FleetPeer[] }): SweepSummary {
94
+ const results: PeerSweepResult[] = [];
95
+ const skipped: SweepSummary["skipped"] = [];
96
+ for (const peer of opts.fleet) {
97
+ const runtimes = sessionRuntimesOf(peer);
98
+ if (runtimes.length === 0) {
99
+ skipped.push({ personality: peer.personality, reason: "no session runtime" });
100
+ continue;
101
+ }
102
+ // a vanished cwd is fine on the off-path — surfaces report `absent`
103
+ for (const runtime of runtimes) {
104
+ const outcomes =
105
+ runtime === "codex"
106
+ ? unprovisionCodexPeer({ cwd: peer.cwd })
107
+ : unprovisionClaudePeer({ cwd: peer.cwd });
108
+ results.push({
109
+ personality: peer.personality,
110
+ runtime,
111
+ cwd: peer.cwd,
112
+ ok: outcomes.every((o) => o.action !== "failed"),
113
+ outcomes,
114
+ });
115
+ }
116
+ }
117
+ return { results, skipped };
118
+ }
119
+
120
+ export type PeerCheckResult = {
121
+ personality: string;
122
+ runtime: SessionRuntime;
123
+ cwd: string;
124
+ ok: boolean;
125
+ problems: string[];
126
+ };
127
+
128
+ export function checkFleetSurfaces(opts: {
129
+ fleet: FleetPeer[];
130
+ hooksDir: string;
131
+ port: number;
132
+ }): { checks: PeerCheckResult[]; skipped: Array<{ personality: string; reason: string }> } {
133
+ const checks: PeerCheckResult[] = [];
134
+ const skipped: Array<{ personality: string; reason: string }> = [];
135
+ for (const peer of opts.fleet) {
136
+ const runtimes = sessionRuntimesOf(peer);
137
+ if (runtimes.length === 0) {
138
+ skipped.push({
139
+ personality: peer.personality,
140
+ reason: peer.runtimes.length
141
+ ? `no session runtime (${peer.runtimes.join(",")})`
142
+ : "no runtimes in fleet map (pre-v1.2 entry)",
143
+ });
144
+ continue;
145
+ }
146
+ if (!fs.existsSync(peer.cwd)) {
147
+ skipped.push({ personality: peer.personality, reason: `cwd missing: ${peer.cwd}` });
148
+ continue;
149
+ }
150
+ for (const runtime of runtimes) {
151
+ const surfaceChecks =
152
+ runtime === "codex"
153
+ ? checkCodexPeer({ cwd: peer.cwd, port: opts.port })
154
+ : checkClaudePeer({
155
+ cwd: peer.cwd,
156
+ hooksDir: opts.hooksDir,
157
+ port: opts.port,
158
+ personality: peer.personality,
159
+ });
160
+ checks.push({
161
+ personality: peer.personality,
162
+ runtime,
163
+ cwd: peer.cwd,
164
+ ok: surfaceChecks.every((c) => c.ok),
165
+ problems: surfaceChecks.filter((c) => !c.ok).map((c) => `${c.surface}: ${c.detail}`),
166
+ });
167
+ }
168
+ }
169
+ return { checks, skipped };
170
+ }
@@ -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
  }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Embedded skill files — the DIRECT-surface form of the four session skills
3
+ * (ADR-009 v1.2: direct per-peer surfaces instead of the plugin socket).
4
+ * Bodies are the boris-accepted plugin skills (adapters/claude/skills, spot-
5
+ * checked against the live CLI 10.06) with exactly two deltas:
6
+ *
7
+ * 1. names are namespaced `iapeer-memory-*` (boris design input: direct
8
+ * skills lose the plugin namespace `/iapeer-memory:name` — the prefix
9
+ * replaces it; the `copywriter` collision class);
10
+ * 2. "plugin" wording → "session surfaces" where it described the socket
11
+ * form (the socket is now files merged into the peer's cwd).
12
+ *
13
+ * provision-peer materialises them to `<cwd>/.claude/skills/<name>/SKILL.md`
14
+ * (bytes-compare, package-owned — overwritten on version change; the
15
+ * `iapeer-memory-` directory prefix is OUR namespace, unprovision removes
16
+ * every directory matching it).
17
+ */
18
+
19
+ export type SkillName =
20
+ | "iapeer-memory-init"
21
+ | "iapeer-memory-status"
22
+ | "iapeer-memory-migrate"
23
+ | "iapeer-memory-distill";
24
+
25
+ export const SKILL_NAMES: readonly SkillName[] = [
26
+ "iapeer-memory-init",
27
+ "iapeer-memory-status",
28
+ "iapeer-memory-migrate",
29
+ "iapeer-memory-distill",
30
+ ] as const;
31
+
32
+ /** Directory-name prefix that marks a skill directory as OURS (the removal
33
+ * glob of unprovision — the namespace promise of the `iapeer-memory-*`
34
+ * naming). */
35
+ export const SKILL_DIR_PREFIX = "iapeer-memory-";
36
+
37
+ const SKILL_INIT = `---
38
+ name: iapeer-memory-init
39
+ description: "Use when the user asks to install, provision or initialize iapeer-memory on this host (\\"set up iapeer-memory\\", \\"init memory\\", \\"provision the vault\\"). Thin facade over \`iapeer-memory init\`: the procedure lives in the package CLI, not here."
40
+ allowed-tools: ["Bash", "AskUserQuestion"]
41
+ ---
42
+
43
+ # Provision iapeer-memory on this host
44
+
45
+ The session surfaces are only a socket (ADR-009) — provisioning is owned by
46
+ the package CLI. Do not improvise installation steps around it.
47
+
48
+ 1. Locate the CLI: \`command -v iapeer-memory || ls ~/.local/bin/iapeer-memory\`.
49
+ Missing → run via \`npx @agfpd/iapeer-memory\` instead.
50
+ 2. Init is two-mode. On a tty it prompts; your Bash calls have NO tty, so
51
+ without \`--vault\` init refuses (silently provisioning a default storage
52
+ path is forbidden). Collect the answers from the user first
53
+ (AskUserQuestion), then run:
54
+ \`iapeer-memory init --vault PATH --locale en|ru
55
+ [--embedding-endpoint URL] [--reranker-endpoint URL]\`.
56
+ Do NOT ask for the human owner: init reads the iapeer registry and uses
57
+ the single natural peer by itself (don't ask what the stack already
58
+ knows). Pass \`--human NAME\` only when the registry can't answer (zero or
59
+ several natural peers) and the user wants a human role.
60
+ 3. Init prints a step table (deps → vault → config → binary → templates →
61
+ roles → fleet → watcher → surfaces → slot → sweep → guide) and is
62
+ idempotent: on exit 1 re-running init is the official repair path,
63
+ together with \`iapeer-memory verify --repair\`.
64
+ 4. A host that is already provisioned and only version-stale wants the
65
+ update story, not init: \`npx @agfpd/iapeer-memory@latest update\`.
66
+
67
+ After success, check the chain with the \`iapeer-memory-status\` skill.
68
+ (\`iapeer onboard\` runs this same init from the core's host phase —
69
+ full-stack onboarding already covers memory.)
70
+ `;
71
+
72
+ const SKILL_STATUS = `---
73
+ name: iapeer-memory-status
74
+ description: "Use when the user asks for the iapeer-memory status (\\"memory status\\", \\"is the vault index alive\\", \\"check iapeer-memory\\", \\"is memoryd running\\"). Read-only facade over \`iapeer-memory status\`: package ↔ surfaces link first, then the CLI's own diagnostics. Never repairs anything."
75
+ allowed-tools: ["Bash"]
76
+ ---
77
+
78
+ # iapeer-memory status — read-only diagnostics
79
+
80
+ The session surfaces are the socket, the package is the system (ADR-009).
81
+ This skill's first duty is to DIAGNOSE A BROKEN LINK between them — a
82
+ session whose surfaces are wired but whose system is missing must say so
83
+ explicitly.
84
+
85
+ 1. **Socket → system link**: \`command -v iapeer-memory || ls ~/.local/bin/iapeer-memory\`.
86
+ Missing → report: "session surfaces present, package missing — the socket
87
+ has no system behind it; run: npx @agfpd/iapeer-memory init". Stop here.
88
+ 2. **Everything else**: run \`iapeer-memory status\` and relay its table —
89
+ verify checks (config, memory-slot, memoryd heartbeat, notifier watcher,
90
+ role doctrine versions, per-peer surfaces), slot-file, mcp-endpoint
91
+ probe, search pipeline, inbox load. Exit 1 = something needs attention.
92
+
93
+ Reading the table: \`search\` shows the LIVE per-component pipeline from the
94
+ running memoryd (bm25/embedding/reranker/graph) and falls back to the
95
+ static config view when memoryd is down; a growing \`inbox\` count means the
96
+ Index curator is not keeping up.
97
+ `;
98
+
99
+ const SKILL_MIGRATE = `---
100
+ name: iapeer-memory-migrate
101
+ description: "Use when the user asks to migrate harness auto-memory into iapeer-memory (\\"migrate memory\\", \\"move auto-memory to the vault\\", \\"перенеси auto-memory\\"), or when connecting a peer that has accumulated Claude auto-memory. Facade over \`iapeer-memory migrate\`: the skill resolves the claude-specific SOURCE directory, the deterministic engine does the rest (dry-run → confirm → apply, with backups)."
102
+ argument-hint: "<agent> [<project-dir>]"
103
+ allowed-tools: ["Bash", "AskUserQuestion"]
104
+ ---
105
+
106
+ # Migrate Claude auto-memory into the vault
107
+
108
+ The engine (\`iapeer-memory migrate\`) is source-agnostic — THIS skill owns the
109
+ claude-specific knowledge of where auto-memory lives. (The codex source is
110
+ NOT wired yet: its live format is unverified — never guess it.)
111
+
112
+ ## Resolve the source directory
113
+
114
+ - **Launchd/persistent peer** (no \`<project-dir>\` argument):
115
+ \`SOURCE=~/.claude/agent-memory/<agent>/\`
116
+ - **Project session** (\`<project-dir>\` given): the slug is the absolute
117
+ path with every non-alphanumeric character replaced by \`-\` — dots too:
118
+ \`/a/b.c\` → \`-a-b-c\` (so \`~/.iapeer/...\` yields a double dash). When in
119
+ doubt, \`ls ~/.claude/projects/\` and match.
120
+ \`SOURCE=~/.claude/projects/<slug>/memory/\`
121
+
122
+ No directory or no \`.md\` files inside → nothing to migrate; say so and stop.
123
+
124
+ ## Run
125
+
126
+ 1. Dry-run first: \`iapeer-memory migrate --source "$SOURCE" --agent <agent>\`
127
+ — show the user the plan verbatim (per-file type → subtype mapping,
128
+ skip lists, totals).
129
+ 2. Ask for confirmation (AskUserQuestion).
130
+ 3. Apply: same command + \`--apply\`. Per-file backups land under
131
+ \`~/.iapeer/state/iapeer-memory/migrate-backups/\` before conversion; an
132
+ existing target note is never overwritten.
133
+ 4. Report: migrated/skipped/errors + backup path.
134
+
135
+ ## After migration
136
+
137
+ A \`feedback\` note that is semantically a pitfall cannot be told apart
138
+ deterministically — re-filing such notes to \`pitfall\` is the agent's manual
139
+ step afterwards (the iapeer-memory-distill skill covers it).
140
+ `;
141
+
142
+ const SKILL_DISTILL = `---
143
+ name: iapeer-memory-distill
144
+ description: "Use when the user asks the agent to clean up its own memory (\\"distill your memory\\", \\"прибери свою память\\", \\"clean up your operative notes\\"). Deep MANUAL distillation of the agent's own agent-memory folder, in-session, user in the loop — deeper than the DreamWeaver weekly tick: fact-checks, re-filing, promoting team knowledge to canon."
145
+ allowed-tools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"]
146
+ ---
147
+
148
+ # Distill your own agent memory
149
+
150
+ You are cleaning YOUR OWN folder: \`<vault>/06_Agent_Memory/<your personality>/\`
151
+ (RU locale: \`06_Оперативка_агентов/<…>/\`). Identity comes from
152
+ \`PEER_PERSONALITY\` — if it is empty, refuse: you cannot know whose memory
153
+ you are touching. An absent/empty folder = nothing to distill; say so and stop.
154
+
155
+ ## The link-watershed rule (before ANY identity-changing operation)
156
+
157
+ Deleting, renaming or replacing a note splits on the wikilink graph — query
158
+ the note's connections first (vault_graph MCP tool):
159
+
160
+ - **0 incoming + 0 outgoing** → isolated; act directly (\`rm\` / rename).
161
+ - **≥1 link in either direction** → the note is part of the graph; set
162
+ \`status\` to the deprecated token instead and (if needed) write a
163
+ replacement note. The Index archives it on its PERMANENT_CHANGED pass.
164
+
165
+ Body edits that keep identity (rewording, updating description, switching
166
+ subtype) need no graph check.
167
+
168
+ ## Passes
169
+
170
+ 1. **Inventory**: list every note; for each — subtype, status, age, one-line
171
+ gist.
172
+ 2. **Dedup**: near-duplicate notes about one topic → merge into the
173
+ strongest one, deprecate the rest (watershed rule).
174
+ 3. **Compress**: bloated notes → tighten to the essentials; notes are
175
+ injected into readers' contexts, bloat costs the whole team tokens.
176
+ 4. **Verify**: notes asserting local facts (paths, flags, versions) —
177
+ re-check the fact cheaply where possible; stale → fix or deprecate.
178
+ 5. **Re-file**: \`feedback\` notes that are semantically pitfalls (a rule
179
+ born from one incident) → subtype \`pitfall\`; other mis-filed subtypes
180
+ likewise.
181
+ 6. **Promote**: material useful to the whole team → draft into the inbox
182
+ folder (canon style: self-contained, objective), keep the personal
183
+ angle in your memory note with an inline \`[[draft title]]\` link.
184
+ 7. **Report**: summary to the user — counts per pass, anything that needs
185
+ their decision.
186
+
187
+ Confirm with the user between passes 6 and 7 when the promote list is
188
+ non-empty — moving knowledge to canon is visible to the whole team.
189
+ `;
190
+
191
+ export const SKILL_BODIES: Record<SkillName, string> = {
192
+ "iapeer-memory-init": SKILL_INIT,
193
+ "iapeer-memory-status": SKILL_STATUS,
194
+ "iapeer-memory-migrate": SKILL_MIGRATE,
195
+ "iapeer-memory-distill": SKILL_DISTILL,
196
+ };
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,