@agfpd/iapeer-memory 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer-memory",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "iapeer-memory — peer memory for the iapeer ecosystem: vault, memoryd (index/search/MCP-http), layer-5 context fragments, role doctrines. The package IS the system; the claude/codex plugins are thin session sockets (docs/10-distribution.md, ADR-009).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@agfpd/iapeer-memory-core": "0.1.2"
30
+ "@agfpd/iapeer-memory-core": "0.1.4"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/bun": "^1.2.0",
package/src/binary.ts CHANGED
@@ -23,13 +23,14 @@
23
23
 
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
+ import { signInstalledBinary, type SigningOutcome } from "./signing.js";
26
27
 
27
28
  export function isCompiledRuntime(): boolean {
28
29
  return import.meta.url.includes("/$bunfs/");
29
30
  }
30
31
 
31
32
  export type InstallBinaryOutcome =
32
- | { action: "compiled"; outPath: string; bytes: number }
33
+ | { action: "compiled"; outPath: string; bytes: number; signing: SigningOutcome }
33
34
  | { action: "skipped-compiled"; outPath: string }
34
35
  | { action: "failed"; outPath: string; detail: string };
35
36
 
@@ -65,7 +66,10 @@ export function installBinary(opts: { outPath: string }): InstallBinaryOutcome {
65
66
 
66
67
  fs.chmodSync(tmp, 0o755);
67
68
  fs.renameSync(tmp, outPath); // atomic swap — safe over a running binary on macOS
68
- return { action: "compiled", outPath, bytes: fs.statSync(outPath).size };
69
+ // Stable-identity re-sign on EVERY compile path (TCC grants survive
70
+ // updates — contract with iapeer, see signing.ts). Soft-fail by design.
71
+ const signing = signInstalledBinary(outPath);
72
+ return { action: "compiled", outPath, bytes: fs.statSync(outPath).size, signing };
69
73
  }
70
74
 
71
75
  export function removeBinary(outPath: string): "removed" | "absent" {
@@ -35,13 +35,23 @@ import { provisionVault, writeDefaultConfig } from "../provision.js";
35
35
  import { writeRolesManifest, type RoleEntry } from "../roles.js";
36
36
  import { writeSlot } from "../slot.js";
37
37
  import {
38
+ doctrineOwnership,
38
39
  guideText,
39
40
  materialiseTemplates,
41
+ rolePersonality,
40
42
  roleTemplatePath,
41
43
  ROLE_NAMES,
42
44
  } from "../templates/index.js";
43
45
  import { packageVersion } from "../version.js";
44
- import { registerWatcher, writeLauncherScript } from "../watcher.js";
46
+ import {
47
+ dreamTimerMessage,
48
+ patchWakePolicyEphemeral,
49
+ registerTimer,
50
+ registerWatcher,
51
+ sweepTimerMessage,
52
+ writeLauncherScript,
53
+ writeStaleCheckScript,
54
+ } from "../watcher.js";
45
55
 
46
56
  type InitFlags = {
47
57
  vault?: string;
@@ -53,6 +63,10 @@ type InitFlags = {
53
63
  skipDeps: boolean;
54
64
  skipEcosystem: boolean;
55
65
  skipBinary: boolean;
66
+ /** Skip the host-wide guide fragment (staged fleet rollout — cutover 10.06:
67
+ * the fleet still carries the MergeMind guide; ours lands by a separate
68
+ * decision after the plugin swap). */
69
+ skipGuide: boolean;
56
70
  iapeerBin: string;
57
71
  };
58
72
 
@@ -62,6 +76,7 @@ function parseFlags(argv: string[]): InitFlags | null {
62
76
  skipDeps: false,
63
77
  skipEcosystem: false,
64
78
  skipBinary: false,
79
+ skipGuide: false,
65
80
  iapeerBin: "iapeer",
66
81
  };
67
82
  for (let i = 0; i < argv.length; i++) {
@@ -77,6 +92,7 @@ function parseFlags(argv: string[]): InitFlags | null {
77
92
  case "--skip-deps": f.skipDeps = true; break;
78
93
  case "--skip-ecosystem": f.skipEcosystem = true; break;
79
94
  case "--skip-binary": f.skipBinary = true; break;
95
+ case "--skip-guide": f.skipGuide = true; break;
80
96
  case "--iapeer-bin": f.iapeerBin = take() ?? "iapeer"; break;
81
97
  default:
82
98
  console.error(`iapeer-memory init: unknown flag: ${a}`);
@@ -241,7 +257,9 @@ export async function cmdInit(argv: string[]): Promise<number> {
241
257
  step(
242
258
  "binary",
243
259
  bin.action === "compiled"
244
- ? `${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB)`
260
+ ? `${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB; signing: ${bin.signing.state}` +
261
+ `${bin.signing.state === "signed-new-identity" ? " — the one install-time keychain event" : ""}` +
262
+ `${bin.signing.state === "failed-soft" ? ` — ${bin.signing.detail}` : ""})`
245
263
  : bin.action === "skipped-compiled"
246
264
  ? `kept existing ${bin.outPath} (running from the installed binary)`
247
265
  : `compile failed — ${bin.detail}`,
@@ -253,7 +271,9 @@ export async function cmdInit(argv: string[]): Promise<number> {
253
271
  const tmpl = materialiseTemplates({ templatesDir: paths.templatesDir, locale });
254
272
  step("templates", `${paths.templatesDir} (${tmpl.written.length} written, ${tmpl.identical.length} identical)`);
255
273
 
256
- // 6. role peers + doctrines + manifest
274
+ // 6. role peers + doctrines + manifest. Personalities are namespaced
275
+ // memory-<role> (collision-proof by design); the manifest keeps the
276
+ // CONCEPTUAL role keys.
257
277
  if (flags.skipEcosystem) {
258
278
  step("roles", "skipped (--skip-ecosystem)");
259
279
  } else {
@@ -261,26 +281,42 @@ export async function cmdInit(argv: string[]): Promise<number> {
261
281
  let rolesOk = true;
262
282
  let createdAny = false;
263
283
  for (const role of ROLE_NAMES) {
264
- const exists = (peers ?? []).some((p) => p.personality === role);
284
+ const personality = rolePersonality(role);
285
+ const exists = (peers ?? []).some((p) => p.personality === personality);
265
286
  if (!exists) {
266
- const created = run([flags.iapeerBin, "create", role]);
287
+ const created = run([flags.iapeerBin, "create", personality]);
267
288
  if (created.exitCode !== 0) {
268
289
  rolesOk = false;
269
- console.log(` roles create ${role} failed: ${created.stderr.trim()}`);
290
+ console.log(` roles create ${personality} failed: ${created.stderr.trim()}`);
270
291
  continue;
271
292
  }
272
293
  createdAny = true;
273
294
  }
274
295
  }
275
296
  // peerCwd: the registry FACT when the core exposes it (`cwd` in
276
- // `iapeer list --json` — iapeer 39de94b, next core release), otherwise
277
- // the core's DOCUMENTED create default (no --path — требование Артура;
278
- // IAPEER_ROOT-aware). Forward-compatible: switches to the fact
279
- // automatically once the deployed core ships the field.
297
+ // `iapeer list --json` — iapeer 0.2.14), otherwise the core's
298
+ // DOCUMENTED create default (no --path — требование Артура;
299
+ // IAPEER_ROOT-aware).
280
300
  const freshPeers = createdAny ? listPeers(flags.iapeerBin) : peers;
281
301
  for (const role of ROLE_NAMES) {
282
- const registryCwd = (freshPeers ?? []).find((p) => p.personality === role)?.cwd;
283
- const peerCwd = registryCwd || path.join(iapeerDir, "peers", role);
302
+ const personality = rolePersonality(role);
303
+ const registryCwd = (freshPeers ?? []).find((p) => p.personality === personality)?.cwd;
304
+ const peerCwd = registryCwd || path.join(iapeerDir, "peers", personality);
305
+ // COLLISION GUARD (прецедент 10.06: «index» был занят живым
306
+ // MergeMind-Индексом; бренд-имена ролей — решение Артура, защита
307
+ // целиком здесь): a pre-existing peer whose doctrine is NOT ours is
308
+ // somebody else's — rendering over it would hijack a live peer.
309
+ // FAIL loud with a recipe, never render.
310
+ if (doctrineOwnership(peerCwd) === "foreign") {
311
+ rolesOk = false;
312
+ console.log(
313
+ ` roles COLLISION: peer "${personality}" exists with a foreign doctrine ` +
314
+ `(${path.join(peerCwd, ".iapeer", "IAPEER.md")}) — not touching it. Recipe: ` +
315
+ `rename/remove that peer (iapeer stop ${personality} && iapeer remove ${personality}) ` +
316
+ `or move its cwd, then re-run init`,
317
+ );
318
+ continue;
319
+ }
284
320
  const template = roleTemplatePath(paths.templatesDir, locale, role);
285
321
  const rendered = renderDoctrine({ templatePath: template, peerCwd, version });
286
322
  if (rendered.action === "missing-template") {
@@ -291,17 +327,26 @@ export async function cmdInit(argv: string[]): Promise<number> {
291
327
  roleEntries.push({ role, peerCwd, template });
292
328
  }
293
329
  writeRolesManifest({ rolesManifestPath: paths.rolesManifestPath, roles: roleEntries });
330
+ // The copywriter is the inverted pipeline's first receiver — an
331
+ // EPHEMERAL worker (clean window per delivery, ADR-015): patch one key
332
+ // into its core-owned profile, no-clobber.
333
+ const cwEntry = roleEntries.find((r) => r.role === "copywriter");
334
+ const wakePolicy = cwEntry ? patchWakePolicyEphemeral(cwEntry.peerCwd) : "missing-profile";
294
335
  step(
295
336
  "roles",
296
- `${roleEntries.map((r) => r.role).join(", ")} (doctrines v${version}, manifest ${paths.rolesManifestPath})`,
297
- rolesOk && roleEntries.length === ROLE_NAMES.length,
337
+ `${roleEntries.map((r) => rolePersonality(r.role as (typeof ROLE_NAMES)[number])).join(", ")} ` +
338
+ `(doctrines v${version}, copywriter wake_policy: ${wakePolicy}, manifest ${paths.rolesManifestPath})`,
339
+ rolesOk && roleEntries.length === ROLE_NAMES.length && wakePolicy !== "missing-profile",
298
340
  );
299
341
  }
300
342
 
301
- // 7. watcher registration (registrant = index; reply goes to the index
302
- // session confirmation is the durable profile, checked by verify)
343
+ // 7. notifier wiring (ADR-015, инверсия): the EVENT trigger targets the
344
+ // COPYWRITER (first receiver); two TIMERS target the index weekly
345
+ // DREAM_TICK and the check-gated fail-open sweep. Registrant = index for
346
+ // all three (one durable profile to verify); same-id re-send = replace.
303
347
  if (flags.skipEcosystem) {
304
348
  step("watcher", "skipped (--skip-ecosystem)");
349
+ step("timers", "skipped (--skip-ecosystem)");
305
350
  } else {
306
351
  writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath });
307
352
  const sent = registerWatcher({
@@ -310,10 +355,36 @@ export async function cmdInit(argv: string[]): Promise<number> {
310
355
  });
311
356
  step(
312
357
  "watcher",
313
- sent.ok
314
- ? `registration sent (${paths.launcherPath} target index); confirm: iapeer-memory verify`
315
- : `registration failed — ${sent.detail}`,
316
- sent.ok,
358
+ sent.suppressed
359
+ ? "skipped (test sandbox sends suppressed)"
360
+ : sent.ok
361
+ ? `registration sent (${paths.launcherPath} → target copywriter); confirm: iapeer-memory verify`
362
+ : `registration failed — ${sent.detail}`,
363
+ sent.ok || Boolean(sent.suppressed),
364
+ );
365
+
366
+ writeStaleCheckScript({
367
+ checkScriptPath: paths.checkScriptPath,
368
+ vaultPath: vault,
369
+ inboxFolders: [getTaxonomy(locale).folders.inbox, getTaxonomy(locale).folders.inboxHuman],
370
+ });
371
+ const sweep = registerTimer({
372
+ message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
373
+ iapeerBin: flags.iapeerBin,
374
+ });
375
+ const dream = registerTimer({
376
+ message: dreamTimerMessage(),
377
+ iapeerBin: flags.iapeerBin,
378
+ });
379
+ const timersSandboxed = sweep.suppressed && dream.suppressed;
380
+ step(
381
+ "timers",
382
+ timersSandboxed
383
+ ? "skipped (test sandbox — sends suppressed)"
384
+ : sweep.ok && dream.ok
385
+ ? `sweep (@every 1h, check ${paths.checkScriptPath}) + dream-tick (weekly) → target index`
386
+ : `sweep: ${sweep.ok ? "sent" : sweep.detail}; dream: ${dream.ok ? "sent" : dream.detail}`,
387
+ Boolean(timersSandboxed) || (sweep.ok && dream.ok),
317
388
  );
318
389
  }
319
390
 
@@ -353,8 +424,12 @@ export async function cmdInit(argv: string[]): Promise<number> {
353
424
  }
354
425
 
355
426
  // 10. host-wide guide fragment (layer 5 — reaches every peer on next wakes)
356
- const guidePath = writeHostWideGuideFragment(iapeerDir, guideText(locale));
357
- step("guide", guidePath);
427
+ if (flags.skipGuide) {
428
+ step("guide", "skipped (--skip-guide) — roll out by a separate decision after the fleet plugin swap");
429
+ } else {
430
+ const guidePath = writeHostWideGuideFragment(iapeerDir, guideText(locale));
431
+ step("guide", guidePath);
432
+ }
358
433
 
359
434
  console.log(
360
435
  failures
@@ -28,7 +28,8 @@ export function cmdInstallBinary(argv: string[]): number {
28
28
  switch (outcome.action) {
29
29
  case "compiled":
30
30
  console.log(
31
- `install-binary: compiled ${outcome.outPath} (${Math.round(outcome.bytes / 1024 / 1024)}MB)`,
31
+ `install-binary: compiled ${outcome.outPath} (${Math.round(outcome.bytes / 1024 / 1024)}MB; ` +
32
+ `signing: ${outcome.signing.state}${outcome.signing.state === "failed-soft" ? ` — ${outcome.signing.detail}` : ""})`,
32
33
  );
33
34
  return 0;
34
35
  case "skipped-compiled":
@@ -147,7 +147,9 @@ export async function cmdStatus(argv: string[]): Promise<number> {
147
147
  }
148
148
  console.log(
149
149
  ` ${"inbox".padEnd(width)} ` +
150
- (count < 0 ? `folder missing (${inboxDir})` : `${count} draft(s) awaiting the Index`),
150
+ (count < 0
151
+ ? `folder missing (${inboxDir})`
152
+ : `${count} draft(s) awaiting the pipeline (a growing pile = the copywriter thread is stuck; the sweep places stale drafts unvetted)`),
151
153
  );
152
154
  }
153
155
 
@@ -19,7 +19,13 @@ import fs from "node:fs";
19
19
  import { memoryPaths } from "../paths.js";
20
20
  import { removeBinary } from "../binary.js";
21
21
  import { removeSlot } from "../slot.js";
22
- import { unregisterWatcher, WATCHER_TRIGGER_ID } from "../watcher.js";
22
+ import {
23
+ DREAM_TRIGGER_ID,
24
+ SWEEP_TRIGGER_ID,
25
+ unregisterTimer,
26
+ unregisterWatcher,
27
+ WATCHER_TRIGGER_ID,
28
+ } from "../watcher.js";
23
29
 
24
30
  /**
25
31
  * Owner verification before signalling: the process command line must look
@@ -97,8 +103,8 @@ export function cmdUninstall(argv: string[]): number {
97
103
  console.log(`slot : ${slot === "removed" ? "declaration removed" : "already absent"}`);
98
104
  }
99
105
 
100
- // watcher: best-effort unregister (not-found is soft on the notifier side;
101
- // the teaching reply goes to the index session, not here).
106
+ // notifier wiring: best-effort unregister of all three triggers (not-found
107
+ // is soft on the notifier side; teaching replies go to the index session).
102
108
  const unreg = unregisterWatcher({ iapeerBin });
103
109
  console.log(
104
110
  `watcher : ${
@@ -107,6 +113,16 @@ export function cmdUninstall(argv: string[]): number {
107
113
  : `unregister not sent (${unreg.detail}) — remove the trigger manually via the watcher peer`
108
114
  }`,
109
115
  );
116
+ for (const id of [SWEEP_TRIGGER_ID, DREAM_TRIGGER_ID]) {
117
+ const t = unregisterTimer({ id, iapeerBin });
118
+ console.log(
119
+ `timer : ${
120
+ t.ok
121
+ ? `unregister sent for ${id}`
122
+ : `unregister not sent for ${id} (${t.detail}) — remove manually via the timer peer`
123
+ }`,
124
+ );
125
+ }
110
126
 
111
127
  console.log(`memoryd : ${stopMemorydByPidFile(paths.pidPath)}`);
112
128
 
@@ -25,6 +25,7 @@
25
25
  */
26
26
 
27
27
  import {
28
+ configFromEnv,
28
29
  isLocaleId,
29
30
  renderDoctrine,
30
31
  type LocaleId,
@@ -35,7 +36,14 @@ import { readRolesManifest } from "../roles.js";
35
36
  import { writeSlot } from "../slot.js";
36
37
  import { materialiseTemplates } from "../templates/index.js";
37
38
  import { packageVersion } from "../version.js";
38
- import { writeLauncherScript } from "../watcher.js";
39
+ import {
40
+ dreamTimerMessage,
41
+ registerTimer,
42
+ registerWatcher,
43
+ sweepTimerMessage,
44
+ writeLauncherScript,
45
+ writeStaleCheckScript,
46
+ } from "../watcher.js";
39
47
  import { stopMemorydByPidFile } from "./uninstall.js";
40
48
 
41
49
  export function cmdUpdate(argv: string[]): number {
@@ -72,7 +80,8 @@ export function cmdUpdate(argv: string[]): number {
72
80
  step(
73
81
  "binary",
74
82
  bin.action === "compiled"
75
- ? `recompiled ${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB)`
83
+ ? `recompiled ${bin.outPath} (${Math.round(bin.bytes / 1024 / 1024)}MB; signing: ${bin.signing.state}` +
84
+ `${bin.signing.state === "failed-soft" ? ` — ${bin.signing.detail}` : ""})`
76
85
  : bin.action === "skipped-compiled"
77
86
  ? "running FROM the installed binary — recompile via: npx @agfpd/iapeer-memory@latest update"
78
87
  : `compile failed — ${bin.detail}`,
@@ -120,6 +129,39 @@ export function cmdUpdate(argv: string[]): number {
120
129
  // 5. launcher
121
130
  step("launcher", writeLauncherScript({ launcherPath: paths.launcherPath, binaryPath: paths.binaryPath }));
122
131
 
132
+ // 5b. notifier wiring (ADR-015): same-id re-send REPLACES the trigger —
133
+ // the idempotent re-target path (old hosts with target=index migrate to
134
+ // target=copywriter by this very step).
135
+ {
136
+ let inboxFolders: string[] | null = null;
137
+ try {
138
+ const config = configFromEnv();
139
+ inboxFolders = [config.taxonomy.folders.inbox, config.taxonomy.folders.inboxHuman];
140
+ writeStaleCheckScript({
141
+ checkScriptPath: paths.checkScriptPath,
142
+ vaultPath: config.vaultPath,
143
+ inboxFolders,
144
+ });
145
+ } catch {
146
+ // unprovisioned env — registrations below still re-target
147
+ }
148
+ const w = registerWatcher({ launcherPath: paths.launcherPath });
149
+ const s = registerTimer({
150
+ message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
151
+ });
152
+ const d = registerTimer({ message: dreamTimerMessage() });
153
+ const sandboxed = w.suppressed && s.suppressed && d.suppressed;
154
+ step(
155
+ "triggers",
156
+ sandboxed
157
+ ? "skipped (test sandbox — sends suppressed)"
158
+ : w.ok && s.ok && d.ok
159
+ ? `re-sent: event→copywriter, sweep+dream→index (same id = replace); confirm: verify`
160
+ : `event: ${w.ok ? "sent" : w.detail}; sweep: ${s.ok ? "sent" : s.detail}; dream: ${d.ok ? "sent" : d.detail}`,
161
+ Boolean(sandboxed) || (w.ok && s.ok && d.ok),
162
+ );
163
+ }
164
+
123
165
  // 6. memoryd managed restart (the watcher relaunches with the new binary)
124
166
  step("memoryd", `${stopMemorydByPidFile(paths.pidPath)} — the notifier watcher relaunches it with the new binary`);
125
167
 
@@ -30,9 +30,16 @@ import { readRolesManifest } from "../roles.js";
30
30
  import { readSlot, writeSlot, SLOT_PROVIDER } from "../slot.js";
31
31
  import { packageVersion } from "../version.js";
32
32
  import {
33
+ dreamTimerMessage,
34
+ DEFAULT_EVENT_TARGET,
35
+ DREAM_TRIGGER_ID,
33
36
  readWatcherTrigger,
37
+ registerTimer,
34
38
  registerWatcher,
39
+ sweepTimerMessage,
40
+ SWEEP_TRIGGER_ID,
35
41
  writeLauncherScript,
42
+ writeStaleCheckScript,
36
43
  WATCHER_TRIGGER_ID,
37
44
  } from "../watcher.js";
38
45
 
@@ -49,6 +56,8 @@ export type VerifyOptions = {
49
56
  staleMs?: number;
50
57
  /** Injectable for tests. */
51
58
  nowMs?: number;
59
+ /** Injectable for tests — repair MUST NOT reach the live notifier. */
60
+ iapeerBin?: string;
52
61
  };
53
62
 
54
63
  type RolesManifest = {
@@ -149,56 +158,123 @@ export function runVerify(opts: VerifyOptions = {}): CheckResult[] {
149
158
  });
150
159
  }
151
160
 
152
- // 3. notifier watcher registration. The durable trigger lives in the
153
- // REGISTRANT's peer profile (canonical storage contract notifier-runtime
154
- // fact, topic memoryd-watcher): registrant = index, whose cwd we know
155
- // from the roles manifest. Re-registration is ASYNC (the watcher session
156
- // writes the profile on message receipt) — repair reports "sent", a
157
- // re-run confirms.
161
+ // 3. notifier wiring (ADR-015). Durable triggers live in the REGISTRANT's
162
+ // peer profile (canonical storage contract): registrant = index for all
163
+ // three the EVENT trigger (target=copywriter, inverted pipeline), the
164
+ // sweep timer and the dream timer (both target=index). Re-registration is
165
+ // ASYNC (same id = replace) — repair reports "sent", a re-run confirms.
158
166
  {
159
167
  const manifest = readRolesManifest(paths.rolesManifestPath);
160
168
  const indexEntry = manifest?.roles.find((r) => r.role === "index") ?? null;
161
169
  if (!indexEntry) {
162
- results.push({
163
- name: "notifier-watcher",
164
- status: "skip",
165
- detail: "roles manifest has no index peer — init has not run",
166
- });
167
- } else {
168
- const trigger = readWatcherTrigger({ registrantCwd: indexEntry.peerCwd });
169
- const scriptOk = trigger?.script === paths.launcherPath;
170
- if (trigger && scriptOk) {
170
+ for (const name of ["notifier-watcher", "sweep-timer", "dream-timer"]) {
171
171
  results.push({
172
- name: "notifier-watcher",
173
- status: "ok",
174
- detail: `trigger ${WATCHER_TRIGGER_ID} in index profile target ${trigger.target ?? "?"}`,
172
+ name,
173
+ status: "skip",
174
+ detail: "roles manifest has no index peer init has not run",
175
175
  });
176
- } else {
176
+ }
177
+ } else {
178
+ const registrantCwd = indexEntry.peerCwd;
179
+ const checks: Array<{
180
+ name: string;
181
+ id: string;
182
+ role: "event" | "time";
183
+ expect: (t: NonNullable<ReturnType<typeof readWatcherTrigger>>) => string | null;
184
+ repairSend: () => { ok: boolean; detail: string };
185
+ }> = [
186
+ {
187
+ name: "notifier-watcher",
188
+ id: WATCHER_TRIGGER_ID,
189
+ role: "event",
190
+ expect: (t) =>
191
+ t.script !== paths.launcherPath
192
+ ? `script is ${t.script}, expected ${paths.launcherPath}`
193
+ : t.target !== DEFAULT_EVENT_TARGET
194
+ ? `target is ${t.target ?? "?"}, expected ${DEFAULT_EVENT_TARGET} (inverted pipeline)`
195
+ : null,
196
+ repairSend: () => {
197
+ writeLauncherScript({
198
+ launcherPath: paths.launcherPath,
199
+ binaryPath: paths.binaryPath,
200
+ });
201
+ return registerWatcher({
202
+ launcherPath: paths.launcherPath,
203
+ iapeerBin: opts.iapeerBin,
204
+ });
205
+ },
206
+ },
207
+ {
208
+ name: "sweep-timer",
209
+ id: SWEEP_TRIGGER_ID,
210
+ role: "time",
211
+ expect: (t) =>
212
+ (t as { check?: string }).check !== paths.checkScriptPath
213
+ ? `check is ${(t as { check?: string }).check ?? "?"}, expected ${paths.checkScriptPath}`
214
+ : t.target !== "index"
215
+ ? `target is ${t.target ?? "?"}, expected index`
216
+ : null,
217
+ repairSend: () => {
218
+ try {
219
+ const config = configFromEnv();
220
+ writeStaleCheckScript({
221
+ checkScriptPath: paths.checkScriptPath,
222
+ vaultPath: config.vaultPath,
223
+ inboxFolders: [
224
+ config.taxonomy.folders.inbox,
225
+ config.taxonomy.folders.inboxHuman,
226
+ ],
227
+ });
228
+ } catch {
229
+ // unprovisioned env — the registration alone still heals the trigger
230
+ }
231
+ return registerTimer({
232
+ message: sweepTimerMessage({ checkScriptPath: paths.checkScriptPath }),
233
+ iapeerBin: opts.iapeerBin,
234
+ });
235
+ },
236
+ },
237
+ {
238
+ name: "dream-timer",
239
+ id: DREAM_TRIGGER_ID,
240
+ role: "time",
241
+ expect: (t) =>
242
+ t.target !== "index" ? `target is ${t.target ?? "?"}, expected index` : null,
243
+ repairSend: () =>
244
+ registerTimer({ message: dreamTimerMessage(), iapeerBin: opts.iapeerBin }),
245
+ },
246
+ ];
247
+ for (const c of checks) {
248
+ const trigger = readWatcherTrigger({ registrantCwd, id: c.id, role: c.role });
177
249
  const problem = trigger
178
- ? `trigger script is ${trigger.script}, expected ${paths.launcherPath}`
179
- : `no ${WATCHER_TRIGGER_ID} trigger in ${indexEntry.peerCwd}/.iapeer/peer-profile.json`;
180
- if (!repair) {
181
- results.push({ name: "notifier-watcher", status: "fail", detail: problem });
182
- } else {
183
- writeLauncherScript({
184
- launcherPath: paths.launcherPath,
185
- binaryPath: paths.binaryPath,
250
+ ? c.expect(trigger)
251
+ : `no ${c.id} trigger in ${registrantCwd}/.iapeer/peer-profile.json`;
252
+ if (problem === null) {
253
+ results.push({
254
+ name: c.name,
255
+ status: "ok",
256
+ detail: `trigger ${c.id} in index profile → target ${trigger!.target ?? "?"}`,
186
257
  });
187
- const sent = registerWatcher({ launcherPath: paths.launcherPath });
188
- results.push(
189
- sent.ok
190
- ? {
191
- name: "notifier-watcher",
192
- status: "repaired",
193
- detail: `${problem} — re-registration sent (async; re-run verify to confirm)`,
194
- }
195
- : {
196
- name: "notifier-watcher",
197
- status: "fail",
198
- detail: `${problem}; re-registration failed — ${sent.detail}`,
199
- },
200
- );
258
+ continue;
201
259
  }
260
+ if (!repair) {
261
+ results.push({ name: c.name, status: "fail", detail: problem });
262
+ continue;
263
+ }
264
+ const sent = c.repairSend();
265
+ results.push(
266
+ sent.ok
267
+ ? {
268
+ name: c.name,
269
+ status: "repaired",
270
+ detail: `${problem} — re-registration sent (async; re-run verify to confirm)`,
271
+ }
272
+ : {
273
+ name: c.name,
274
+ status: "fail",
275
+ detail: `${problem}; re-registration failed — ${sent.detail}`,
276
+ },
277
+ );
202
278
  }
203
279
  }
204
280
  }
package/src/paths.ts CHANGED
@@ -50,6 +50,8 @@ export type MemoryPaths = {
50
50
  templatesDir: string;
51
51
  /** memoryd launcher — the notifier watcher's script (wraps the stable binary). */
52
52
  launcherPath: string;
53
+ /** Sweep check-script — gates the fail-open inbox sweep (ADR-015). */
54
+ checkScriptPath: string;
53
55
  };
54
56
 
55
57
  export function memoryPaths(
@@ -87,6 +89,7 @@ export function memoryPaths(
87
89
  env.IAPEER_MEMORY_BINARY_PATH || path.join(home, ".local", "bin", "iapeer-memory"),
88
90
  templatesDir: path.join(path.dirname(configFile), "templates"),
89
91
  launcherPath: path.join(path.dirname(configFile), "memoryd-launcher.sh"),
92
+ checkScriptPath: path.join(path.dirname(configFile), "inbox-stale-check.sh"),
90
93
  };
91
94
  }
92
95