@agfpd/iapeer-memory 0.1.13 → 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,14 +12,20 @@
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
  */
@@ -34,10 +40,13 @@ import {
34
40
  type LocaleId,
35
41
  } from "@agfpd/iapeer-memory-core";
36
42
  import { installBinary } from "../binary.js";
37
- import { writeFleetMap } from "../fleet.js";
43
+ import { readFleetMap, writeFleetMap } from "../fleet.js";
38
44
  import { memoryPaths } from "../paths.js";
39
45
  import { readRolesManifest } from "../roles.js";
40
- import { writeSlot } from "../slot.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";
41
50
  import { guideText, materialiseTemplates } from "../templates/index.js";
42
51
  import { packageVersion } from "../version.js";
43
52
  import {
@@ -52,8 +61,11 @@ import { stopMemorydByPidFile } from "./uninstall.js";
52
61
 
53
62
  export function cmdUpdate(argv: string[]): number {
54
63
  let skipBinary = false;
55
- for (const a of argv) {
64
+ let iapeerBin: string | undefined;
65
+ for (let i = 0; i < argv.length; i++) {
66
+ const a = argv[i];
56
67
  if (a === "--skip-binary") skipBinary = true;
68
+ else if (a === "--iapeer-bin") iapeerBin = argv[++i];
57
69
  else {
58
70
  console.error(`iapeer-memory update: unknown flag: ${a}`);
59
71
  return 2;
@@ -123,24 +135,103 @@ export function cmdUpdate(argv: string[]): number {
123
135
  );
124
136
  }
125
137
 
126
- // 4. slot version (contract obligation)
127
- const slot = writeSlot({
128
- slotPath: paths.slotPath,
129
- version,
130
- heartbeat: paths.heartbeatPath,
131
- });
132
- step(
133
- "slot",
134
- slot.action === "refused-foreign"
135
- ? `slot held by foreign provider "${slot.existing?.provider}"not ours to update`
136
- : `${slot.action} (v${version})`,
137
- slot.action !== "refused-foreign",
138
- );
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
+ }
139
151
 
140
- // 5. launcher
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
+ }
230
+
231
+ // 8. launcher
141
232
  step("launcher", writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath }));
142
233
 
143
- // 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 —
144
235
  // the idempotent re-target path (old hosts with target=index migrate to
145
236
  // target=scriber by this very step).
146
237
  {
@@ -173,7 +264,7 @@ export function cmdUpdate(argv: string[]): number {
173
264
  );
174
265
  }
175
266
 
176
- // 5c. host-wide guide — update an ALREADY-ROLLED-OUT guide only
267
+ // 8c. host-wide guide — update an ALREADY-ROLLED-OUT guide only
177
268
  // (presence = the rollout sanction; init --skip-guide hosts stay
178
269
  // untouched). Vault substituted into {{VAULT_PATH}} (дыра 10.06: the
179
270
  // literal placeholder left peers without the write path).
@@ -190,29 +281,9 @@ export function cmdUpdate(argv: string[]): number {
190
281
  }
191
282
  }
192
283
 
193
- // 5d. fleet map personality cwd for memoryd's fragment renderer
194
- // (docs/05; дыра 10.06). BEFORE the memoryd restart below: the fresh
195
- // daemon renders the whole fleet at startup from this very map.
196
- {
197
- const fleet = writeFleetMap({ fleetMapPath: paths.fleetMapPath });
198
- step(
199
- "fleet",
200
- fleet.action === "written"
201
- ? fleet.detail
202
- : `fleet map not written (${fleet.detail}) — fragments stay stale until verify --repair`,
203
- fleet.action === "written",
204
- );
205
- }
206
-
207
- // 6. memoryd managed restart (the watcher relaunches with the new binary)
284
+ // 9. memoryd managed restart (the watcher relaunches with the new binary)
208
285
  step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
209
286
 
210
- // 7. plugin — harness-owned surface
211
- console.log(
212
- " plugin update via the harness's native plugin flow " +
213
- "(claude/codex plugin manager); the package never reaches into harness internals",
214
- );
215
-
216
287
  console.log(
217
288
  failures
218
289
  ? `\nupdate finished with ${failures} problem(s) — iapeer-memory verify --repair`
@@ -25,10 +25,13 @@ import {
25
25
  renderDoctrine,
26
26
  renderedVersion,
27
27
  } from "@agfpd/iapeer-memory-core";
28
- import { writeFleetMap } from "../fleet.js";
28
+ import { readFleetMap, writeFleetMap } from "../fleet.js";
29
29
  import { memoryPaths, type MemoryPaths } from "../paths.js";
30
30
  import { readRolesManifest } from "../roles.js";
31
- 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";
32
35
  import { packageVersion } from "../version.js";
33
36
  import {
34
37
  dreamTimerMessage,
@@ -95,6 +98,11 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
95
98
 
96
99
  // 1b. memory-provider slot (iapeer memory-slot contract): a provisioned
97
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;
98
106
  if (!configOk) {
99
107
  results.push({
100
108
  name: "memory-slot",
@@ -103,17 +111,37 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
103
111
  });
104
112
  } else {
105
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);
106
120
  if (slot && slot.provider !== SLOT_PROVIDER) {
107
121
  results.push({
108
122
  name: "memory-slot",
109
123
  status: "fail",
110
124
  detail: `slot held by foreign provider "${slot.provider}" — refusing to touch (uninstall it first)`,
111
125
  });
112
- } else if (slot && slot.version === version) {
113
- 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
+ });
114
140
  } else {
115
141
  const problem = slot
116
- ? `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"
117
145
  : `slot declaration missing at ${paths.slotPath}`;
118
146
  if (!repair) {
119
147
  results.push({ name: "memory-slot", status: "fail", detail: problem });
@@ -121,6 +149,7 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
121
149
  const w = writeSlot({
122
150
  slotPath: paths.slotPath,
123
151
  version,
152
+ binaryPath: paths.binaryPath,
124
153
  heartbeat: paths.heartbeatPath,
125
154
  });
126
155
  results.push(
@@ -169,6 +198,82 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
169
198
  }
170
199
  }
171
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
+
172
277
  // 2. memoryd heartbeat
173
278
  try {
174
279
  const stat = fs.statSync(paths.heartbeatPath);
package/src/fleet.ts CHANGED
@@ -7,9 +7,14 @@
7
7
  * new peer wakes → SessionStart kick → `verify --repair` re-writes the
8
8
  * map → memoryd renders the newcomer's fragment within a tick.
9
9
  *
10
- * `iapeer list` is READ-ONLY on the host no test fuse needed here
11
- * (the fuse class guards host MUTATIONS); deterministic tests pass a
12
- * fake `iapeerBin` instead.
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.
13
18
  */
14
19
 
15
20
  import fs from "node:fs";
@@ -21,7 +26,46 @@ export type FleetMapResult = {
21
26
  detail: string;
22
27
  };
23
28
 
24
- type ListedPeer = { personality?: unknown; cwd?: unknown };
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
+ }
25
69
 
26
70
  export function writeFleetMap(opts: {
27
71
  fleetMapPath: string;
@@ -29,6 +73,17 @@ export function writeFleetMap(opts: {
29
73
  /** Injectable for tests. */
30
74
  nowIso?: string;
31
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
+ }
32
87
  const bin = opts.iapeerBin ?? "iapeer";
33
88
  let stdout: string;
34
89
  try {
@@ -57,15 +112,25 @@ export function writeFleetMap(opts: {
57
112
  return { action: "failed", count: 0, detail: "iapeer list --json: unparsable output" };
58
113
  }
59
114
 
60
- const peers = listed
115
+ const peers: FleetPeer[] = listed
61
116
  .filter(
62
- (p): p is { personality: string; cwd: string } =>
117
+ (p): p is ListedPeer & { personality: string; cwd: string } =>
63
118
  typeof p.personality === "string" &&
64
119
  p.personality.trim() !== "" &&
65
120
  typeof p.cwd === "string" &&
66
121
  p.cwd.trim() !== "",
67
122
  )
68
- .map((p) => ({ personality: p.personality.trim(), cwd: p.cwd.trim() }));
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
+ }));
69
134
 
70
135
  const body =
71
136
  JSON.stringify(
package/src/paths.ts CHANGED
@@ -48,6 +48,10 @@ 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). */
@@ -92,6 +96,7 @@ export function memoryPaths(
92
96
  binaryPath:
93
97
  env.IAPEER_MEMORY_BINARY_PATH || path.join(home, ".local", "bin", "iapeer-memory"),
94
98
  templatesDir: path.join(path.dirname(configFile), "templates"),
99
+ hooksDir: path.join(path.dirname(configFile), "hooks"),
95
100
  launcherPath: path.join(path.dirname(configFile), "memoryd-launcher.sh"),
96
101
  checkScriptPath: path.join(path.dirname(configFile), "inbox-stale-check.sh"),
97
102
  fleetMapPath: path.join(stateDir, "fleet.json"),
package/src/slot.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Memory-provider slot declaration — the iapeer memory-slot contract (FINAL,
3
- * iapeer docs fc68c54/e2195a7/c968219). The slot file tells the core that
4
- * the three public surfaces (layer-5 fragments / MCP tools / daemon under a
5
- * notifier watcher) are occupied:
2
+ * Memory-provider slot declaration — the iapeer memory-slot contract (FINAL
3
+ * base, iapeer docs fc68c54/e2195a7/c968219; v1.2 revision agreed 11.06).
4
+ * The slot file tells the core that the three public surfaces (layer-5
5
+ * fragments / MCP tools / daemon under a notifier watcher) are occupied:
6
6
  *
7
7
  * - the PROVIDER writes and removes the file (our init/uninstall), atomic
8
8
  * temp+rename; the core only reads it (absent/unreadable = empty slot);
@@ -12,14 +12,21 @@
12
12
  * marker, ADR-010); our `update` re-writes it (P4 obligation);
13
13
  * - `heartbeat` (optional) = the absolute path whose mtime memoryd touches —
14
14
  * the core may show staleness in `iapeer status`, never acts on it;
15
- * - `plugin` (v1.1, agreed 10.06 + live in iapeer 0.2.25) = the marketplace
16
- * identity of the session plugin. The core DERIVES installs from this block:
17
- * birth-hook installs for new peers, `iapeer memory-plugin on|off (--peer|
18
- * --all)` is the operator verb (built-in marketplace ensure + stale-cache
19
- * retry). Reference reader: iapeer src/status/index.ts parsePluginBlock
20
- * all three fields required non-empty, anything less = treated as a v1
21
- * declaration (no install). marketplaceRef matches iapeer onboard's
22
- * MARKETPLACE_REF for the distribution default.
15
+ * - `provision`/`unprovision` (v1.2, ADR-009 v1.2 boris's birth-joint
16
+ * inversion, schema fixed with the core 11.06): the PROVIDER's OWN command
17
+ * the core shells into at peer birth / verb sweeps / peer removal. The
18
+ * core never learns the surface forms; placeholders {cwd} {runtime}
19
+ * {personality} {occasion} substitute PER-ARGUMENT (argv spawn, no shell,
20
+ * 120s timeout, best-effort + loud warn). Precedence at the core:
21
+ * provision > plugin with NO runtime fallback;
22
+ * - `plugin` (v1.1, deprecated by v1.2): we no longer WRITE it — holding
23
+ * both blocks would make an old core re-install the plugin we swept
24
+ * (agreed 11.06). An old core reads our v1.2 slot as «provider without a
25
+ * plugin» and honestly skips the birth install; the newborn is picked up
26
+ * by the verify --repair sweep. RELEASE ORDER closes even that window on
27
+ * this host: the core ships its v1.2 parser FIRST, our release follows.
28
+ * The type keeps the field so uninstall/update can MIGRATE old slots
29
+ * (plugin off --all while the block is still readable).
23
30
  */
24
31
 
25
32
  import fs from "node:fs";
@@ -28,7 +35,8 @@ import path from "node:path";
28
35
  export const SLOT_PROVIDER = "iapeer-memory";
29
36
  export const SLOT_PACKAGE = "@agfpd/iapeer-memory";
30
37
 
31
- /** Mirror of iapeer's MemoryProviderPlugin (src/status/index.ts). */
38
+ /** Mirror of iapeer's MemoryProviderPlugin (src/status/index.ts). v1.1
39
+ * legacy: READ-only here (migration off-path); v1.2 slots no longer carry it. */
32
40
  export type MemoryProviderPlugin = {
33
41
  /** Plugin id in the marketplace (forms `<name>@<marketplace>`). */
34
42
  name: string;
@@ -38,19 +46,54 @@ export type MemoryProviderPlugin = {
38
46
  marketplaceRef: string;
39
47
  };
40
48
 
41
- export const SLOT_PLUGIN: MemoryProviderPlugin = {
42
- name: "iapeer-memory",
43
- marketplace: "agfpd",
44
- marketplaceRef: "agfpd/agfpd-marketplace",
49
+ /** v1.2 provision command block — argv form (§7 req 1: per-argument
50
+ * placeholder substitution, spawn without a shell). */
51
+ export type MemoryProviderCommand = {
52
+ /** Absolute path (§7 req 2: birth-hooks live in a minimal launchd PATH). */
53
+ command: string;
54
+ args: string[];
45
55
  };
46
56
 
57
+ /** The provision/unprovision blocks of OUR slot — built around the stable
58
+ * installed binary (the same path the hooks/watcher rely on). */
59
+ export function slotProvisionBlocks(binaryPath: string): {
60
+ provision: MemoryProviderCommand;
61
+ unprovision: MemoryProviderCommand;
62
+ } {
63
+ return {
64
+ provision: {
65
+ command: binaryPath,
66
+ args: [
67
+ "provision-peer",
68
+ "--cwd", "{cwd}",
69
+ "--runtime", "{runtime}",
70
+ "--personality", "{personality}",
71
+ "--occasion", "{occasion}",
72
+ ],
73
+ },
74
+ unprovision: {
75
+ command: binaryPath,
76
+ args: [
77
+ "unprovision-peer",
78
+ "--cwd", "{cwd}",
79
+ "--runtime", "{runtime}",
80
+ "--occasion", "{occasion}",
81
+ ],
82
+ },
83
+ };
84
+ }
85
+
47
86
  export type MemoryProviderSlot = {
48
87
  provider: string;
49
88
  package: string;
50
89
  version: string;
51
90
  registeredAt: string;
52
91
  heartbeat?: string;
92
+ /** v1.1 legacy (read for migration; never written by v1.2 code). */
53
93
  plugin?: MemoryProviderPlugin;
94
+ /** v1.2 (ADR-009 v1.2). */
95
+ provision?: MemoryProviderCommand;
96
+ unprovision?: MemoryProviderCommand;
54
97
  };
55
98
 
56
99
  /** Never throws: missing / unreadable / malformed → null (empty slot). */
@@ -72,6 +115,8 @@ export type SlotWriteResult = {
72
115
  export function writeSlot(opts: {
73
116
  slotPath: string;
74
117
  version: string;
118
+ /** Absolute path of the installed binary — the provision command carrier. */
119
+ binaryPath: string;
75
120
  heartbeat?: string;
76
121
  /** Injectable for tests. */
77
122
  nowIso?: string;
@@ -80,15 +125,15 @@ export function writeSlot(opts: {
80
125
  if (existing && existing.provider !== SLOT_PROVIDER) {
81
126
  return { action: "refused-foreign", existing };
82
127
  }
128
+ const blocks = slotProvisionBlocks(opts.binaryPath);
83
129
  if (
84
130
  existing &&
85
131
  existing.version === opts.version &&
86
132
  existing.heartbeat === opts.heartbeat &&
87
133
  existing.package === SLOT_PACKAGE &&
88
- existing.plugin &&
89
- existing.plugin.name === SLOT_PLUGIN.name &&
90
- existing.plugin.marketplace === SLOT_PLUGIN.marketplace &&
91
- existing.plugin.marketplaceRef === SLOT_PLUGIN.marketplaceRef
134
+ existing.plugin === undefined && // a v1.1 slot (plugin block) must MIGRATE to the v1.2 form
135
+ JSON.stringify(existing.provision) === JSON.stringify(blocks.provision) &&
136
+ JSON.stringify(existing.unprovision) === JSON.stringify(blocks.unprovision)
92
137
  ) {
93
138
  return { action: "identical", existing }; // idempotent re-init: no churn
94
139
  }
@@ -98,7 +143,7 @@ export function writeSlot(opts: {
98
143
  version: opts.version,
99
144
  registeredAt: opts.nowIso ?? new Date().toISOString(),
100
145
  ...(opts.heartbeat ? { heartbeat: opts.heartbeat } : {}),
101
- plugin: SLOT_PLUGIN,
146
+ ...blocks,
102
147
  };
103
148
  fs.mkdirSync(path.dirname(opts.slotPath), { recursive: true });
104
149
  const tmp = `${opts.slotPath}.tmp`;