@agfpd/iapeer-memory 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,29 +12,42 @@
12
12
  * 3. doctrines — re-render every role from the roles manifest with the
13
13
  * fresh version marker; roles pick it up on their next
14
14
  * cold wake (ADR-007), no restarts;
15
- * 4. slot — re-declare with the new version (contract obligation);
16
- * 5. launcherregenerate (in case the binary path moved);
17
- * 6. memoryd — MANAGED restart: verified SIGTERM via the pid file
15
+ * 4. fleet — re-write the fleet map from `iapeer list --json`;
16
+ * 5. surfacesdirect per-peer session surfaces sweep over the map
17
+ * (ADR-009 v1.2: the «всё на местах у подключённых пиров»
18
+ * duty — both runtimes, idempotent, repairs drift);
19
+ * 6. plugin-off — v1.1→v1.2 migration: when the on-disk slot still
20
+ * carries a plugin block, sweep the legacy plugin off the
21
+ * fleet (while the old declaration is STILL readable by
22
+ * the core verb) — only after surfaces landed cleanly;
23
+ * 7. slot — re-declare in the v1.2 form (provision command blocks,
24
+ * new version — contract obligation);
25
+ * 8. launcher + triggers + guide — regenerate;
26
+ * 9. memoryd — MANAGED restart: verified SIGTERM via the pid file →
18
27
  * 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).
28
+ * with the NEW binary. Not running nothing to do.
23
29
  *
24
30
  * Idempotent: same version re-run → identical/no-op on every surface.
25
31
  */
26
32
 
33
+ import fs from "node:fs";
34
+ import path from "node:path";
27
35
  import {
28
36
  configFromEnv,
29
37
  isLocaleId,
30
38
  renderDoctrine,
39
+ writeHostWideGuideFragment,
31
40
  type LocaleId,
32
41
  } from "@agfpd/iapeer-memory-core";
33
42
  import { installBinary } from "../binary.js";
43
+ import { readFleetMap, writeFleetMap } from "../fleet.js";
34
44
  import { memoryPaths } from "../paths.js";
35
45
  import { readRolesManifest } from "../roles.js";
36
- import { writeSlot } from "../slot.js";
37
- import { materialiseTemplates } from "../templates/index.js";
46
+ import { applyMemoryPlugin, readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
47
+ import { withProvisionLock } from "../surfaces/lock.js";
48
+ import { sweepProvision } from "../surfaces/sweep.js";
49
+ import { mcpPort } from "./provision-peer.js";
50
+ import { guideText, materialiseTemplates } from "../templates/index.js";
38
51
  import { packageVersion } from "../version.js";
39
52
  import {
40
53
  dreamTimerMessage,
@@ -48,8 +61,11 @@ import { stopMemorydByPidFile } from "./uninstall.js";
48
61
 
49
62
  export function cmdUpdate(argv: string[]): number {
50
63
  let skipBinary = false;
51
- for (const a of argv) {
64
+ let iapeerBin: string | undefined;
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const a = argv[i];
52
67
  if (a === "--skip-binary") skipBinary = true;
68
+ else if (a === "--iapeer-bin") iapeerBin = argv[++i];
53
69
  else {
54
70
  console.error(`iapeer-memory update: unknown flag: ${a}`);
55
71
  return 2;
@@ -119,24 +135,103 @@ export function cmdUpdate(argv: string[]): number {
119
135
  );
120
136
  }
121
137
 
122
- // 4. slot version (contract obligation)
123
- const slot = writeSlot({
124
- slotPath: paths.slotPath,
125
- version,
126
- heartbeat: paths.heartbeatPath,
127
- });
128
- step(
129
- "slot",
130
- slot.action === "refused-foreign"
131
- ? `slot held by foreign provider "${slot.existing?.provider}"not ours to update`
132
- : `${slot.action} (v${version})`,
133
- slot.action !== "refused-foreign",
134
- );
138
+ // 4. fleet map — personality → cwd × runtimes (the joint of the surfaces
139
+ // sweep below AND memoryd's fragment renderer, docs/05). BEFORE surfaces
140
+ // and BEFORE the memoryd restart: both consume the fresh map.
141
+ {
142
+ const fleet = writeFleetMap({ fleetMapPath: paths.fleetMapPath, iapeerBin });
143
+ step(
144
+ "fleet",
145
+ fleet.action === "written"
146
+ ? fleet.detail
147
+ : `fleet map not written (${fleet.detail})surfaces sweep runs on the LAST map; fragments stay stale until verify --repair`,
148
+ fleet.action === "written",
149
+ );
150
+ }
151
+
152
+ // 5. direct session surfaces sweep (ADR-009 v1.2) — the update duty:
153
+ // «всё на местах у подключённых пиров, что codex, что claude».
154
+ const existingSlot = readSlot(paths.slotPath);
155
+ const slotForeign = existingSlot !== null && existingSlot.provider !== SLOT_PROVIDER;
156
+ let surfacesOk = false;
157
+ if (slotForeign) {
158
+ step("surfaces", "skipped (foreign slot — not our host)");
159
+ } else {
160
+ const fleet = readFleetMap(paths.fleetMapPath) ?? [];
161
+ const locked = withProvisionLock({
162
+ stateDir: paths.stateDir,
163
+ fn: () => sweepProvision({ fleet, hooksDir: paths.hooksDir, port: mcpPort() }),
164
+ });
165
+ if (!locked.acquired) {
166
+ step("surfaces", locked.detail, false);
167
+ } else {
168
+ const { results, skipped } = locked.result;
169
+ const failed = results.filter((r) => !r.ok);
170
+ surfacesOk = failed.length === 0;
171
+ step(
172
+ "surfaces",
173
+ `${results.length - failed.length}/${results.length} peer-runtime(s) in place` +
174
+ (skipped.length ? `, ${skipped.length} skipped` : "") +
175
+ " — live sessions pick changes up on next restart",
176
+ surfacesOk,
177
+ );
178
+ for (const f of failed) {
179
+ console.log(
180
+ ` surfaces FAIL ${f.personality}:${f.runtime} — ${f.outcomes
181
+ .filter((o) => o.action === "failed")
182
+ .map((o) => `${o.surface}: ${o.detail ?? "failed"}`)
183
+ .join("; ")}`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+
189
+ // 6. v1.1 → v1.2 migration (one-shot per host): the on-disk slot still
190
+ // carries a plugin block — sweep the legacy plugin off the fleet WHILE the
191
+ // old declaration is still readable (the core verb derives the identity
192
+ // from it), and ONLY after the direct surfaces landed cleanly.
193
+ let migrationBlocked = false;
194
+ if (!slotForeign && existingSlot?.plugin) {
195
+ if (!surfacesOk) {
196
+ migrationBlocked = true;
197
+ step(
198
+ "plugin-off",
199
+ "POSTPONED: direct surfaces did not land cleanly — legacy plugin and v1.1 slot kept (fix and re-run update)",
200
+ false,
201
+ );
202
+ } else {
203
+ const off = applyMemoryPlugin({ mode: "off" });
204
+ step(
205
+ "plugin-off",
206
+ off.suppressed
207
+ ? "skipped (test sandbox — core calls suppressed)"
208
+ : off.ok
209
+ ? "legacy v1.1 session plugin swept off the fleet (memory-plugin off --all)"
210
+ : `legacy plugin off failed (${off.detail.slice(0, 120)}) — manual: iapeer memory-plugin off --all`,
211
+ );
212
+ }
213
+ }
214
+
215
+ // 7. slot version + v1.2 form (contract obligation). Kept v1.1 while the
216
+ // migration is blocked — the legacy channel stays derivable.
217
+ if (slotForeign) {
218
+ step("slot", `slot held by foreign provider "${existingSlot?.provider}" — not ours to update`, false);
219
+ } else if (migrationBlocked) {
220
+ step("slot", "kept v1.1 declaration (migration postponed — see plugin-off)", false);
221
+ } else {
222
+ const slot = writeSlot({
223
+ slotPath: paths.slotPath,
224
+ version,
225
+ binaryPath: paths.binaryPath,
226
+ heartbeat: paths.heartbeatPath,
227
+ });
228
+ step("slot", `${slot.action} (v${version}, provision-command declared)`, slot.action !== "refused-foreign");
229
+ }
135
230
 
136
- // 5. launcher
231
+ // 8. launcher
137
232
  step("launcher", writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath }));
138
233
 
139
- // 5b. notifier wiring (ADR-015): same-id re-send REPLACES the trigger —
234
+ // 8b. notifier wiring (ADR-015): same-id re-send REPLACES the trigger —
140
235
  // the idempotent re-target path (old hosts with target=index migrate to
141
236
  // target=scriber by this very step).
142
237
  {
@@ -169,14 +264,25 @@ export function cmdUpdate(argv: string[]): number {
169
264
  );
170
265
  }
171
266
 
172
- // 6. memoryd managed restart (the watcher relaunches with the new binary)
173
- step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} the notifier watcher relaunches it with the new binary`);
267
+ // 8c. host-wide guide update an ALREADY-ROLLED-OUT guide only
268
+ // (presence = the rollout sanction; init --skip-guide hosts stay
269
+ // untouched). Vault substituted into {{VAULT_PATH}} (дыра 10.06: the
270
+ // literal placeholder left peers without the write path).
271
+ {
272
+ const iapeerDir = path.dirname(paths.slotPath);
273
+ const guidePath = path.join(iapeerDir, "fragments", "iapeer-memory.md");
274
+ if (!fs.existsSync(guidePath)) {
275
+ step("guide", "not rolled out on this host — left untouched (roll out via init)");
276
+ } else if (!vaultPathForDoctrines) {
277
+ step("guide", "unprovisioned env — vault unknown, guide left as is", false);
278
+ } else {
279
+ writeHostWideGuideFragment(iapeerDir, guideText(locale, vaultPathForDoctrines));
280
+ step("guide", `${guidePath} re-written (v${version}, vault path substituted)`);
281
+ }
282
+ }
174
283
 
175
- // 7. plugin harness-owned surface
176
- console.log(
177
- " plugin update via the harness's native plugin flow " +
178
- "(claude/codex plugin manager); the package never reaches into harness internals",
179
- );
284
+ // 9. memoryd managed restart (the watcher relaunches with the new binary)
285
+ step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
180
286
 
181
287
  console.log(
182
288
  failures
@@ -25,9 +25,13 @@ import {
25
25
  renderDoctrine,
26
26
  renderedVersion,
27
27
  } from "@agfpd/iapeer-memory-core";
28
+ import { readFleetMap, writeFleetMap } from "../fleet.js";
28
29
  import { memoryPaths, type MemoryPaths } from "../paths.js";
29
30
  import { readRolesManifest } from "../roles.js";
30
- import { readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
31
+ import { readSlot, slotProvisionBlocks, writeSlot, SLOT_PROVIDER } from "../slot.js";
32
+ import { withProvisionLock } from "../surfaces/lock.js";
33
+ import { checkFleetSurfaces, sweepProvision } from "../surfaces/sweep.js";
34
+ import { mcpPort } from "./provision-peer.js";
31
35
  import { packageVersion } from "../version.js";
32
36
  import {
33
37
  dreamTimerMessage,
@@ -94,6 +98,11 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
94
98
 
95
99
  // 1b. memory-provider slot (iapeer memory-slot contract): a provisioned
96
100
  // host must declare the slot; a FOREIGN slot is never repaired over.
101
+ // A v1.1 slot (plugin block) is NEVER migrated here — the migration is
102
+ // update's job (plugin off --all must run while the old declaration is
103
+ // readable; a SessionStart-kicked repair racing ahead of update would
104
+ // strand the legacy plugin on the whole fleet).
105
+ let slotIsLegacyV11 = false;
97
106
  if (!configOk) {
98
107
  results.push({
99
108
  name: "memory-slot",
@@ -102,17 +111,37 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
102
111
  });
103
112
  } else {
104
113
  const slot = readSlot(paths.slotPath);
114
+ const expectedBlocks = slotProvisionBlocks(paths.binaryPath);
115
+ const formOk =
116
+ slot !== null &&
117
+ slot.plugin === undefined &&
118
+ JSON.stringify(slot.provision) === JSON.stringify(expectedBlocks.provision) &&
119
+ JSON.stringify(slot.unprovision) === JSON.stringify(expectedBlocks.unprovision);
105
120
  if (slot && slot.provider !== SLOT_PROVIDER) {
106
121
  results.push({
107
122
  name: "memory-slot",
108
123
  status: "fail",
109
124
  detail: `slot held by foreign provider "${slot.provider}" — refusing to touch (uninstall it first)`,
110
125
  });
111
- } else if (slot && slot.version === version) {
112
- results.push({ name: "memory-slot", status: "ok", detail: `declared v${slot.version}` });
126
+ } else if (slot?.plugin) {
127
+ slotIsLegacyV11 = true;
128
+ results.push({
129
+ name: "memory-slot",
130
+ status: "fail",
131
+ detail:
132
+ "slot is the legacy v1.1 form (plugin block) — migrate via `iapeer-memory update` (verify never migrates: the plugin-off order is update's duty)",
133
+ });
134
+ } else if (slot && slot.version === version && formOk) {
135
+ results.push({
136
+ name: "memory-slot",
137
+ status: "ok",
138
+ detail: `declared v${slot.version}, provision-command in place`,
139
+ });
113
140
  } else {
114
141
  const problem = slot
115
- ? `slot declares v${slot.version}, package is v${version}`
142
+ ? slot.version !== version
143
+ ? `slot declares v${slot.version}, package is v${version}`
144
+ : "provision-command block missing/drifted"
116
145
  : `slot declaration missing at ${paths.slotPath}`;
117
146
  if (!repair) {
118
147
  results.push({ name: "memory-slot", status: "fail", detail: problem });
@@ -120,6 +149,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
120
149
  const w = writeSlot({
121
150
  slotPath: paths.slotPath,
122
151
  version,
152
+ binaryPath: paths.binaryPath,
123
153
  heartbeat: paths.heartbeatPath,
124
154
  });
125
155
  results.push(
@@ -131,6 +161,119 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
131
161
  }
132
162
  }
133
163
 
164
+ // 1c. fleet map — memoryd's fragment renderer reads it (docs/05; дыра
165
+ // 10.06: без карты пиры не получали paths-блок и индекс автора). Repair
166
+ // re-writes from `iapeer list --json` — the self-healing loop for new
167
+ // peers (SessionStart kick → repair → map fresh → memoryd renders the
168
+ // newcomer on the next heartbeat tick).
169
+ if (!configOk) {
170
+ results.push({ name: "fleet-map", status: "skip", detail: "not provisioned (config check failed)" });
171
+ } else {
172
+ let mapPeers = -1; // -1 = unreadable/missing
173
+ try {
174
+ const raw = JSON.parse(fs.readFileSync(paths.fleetMapPath, "utf-8")) as {
175
+ peers?: unknown[];
176
+ };
177
+ mapPeers = Array.isArray(raw?.peers) ? raw.peers.length : -1;
178
+ } catch {
179
+ mapPeers = -1;
180
+ }
181
+ if (mapPeers > 0) {
182
+ results.push({ name: "fleet-map", status: "ok", detail: `${mapPeers} peer(s) in ${paths.fleetMapPath}` });
183
+ } else {
184
+ const problem =
185
+ mapPeers === 0
186
+ ? `fleet map is empty at ${paths.fleetMapPath}`
187
+ : `fleet map missing/unreadable at ${paths.fleetMapPath}`;
188
+ if (!repair) {
189
+ results.push({ name: "fleet-map", status: "fail", detail: problem });
190
+ } else {
191
+ const w = writeFleetMap({ fleetMapPath: paths.fleetMapPath, iapeerBin: opts.iapeerBin });
192
+ results.push(
193
+ w.action === "written"
194
+ ? { name: "fleet-map", status: "repaired", detail: `${problem} — ${w.detail}` }
195
+ : { name: "fleet-map", status: "fail", detail: `${problem}; repair failed — ${w.detail}` },
196
+ );
197
+ }
198
+ }
199
+ }
200
+
201
+ // 1d. direct per-peer session surfaces (ADR-009 v1.2) across the fleet
202
+ // map — the self-healing loop for newborns on hosts where the core's
203
+ // birth-hook lagged AND the drift-repair duty (требование №2). Skipped on
204
+ // a legacy v1.1 host: the plugin is still the live channel there, direct
205
+ // surfaces land via update's migration.
206
+ if (!configOk) {
207
+ results.push({ name: "peer-surfaces", status: "skip", detail: "not provisioned (config check failed)" });
208
+ } else if (slotIsLegacyV11) {
209
+ results.push({
210
+ name: "peer-surfaces",
211
+ status: "skip",
212
+ detail: "legacy v1.1 host (plugin channel) — direct surfaces land via `iapeer-memory update`",
213
+ });
214
+ } else {
215
+ const fleet = readFleetMap(paths.fleetMapPath);
216
+ if (!fleet) {
217
+ results.push({
218
+ name: "peer-surfaces",
219
+ status: "skip",
220
+ detail: "fleet map unreadable — see fleet-map check",
221
+ });
222
+ } else {
223
+ const { checks, skipped } = checkFleetSurfaces({
224
+ fleet,
225
+ hooksDir: paths.hooksDir,
226
+ port: mcpPort(),
227
+ });
228
+ const bad = checks.filter((c) => !c.ok);
229
+ if (bad.length === 0) {
230
+ results.push({
231
+ name: "peer-surfaces",
232
+ status: "ok",
233
+ detail:
234
+ `${checks.length} peer-runtime(s) in place` +
235
+ (skipped.length ? ` (${skipped.length} skipped: no session runtime / missing cwd)` : ""),
236
+ });
237
+ } else if (!repair) {
238
+ for (const b of bad) {
239
+ results.push({
240
+ name: `peer-surfaces[${b.personality}:${b.runtime}]`,
241
+ status: "fail",
242
+ detail: b.problems.join("; "),
243
+ });
244
+ }
245
+ } else {
246
+ const badPeers = fleet.filter((p) => bad.some((b) => b.cwd === p.cwd));
247
+ const locked = withProvisionLock({
248
+ stateDir: paths.stateDir,
249
+ fn: () => sweepProvision({ fleet: badPeers, hooksDir: paths.hooksDir, port: mcpPort() }),
250
+ });
251
+ if (!locked.acquired) {
252
+ results.push({ name: "peer-surfaces", status: "fail", detail: locked.detail });
253
+ } else {
254
+ const stillBad = locked.result.results.filter((r) => !r.ok);
255
+ results.push(
256
+ stillBad.length === 0
257
+ ? {
258
+ name: "peer-surfaces",
259
+ status: "repaired",
260
+ detail: `${bad.length} drifted peer-runtime(s) re-provisioned (${bad
261
+ .map((b) => `${b.personality}:${b.runtime}`)
262
+ .join(", ")})`,
263
+ }
264
+ : {
265
+ name: "peer-surfaces",
266
+ status: "fail",
267
+ detail: `repair failed for ${stillBad
268
+ .map((r) => `${r.personality}:${r.runtime}`)
269
+ .join(", ")}`,
270
+ },
271
+ );
272
+ }
273
+ }
274
+ }
275
+ }
276
+
134
277
  // 2. memoryd heartbeat
135
278
  try {
136
279
  const stat = fs.statSync(paths.heartbeatPath);
package/src/fleet.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Fleet map — the personality → cwd joint between the package (ecosystem
3
+ * knowledge, ADR-009) and core memoryd's per-peer fragment renderer
4
+ * (docs/05). Written from `iapeer list --json` (the registry cwd is the
5
+ * FACT — iapeer 0.2.14); memoryd reads it fail-open and re-checks the
6
+ * mtime on every heartbeat tick, so the self-healing loop is:
7
+ * new peer wakes → SessionStart kick → `verify --repair` re-writes the
8
+ * map → memoryd renders the newcomer's fragment within a tick.
9
+ *
10
+ * TEST FUSE (incident 11.06, the FOURTH of its class — first FILE-path one):
11
+ * `iapeer list` is read-only, but its RESULT is the target list of the
12
+ * surfaces sweep — a sandboxed `verify --repair` with no fleet map repaired
13
+ * the map from the LIVE registry and then swept the LIVE peers' cwds with
14
+ * direct surfaces (the send-fuse never saw it: no IAP send involved).
15
+ * Querying the live registry from a test IS the leak — so the default
16
+ * binary is refused under the sandbox fuse; tests that need a fleet pass a
17
+ * fake `iapeerBin` (as before) or write the map file directly.
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+
23
+ export type FleetMapResult = {
24
+ action: "written" | "failed";
25
+ count: number;
26
+ detail: string;
27
+ };
28
+
29
+ type ListedPeer = {
30
+ personality?: unknown;
31
+ cwd?: unknown;
32
+ /** iapeer registry: `[{runtime: "claude"|"codex"|…, status}]`. */
33
+ runtimes?: Array<{ runtime?: unknown }>;
34
+ };
35
+
36
+ /** Fleet-map entry. `runtimes` (ADR-009 v1.2) names the peer's session
37
+ * runtimes from the registry — the surfaces sweep keys its per-runtime
38
+ * forms on it (claude: hooks+mcp+skills; codex: project-local MCP).
39
+ * Core's memoryd reader takes personality/cwd only — additive, fail-open. */
40
+ export type FleetPeer = { personality: string; cwd: string; runtimes: string[] };
41
+
42
+ /** Fail-open fleet-map reader (the package side: the surfaces sweep and
43
+ * verify's per-peer checks). Missing/unreadable map → null — callers report
44
+ * honestly instead of guessing the fleet. Entries without a runtimes array
45
+ * (pre-v1.2 maps) read as `runtimes: []` — the sweep skips them until the
46
+ * next map re-write (init/update/verify --repair). */
47
+ export function readFleetMap(fleetMapPath: string): FleetPeer[] | null {
48
+ try {
49
+ const raw = JSON.parse(fs.readFileSync(fleetMapPath, "utf-8")) as {
50
+ peers?: Array<{ personality?: unknown; cwd?: unknown; runtimes?: unknown }>;
51
+ };
52
+ if (!Array.isArray(raw?.peers)) return null;
53
+ return raw.peers
54
+ .filter(
55
+ (p): p is { personality: string; cwd: string; runtimes?: unknown } =>
56
+ typeof p?.personality === "string" && typeof p?.cwd === "string",
57
+ )
58
+ .map((p) => ({
59
+ personality: p.personality,
60
+ cwd: p.cwd,
61
+ runtimes: Array.isArray(p.runtimes)
62
+ ? p.runtimes.filter((r): r is string => typeof r === "string")
63
+ : [],
64
+ }));
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export function writeFleetMap(opts: {
71
+ fleetMapPath: string;
72
+ iapeerBin?: string;
73
+ /** Injectable for tests. */
74
+ nowIso?: string;
75
+ }): FleetMapResult {
76
+ if (
77
+ opts.iapeerBin === undefined &&
78
+ (process.env.IAPEER_MEMORY_SUPPRESS_IAP_SEND === "1" ||
79
+ process.env.IAPEER_TEST_SANDBOX === "1")
80
+ ) {
81
+ return {
82
+ action: "failed",
83
+ count: 0,
84
+ detail: "live-registry query suppressed (test sandbox) — pass a fake iapeerBin",
85
+ };
86
+ }
87
+ const bin = opts.iapeerBin ?? "iapeer";
88
+ let stdout: string;
89
+ try {
90
+ const proc = Bun.spawnSync([bin, "list", "--json"], {
91
+ stdout: "pipe",
92
+ stderr: "pipe",
93
+ });
94
+ if (proc.exitCode !== 0) {
95
+ return {
96
+ action: "failed",
97
+ count: 0,
98
+ detail:
99
+ (proc.stderr.toString().trim() || `iapeer list exited ${proc.exitCode}`).slice(0, 160),
100
+ };
101
+ }
102
+ stdout = proc.stdout.toString();
103
+ } catch (err) {
104
+ return { action: "failed", count: 0, detail: `${bin} unavailable: ${String(err)}` };
105
+ }
106
+
107
+ let listed: ListedPeer[];
108
+ try {
109
+ const raw = JSON.parse(stdout) as unknown;
110
+ listed = Array.isArray(raw) ? (raw as ListedPeer[]) : [];
111
+ } catch {
112
+ return { action: "failed", count: 0, detail: "iapeer list --json: unparsable output" };
113
+ }
114
+
115
+ const peers: FleetPeer[] = listed
116
+ .filter(
117
+ (p): p is ListedPeer & { personality: string; cwd: string } =>
118
+ typeof p.personality === "string" &&
119
+ p.personality.trim() !== "" &&
120
+ typeof p.cwd === "string" &&
121
+ p.cwd.trim() !== "",
122
+ )
123
+ .map((p) => ({
124
+ personality: p.personality.trim(),
125
+ cwd: p.cwd.trim(),
126
+ runtimes: [
127
+ ...new Set(
128
+ (Array.isArray(p.runtimes) ? p.runtimes : [])
129
+ .map((r) => (typeof r?.runtime === "string" ? r.runtime.trim() : ""))
130
+ .filter(Boolean),
131
+ ),
132
+ ],
133
+ }));
134
+
135
+ const body =
136
+ JSON.stringify(
137
+ { updatedAt: opts.nowIso ?? new Date().toISOString(), peers },
138
+ null,
139
+ 2,
140
+ ) + "\n";
141
+ fs.mkdirSync(path.dirname(opts.fleetMapPath), { recursive: true });
142
+ const tmp = `${opts.fleetMapPath}.tmp`;
143
+ fs.writeFileSync(tmp, body, "utf-8");
144
+ fs.renameSync(tmp, opts.fleetMapPath); // atomic — memoryd may race a read
145
+ return {
146
+ action: "written",
147
+ count: peers.length,
148
+ detail: `${peers.length} peer(s) → ${opts.fleetMapPath}`,
149
+ };
150
+ }
package/src/paths.ts CHANGED
@@ -48,10 +48,18 @@ export type MemoryPaths = {
48
48
  binaryPath: string;
49
49
  /** Materialised package-owned templates (roles, guide) — see templates/index.ts. */
50
50
  templatesDir: string;
51
+ /** Materialised hook shims (fail-open bash, 3 lines) — the ABSOLUTE command
52
+ * paths merged into peers' `.claude/settings.json` (ownership lives IN THE
53
+ * DATA: the command path is the identity of our entries — ADR-009 v1.2). */
54
+ hooksDir: string;
51
55
  /** memoryd launcher — the notifier watcher's script (wraps the stable binary). */
52
56
  launcherPath: string;
53
57
  /** Sweep check-script — gates the fail-open inbox sweep (ADR-015). */
54
58
  checkScriptPath: string;
59
+ /** Fleet map (personality → cwd) — written by init/update/verify --repair
60
+ * from `iapeer list --json`, consumed by memoryd's fragment renderer
61
+ * (docs/05; дыра 10.06: без карты пиры не получали paths-блок и индекс). */
62
+ fleetMapPath: string;
55
63
  };
56
64
 
57
65
  export function memoryPaths(
@@ -88,8 +96,10 @@ export function memoryPaths(
88
96
  binaryPath:
89
97
  env.IAPEER_MEMORY_BINARY_PATH || path.join(home, ".local", "bin", "iapeer-memory"),
90
98
  templatesDir: path.join(path.dirname(configFile), "templates"),
99
+ hooksDir: path.join(path.dirname(configFile), "hooks"),
91
100
  launcherPath: path.join(path.dirname(configFile), "memoryd-launcher.sh"),
92
101
  checkScriptPath: path.join(path.dirname(configFile), "inbox-stale-check.sh"),
102
+ fleetMapPath: path.join(stateDir, "fleet.json"),
93
103
  };
94
104
  }
95
105